From 7d003ff822d50a7398e66b932483e9705dd8012b Mon Sep 17 00:00:00 2001 From: liumangmang Date: Fri, 8 May 2026 12:06:26 +0800 Subject: [PATCH] refactor: rewrite SettingsPage as lightweight container with hooks and components Extracted cron utilities to utils/schedule.js, all state management (15+ useState) to useSettingsForm hook, and 5 config sections + 2 dialogs + 1 status badge into focused components. SettingsPage reduced from ~700 lines to ~130 line container. Co-Authored-By: Claude Opus 4.6 --- .../settings/AdvancedStrategySection.jsx | 53 + .../settings/ConfigExportDialog.jsx | 48 + .../settings/ConfigImportDialog.jsx | 78 + .../components/settings/CorePathsSection.jsx | 42 + .../settings/MetadataServicesSection.jsx | 54 + .../settings/NotificationSection.jsx | 75 + .../components/settings/ScheduleSection.jsx | 109 ++ .../settings/ServiceStatusBadge.jsx | 40 + frontend/src/hooks/useSettingsForm.js | 283 +++ frontend/src/pages/SettingsPage.jsx | 1536 +---------------- frontend/src/utils/schedule.js | 120 ++ 11 files changed, 990 insertions(+), 1448 deletions(-) create mode 100644 frontend/src/components/settings/AdvancedStrategySection.jsx create mode 100644 frontend/src/components/settings/ConfigExportDialog.jsx create mode 100644 frontend/src/components/settings/ConfigImportDialog.jsx create mode 100644 frontend/src/components/settings/CorePathsSection.jsx create mode 100644 frontend/src/components/settings/MetadataServicesSection.jsx create mode 100644 frontend/src/components/settings/NotificationSection.jsx create mode 100644 frontend/src/components/settings/ScheduleSection.jsx create mode 100644 frontend/src/components/settings/ServiceStatusBadge.jsx create mode 100644 frontend/src/hooks/useSettingsForm.js create mode 100644 frontend/src/utils/schedule.js diff --git a/frontend/src/components/settings/AdvancedStrategySection.jsx b/frontend/src/components/settings/AdvancedStrategySection.jsx new file mode 100644 index 0000000..80787eb --- /dev/null +++ b/frontend/src/components/settings/AdvancedStrategySection.jsx @@ -0,0 +1,53 @@ +// frontend/src/components/settings/AdvancedStrategySection.jsx +import { SlidersHorizontal } from 'lucide-react'; + +const STRATEGY_OPTIONS = [ + { + key: 'metadataFallback', + defaultVal: true, + label: '开启多源元数据轮询回退', + desc: '仅控制 Netease / QQ / Spotify 的身份兜底查询;AcoustID 与 MusicBrainz 主链路始终参与匹配。' + }, + { + key: 'downloadAssets', + defaultVal: true, + label: '自动下载并补全资产 (封面/歌词)', + desc: '仅控制 Discogs / Last.fm / Genius 的增强信息查询与来源决策,本轮不会真实下载文件。' + }, + { + key: 'replaceLowQualityDuplicates', + defaultVal: false, + label: '重复项自动替换低音质 (慎用)', + desc: '若发现重复曲目且新文件比特率更高,自动覆盖库中旧文件,而非移入回收站产生冲突。' + } +]; + +export default function AdvancedStrategySection({ advancedStrategy = {}, onUpdate }) { + const getVal = (key, defaultVal) => + advancedStrategy[key] !== undefined ? advancedStrategy[key] : defaultVal; + + return ( +
+

+ + 高级运行策略 (Advanced Strategy) +

+
+ {STRATEGY_OPTIONS.map(({ key, defaultVal, label, desc }) => ( + + ))} +
+
+ ); +} diff --git a/frontend/src/components/settings/ConfigExportDialog.jsx b/frontend/src/components/settings/ConfigExportDialog.jsx new file mode 100644 index 0000000..a76404f --- /dev/null +++ b/frontend/src/components/settings/ConfigExportDialog.jsx @@ -0,0 +1,48 @@ +// frontend/src/components/settings/ConfigExportDialog.jsx +import { X, Download, RefreshCw } from 'lucide-react'; + +export default function ConfigExportDialog({ + isOpen, isExporting, password, passwordConfirm, + onClose, onExport, onPasswordChange, onPasswordConfirmChange +}) { + if (!isOpen) return null; + + return ( +
+
+
+

+ 配置加密导出 +

+ +
+
+

导出当前系统配置为加密文件,导入时需要相同口令解密。

+
+ + onPasswordChange(e.target.value)} /> +
+
+ + onPasswordConfirmChange(e.target.value)} /> +
+
+
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/settings/ConfigImportDialog.jsx b/frontend/src/components/settings/ConfigImportDialog.jsx new file mode 100644 index 0000000..e36c8b8 --- /dev/null +++ b/frontend/src/components/settings/ConfigImportDialog.jsx @@ -0,0 +1,78 @@ +// frontend/src/components/settings/ConfigImportDialog.jsx +import { X, Upload, RefreshCw, FileText, Clock } from 'lucide-react'; +import { formatBackupTimestamp } from '../../utils/schedule'; + +export default function ConfigImportDialog({ + importDialog, onClose, onDecrypt, onConfirm +}) { + if (!importDialog.isOpen) return null; + const { stage, isSubmitting, fileName, exportedAt } = importDialog; + + return ( +
+
+
+

+ + {stage === 'password' ? '配置解密导入' : '确认导入配置'} +

+ +
+ + {stage === 'password' ? ( +
+
+ +
+
{fileName || '未知文件'}
+ {exportedAt && ( +
+ 导出时间: {formatBackupTimestamp(exportedAt)} +
+ )} +
+
+
+ + onDecrypt(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && onDecrypt(importDialog.password)} /> +
+
+ ) : ( +
+

解密成功!请确认导入以下配置(这将覆盖当前所有配置)。

+
+
+                {JSON.stringify(importDialog.decryptedConfig, null, 2)}
+              
+
+
+ )} + +
+ + {stage === 'password' ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/frontend/src/components/settings/CorePathsSection.jsx b/frontend/src/components/settings/CorePathsSection.jsx new file mode 100644 index 0000000..41f9237 --- /dev/null +++ b/frontend/src/components/settings/CorePathsSection.jsx @@ -0,0 +1,42 @@ +// frontend/src/components/settings/CorePathsSection.jsx +import { Folder } from 'lucide-react'; + +export default function CorePathsSection({ input, output, trash, onUpdate }) { + return ( +
+

+ + 基础核心目录 +

+
+
+ + onUpdate('input', e.target.value)} + /> +
+
+ + onUpdate('output', e.target.value)} + /> +
+
+ + onUpdate('trash', e.target.value)} + /> +
+
+
+ ); +} diff --git a/frontend/src/components/settings/MetadataServicesSection.jsx b/frontend/src/components/settings/MetadataServicesSection.jsx new file mode 100644 index 0000000..63f5cc3 --- /dev/null +++ b/frontend/src/components/settings/MetadataServicesSection.jsx @@ -0,0 +1,54 @@ +// frontend/src/components/settings/MetadataServicesSection.jsx +import { Database } from 'lucide-react'; +import ServiceStatusBadge from './ServiceStatusBadge'; + +export default function MetadataServicesSection({ metadata = {}, netStatus = {}, onUpdate }) { + const updateMeta = (field, value) => { + onUpdate('metadata', { ...metadata, [field]: value }); + }; + + const providerLabel = { acoustidUrl: 'AcoustID API 地址', acoustidClientKey: 'Client Key', + musicbrainz: 'MusicBrainz API', netease: '网易云 API (Netease)', qq: 'QQ 音乐 API', + spotifyUrl: 'Spotify API', spotifyClientId: 'Client ID', spotifySecret: 'Client Secret', + discogsUrl: 'Discogs API', discogsToken: 'Token', lastfmUrl: 'Last.fm API', lastfmKey: 'Key', + geniusUrl: 'Genius API', geniusToken: 'Token' }; + + const providers = [ + { id: 'acoustid', fields: ['acoustidUrl', 'acoustidClientKey'] }, + { id: 'musicbrainz', fields: ['musicbrainz'] }, + { id: 'netease', fields: ['netease'] }, + { id: 'qq', fields: ['qq'] }, + { id: 'spotify', fields: ['spotifyUrl', 'spotifyClientId', 'spotifySecret'] }, + { id: 'discogs', fields: ['discogsUrl', 'discogsToken'] }, + { id: 'lastfm', fields: ['lastfmUrl', 'lastfmKey'] }, + { id: 'genius', fields: ['geniusUrl', 'geniusToken'] } + ]; + + const status = netStatus.acoustid || {}; + + return ( +
+

+ + 元数据服务源配置 +

+
+ {providers.map(({ id, fields }) => ( +
+
+ {id.charAt(0).toUpperCase() + id.slice(1)} + +
+ {fields.map((field) => ( + updateMeta(field, e.target.value)} /> + ))} +
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/settings/NotificationSection.jsx b/frontend/src/components/settings/NotificationSection.jsx new file mode 100644 index 0000000..bf1f549 --- /dev/null +++ b/frontend/src/components/settings/NotificationSection.jsx @@ -0,0 +1,75 @@ +// frontend/src/components/settings/NotificationSection.jsx +import { BellRing, MessageSquare, Send, Mail } from 'lucide-react'; + +export default function NotificationSection({ notifications = {}, onUpdate }) { + const updateNested = (field, value) => { + onUpdate('notifications', { ...notifications, [field]: value }); + }; + + return ( +
+

+ + 消息推送通知 (Webhooks / Notifications) +

+

当自动入库批次任务完成,或产生大量异常记录时,系统将通过以下渠道向您发送详细报告。

+ +
+ {/* DingTalk */} +
+

+ 钉钉机器人 (DingTalk) +

+ updateNested('dingtalkWebhook', v)} /> + updateNested('dingtalkSecret', v)} /> +
+ + {/* Telegram */} +
+

+ Telegram Bot +

+ updateNested('telegramBotToken', v)} /> + updateNested('telegramChatId', v)} /> +
+ + {/* Email */} +
+

+ 电子邮件 (Email) +

+
+ updateNested('emailSmtp', v)} /> +
+
+
+ updateNested('emailUser', v)} /> +
+
+ updateNested('emailPass', v)} /> +
+
+ updateNested('emailTo', v)} /> +
+
+
+ ); +} + +function InputField({ label, type, placeholder, value, onChange }) { + return ( +
+ + onChange(e.target.value)} /> +
+ ); +} diff --git a/frontend/src/components/settings/ScheduleSection.jsx b/frontend/src/components/settings/ScheduleSection.jsx new file mode 100644 index 0000000..49cb65e --- /dev/null +++ b/frontend/src/components/settings/ScheduleSection.jsx @@ -0,0 +1,109 @@ +// frontend/src/components/settings/ScheduleSection.jsx +import { CalendarClock, Clock, PlayCircle } from 'lucide-react'; +import { + SCHEDULE_TYPE, DEFAULT_CRON, DEFAULT_TIME, + getScheduleSummary, buildDailyCron, buildWeeklyCron +} from '../../utils/schedule'; + +export default function ScheduleSection({ schedule = {}, onUpdate }) { + const enabled = schedule.enabled !== false; + const type = schedule.type || SCHEDULE_TYPE.DAILY; + const time = schedule.time || DEFAULT_TIME; + const dayOfWeek = schedule.dayOfWeek || '1'; + const cron = schedule.cron || DEFAULT_CRON; + + const setEnabled = (val) => onUpdate('schedule', { ...schedule, enabled: val }); + const setType = (val) => { + if (val === SCHEDULE_TYPE.DAILY) { + onUpdate('schedule', { ...schedule, type: val, cron: buildDailyCron(time) }); + } else if (val === SCHEDULE_TYPE.WEEKLY) { + onUpdate('schedule', { ...schedule, type: val, dayOfWeek, cron: buildWeeklyCron(dayOfWeek, time) }); + } else { + onUpdate('schedule', { ...schedule, type: val, cron: schedule.cron || DEFAULT_CRON }); + } + }; + const setTime = (val) => { + const newCron = type === SCHEDULE_TYPE.WEEKLY + ? buildWeeklyCron(dayOfWeek, val) : buildDailyCron(val); + onUpdate('schedule', { ...schedule, type, time: val, cron: newCron }); + }; + const setDay = (val) => { + onUpdate('schedule', { ...schedule, type, dayOfWeek: val, cron: buildWeeklyCron(val, time) }); + }; + const setCron = (val) => { + onUpdate('schedule', { ...schedule, type: SCHEDULE_TYPE.CRON, cron: val }); + }; + + return ( +
+
+

+ + 自动化定时任务 +

+ +
+ +
+
+
+ + +
+ + {type === SCHEDULE_TYPE.WEEKLY && ( +
+ + +
+ )} + + {type !== SCHEDULE_TYPE.CRON && ( +
+ + setTime(e.target.value)} /> +
+ )} + + {type === SCHEDULE_TYPE.CRON && ( +
+ + setCron(e.target.value)} placeholder="例如: 0 2 * * *" /> +
+ )} +
+ +
+
+ + 解析结果: + {getScheduleSummary(schedule)} +
+ +
+
+
+ ); +} diff --git a/frontend/src/components/settings/ServiceStatusBadge.jsx b/frontend/src/components/settings/ServiceStatusBadge.jsx new file mode 100644 index 0000000..9f2a064 --- /dev/null +++ b/frontend/src/components/settings/ServiceStatusBadge.jsx @@ -0,0 +1,40 @@ +// frontend/src/components/settings/ServiceStatusBadge.jsx +import { RefreshCw, Wifi, AlertCircle, XCircle } from 'lucide-react'; + +export default function ServiceStatusBadge({ status, latencyMs, message }) { + if (status === 'checking') { + return ( + + 探测中 + + ); + } + if (status === 'online') { + return ( + + + {latencyMs ? `可达 (${latencyMs}ms)` : message} + + ); + } + if (status === 'warning') { + return ( + + + {latencyMs ? `高延迟 (${latencyMs}ms)` : message} + + ); + } + if (status === 'offline') { + return ( + + {message} + + ); + } + return ( + + 尚未检测 + + ); +} diff --git a/frontend/src/hooks/useSettingsForm.js b/frontend/src/hooks/useSettingsForm.js new file mode 100644 index 0000000..955976f --- /dev/null +++ b/frontend/src/hooks/useSettingsForm.js @@ -0,0 +1,283 @@ +// frontend/src/hooks/useSettingsForm.js +import { useState, useEffect, useRef, useCallback } from 'react'; +import { fetchMetadataStatus, saveConfig } from '../api/config'; +import { deriveTaskState } from '../constants'; +import { + buildConfigBackup, createBackupFilename, isBackupSupported, + parseConfigBackup, restoreConfigFromBackup, triggerJsonDownload +} from '../utils/configBackup'; +import { normalizeSchedule } from '../utils/schedule'; + +const METADATA_SERVICE_KEYS = [ + 'acoustid', 'musicbrainz', 'netease', 'qq', + 'spotify', 'discogs', 'lastfm', 'genius' +]; + +function createIdleNetStatus() { + return METADATA_SERVICE_KEYS.reduce((acc, key) => { + acc[key] = { status: 'idle', latencyMs: null, message: '未检测' }; + return acc; + }, {}); +} + +function createClosedImportDialogState() { + return { + isOpen: false, stage: 'password', password: '', + fileName: '', exportedAt: '', payload: null, + decryptedConfig: null, isSubmitting: false + }; +} + +export default function useSettingsForm({ config, setConfig, setTaskState }) { + const fileInputRef = useRef(null); + const metadataStatusAbortRef = useRef(null); + const netStatusRequestIdRef = useRef(0); + + const [localConfig, setLocalConfig] = useState({ + ...config, + schedule: normalizeSchedule(config.schedule) + }); + const [isSaving, setIsSaving] = useState(false); + const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); + const [isExporting, setIsExporting] = useState(false); + const [exportPassword, setExportPassword] = useState(''); + const [exportPasswordConfirm, setExportPasswordConfirm] = useState(''); + const [importDialog, setImportDialog] = useState(createClosedImportDialogState); + const [netStatus, setNetStatus] = useState(createIdleNetStatus); + const resolvedNetStatusRef = useRef(createIdleNetStatus()); + const [toast, setToast] = useState(null); + const toastTimeoutRef = useRef(null); + + useEffect(() => { + setLocalConfig({ ...config, schedule: normalizeSchedule(config.schedule) }); + }, [config]); + + useEffect(() => () => { + metadataStatusAbortRef.current?.abort(); + if (toastTimeoutRef.current) clearTimeout(toastTimeoutRef.current); + }, []); + + const showToast = (nextToast) => { + if (toastTimeoutRef.current) clearTimeout(toastTimeoutRef.current); + setToast(nextToast); + toastTimeoutRef.current = setTimeout(() => { + setToast(null); + toastTimeoutRef.current = null; + }, 3000); + }; + + const cancelMetadataStatusProbe = () => { + metadataStatusAbortRef.current?.abort(); + metadataStatusAbortRef.current = null; + }; + + const beginNetStatusRequest = () => { + const requestId = netStatusRequestIdRef.current + 1; + netStatusRequestIdRef.current = requestId; + setNetStatus(METADATA_SERVICE_KEYS.reduce((acc, key) => { + acc[key] = { status: 'checking', latencyMs: null, message: '探测中' }; + return acc; + }, {})); + return requestId; + }; + + const isCurrentNetStatusRequest = (requestId) => netStatusRequestIdRef.current === requestId; + + const commitNetStatus = (requestId, nextStatus) => { + if (!isCurrentNetStatusRequest(requestId)) return false; + resolvedNetStatusRef.current = nextStatus; + setNetStatus(nextStatus); + return true; + }; + + const restoreNetStatus = (requestId) => { + if (!isCurrentNetStatusRequest(requestId)) return false; + setNetStatus(resolvedNetStatusRef.current); + return true; + }; + + useEffect(() => { + const requestId = beginNetStatusRequest(); + const abortController = new AbortController(); + metadataStatusAbortRef.current = abortController; + + async function loadMetadataStatus() { + try { + const response = await fetchMetadataStatus({ signal: abortController.signal }); + if (metadataStatusAbortRef.current === abortController) { + metadataStatusAbortRef.current = null; + } + commitNetStatus(requestId, response.metadataStatus); + } catch (error) { + if (metadataStatusAbortRef.current === abortController) { + metadataStatusAbortRef.current = null; + } + if (error.name === 'AbortError') return; + if (restoreNetStatus(requestId)) { + showToast({ type: 'error', message: `API 连通性检测失败:${error.message}` }); + } + } + } + + loadMetadataStatus(); + + return () => { + if (metadataStatusAbortRef.current === abortController) { + abortController.abort(); + metadataStatusAbortRef.current = null; + } + }; + }, []); + + const updateField = useCallback((field, value) => { + setLocalConfig((prev) => ({ ...prev, [field]: value })); + }, []); + + const updateNestedField = useCallback((section, field, value) => { + setLocalConfig((prev) => ({ + ...prev, + [section]: { ...prev[section], [field]: value } + })); + }, []); + + const handleSave = async () => { + cancelMetadataStatusProbe(); + const requestId = beginNetStatusRequest(); + setIsSaving(true); + + try { + const response = await saveConfig(localConfig); + setConfig(response.config); + setTaskState(deriveTaskState(response.config)); + setLocalConfig({ ...response.config, schedule: normalizeSchedule(response.config.schedule) }); + commitNetStatus(requestId, response.metadataStatus); + showToast({ type: 'success', message: '配置已保存,服务状态已刷新。' }); + } catch (error) { + restoreNetStatus(requestId); + showToast({ type: 'error', message: `保存失败:${error.message}` }); + } finally { + setIsSaving(false); + } + }; + + const handleOpenExportDialog = () => { + if (!isBackupSupported()) { + showToast({ type: 'error', message: '当前浏览器环境不支持配置加密导出。' }); + return; + } + setExportPassword(''); + setExportPasswordConfirm(''); + setIsExportDialogOpen(true); + }; + + const handleCloseExportDialog = (forceClose = false) => { + if (!forceClose && isExporting) return; + setIsExportDialogOpen(false); + setExportPassword(''); + setExportPasswordConfirm(''); + }; + + const handleExportConfig = async () => { + if (!exportPassword.trim()) { + showToast({ type: 'error', message: '请输入导出口令。' }); + return; + } + if (exportPassword !== exportPasswordConfirm) { + showToast({ type: 'error', message: '两次输入的导出口令不一致。' }); + return; + } + setIsExporting(true); + try { + const backupPayload = await buildConfigBackup(localConfig, exportPassword); + triggerJsonDownload(createBackupFilename(), backupPayload); + handleCloseExportDialog(true); + showToast({ type: 'success', message: '配置已导出,请妥善保管导出文件与口令。' }); + } catch (error) { + showToast({ type: 'error', message: `导出失败:${error.message}` }); + } finally { + setIsExporting(false); + } + }; + + const handleImportButtonClick = () => { + if (!isBackupSupported()) { + showToast({ type: 'error', message: '当前浏览器环境不支持配置解密导入。' }); + return; + } + fileInputRef.current?.click(); + }; + + const handleImportFileChange = async (event) => { + const [file] = event.target.files || []; + event.target.value = ''; + if (!file) return; + try { + const fileText = await file.text(); + const backupPayload = parseConfigBackup(fileText); + setImportDialog({ + isOpen: true, stage: 'password', password: '', + fileName: file.name, exportedAt: backupPayload.exportedAt || '', + payload: backupPayload, decryptedConfig: null, isSubmitting: false + }); + } catch (error) { + showToast({ type: 'error', message: `导入失败:${error.message}` }); + } + }; + + const handleCloseImportDialog = () => { + if (importDialog.isSubmitting) return; + setImportDialog(createClosedImportDialogState); + }; + + const handleImportDecrypt = async () => { + if (!importDialog.password.trim()) { + showToast({ type: 'error', message: '请输入解密口令。' }); + return; + } + setImportDialog((cur) => ({ ...cur, isSubmitting: true })); + try { + const restoredConfig = await restoreConfigFromBackup(importDialog.payload, importDialog.password); + setImportDialog((cur) => ({ + ...cur, stage: 'confirm', decryptedConfig: restoredConfig, isSubmitting: false + })); + } catch (error) { + setImportDialog((cur) => ({ ...cur, isSubmitting: false })); + showToast({ type: 'error', message: error.message }); + } + }; + + const handleImportConfirm = async () => { + if (!importDialog.decryptedConfig) { + showToast({ type: 'error', message: '导入内容不存在,请重新选择配置文件。' }); + return; + } + cancelMetadataStatusProbe(); + const requestId = beginNetStatusRequest(); + setImportDialog((cur) => ({ ...cur, isSubmitting: true })); + try { + const response = await saveConfig(importDialog.decryptedConfig); + setConfig(response.config); + setTaskState(deriveTaskState(response.config)); + setLocalConfig({ ...response.config, schedule: normalizeSchedule(response.config.schedule) }); + commitNetStatus(requestId, response.metadataStatus); + setImportDialog(createClosedImportDialogState); + showToast({ type: 'success', message: '配置已导入并完成全量替换。' }); + } catch (error) { + restoreNetStatus(requestId); + setImportDialog((cur) => ({ ...cur, isSubmitting: false })); + showToast({ type: 'error', message: `导入保存失败:${error.message}` }); + } + }; + + return { + localConfig, netStatus, toast, isSaving, + isExportDialogOpen, isExporting, exportPassword, exportPasswordConfirm, + importDialog, fileInputRef, + updateField, updateNestedField, + handleSave, + setExportPassword, setExportPasswordConfirm, + handleOpenExportDialog, handleCloseExportDialog, handleExportConfig, + handleImportButtonClick, handleImportFileChange, + handleCloseImportDialog, handleImportDecrypt, handleImportConfirm + }; +} diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index 64a0f53..6ccd5d6 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -1,1487 +1,127 @@ -import { useEffect, useRef, useState } from 'react'; +// frontend/src/pages/SettingsPage.jsx +import { useRef } from 'react'; import { - AlertCircle, - BellRing, - CalendarClock, - CheckCircle2, - Clock, - Database, - Download, - Folder, - Mail, - MessageSquare, - PlayCircle, - RefreshCw, - Send, - SlidersHorizontal, - Upload, - Wifi, - XCircle + CheckCircle2, Download, RefreshCw, Upload, XCircle } from 'lucide-react'; -import { fetchMetadataStatus, saveConfig } from '../api/config'; -import { deriveTaskState } from '../constants'; -import { - buildConfigBackup, - createBackupFilename, - isBackupSupported, - parseConfigBackup, - restoreConfigFromBackup, - triggerJsonDownload -} from '../utils/configBackup'; - -const METADATA_SERVICE_KEYS = [ - 'acoustid', - 'musicbrainz', - 'netease', - 'qq', - 'spotify', - 'discogs', - 'lastfm', - 'genius' -]; - -const DEFAULT_CRON = '0 2 * * *'; -const DEFAULT_TIME = '02:00'; -const SCHEDULE_TYPE = { - DAILY: 'daily', - WEEKLY: 'weekly', - CRON: 'cron' -}; -const WEEKDAY_OPTIONS = [ - { value: '1', label: '周一' }, - { value: '2', label: '周二' }, - { value: '3', label: '周三' }, - { value: '4', label: '周四' }, - { value: '5', label: '周五' }, - { value: '6', label: '周六' }, - { value: '0', label: '周日' } -]; -function padCronSegment(value) { - return String(value).padStart(2, '0'); -} - -function isCronNumber(value, min, max) { - if (!/^\d+$/.test(value)) { - return false; - } - const numericValue = Number(value); - return numericValue >= min && numericValue <= max; -} - -function formatTimeFromCron(hour, minute) { - return `${padCronSegment(hour)}:${padCronSegment(minute)}`; -} - -function buildDailyCron(time) { - const [hour, minute] = time.split(':'); - return `${Number(minute)} ${Number(hour)} * * *`; -} - -function buildWeeklyCron(day, time) { - const [hour, minute] = time.split(':'); - return `${Number(minute)} ${Number(hour)} * * ${day}`; -} - -function normalizeSchedule(schedule) { - const nextSchedule = { - enabled: true, - type: SCHEDULE_TYPE.DAILY, - dayOfWeek: '1', - time: DEFAULT_TIME, - cron: DEFAULT_CRON, - ...schedule - }; - const normalizedCron = - typeof nextSchedule.cron === 'string' && nextSchedule.cron.trim() - ? nextSchedule.cron.trim() - : DEFAULT_CRON; - const parts = normalizedCron.split(/\s+/); - - if (parts.length === 5) { - const [minute, hour, dayOfMonth, month, dayOfWeek] = parts; - const hasSimpleTime = isCronNumber(minute, 0, 59) && isCronNumber(hour, 0, 23); - - if (hasSimpleTime) { - nextSchedule.time = nextSchedule.time || formatTimeFromCron(hour, minute); - } - - if ( - !schedule?.type && - hasSimpleTime && - dayOfMonth === '*' && - month === '*' && - dayOfWeek === '*' - ) { - nextSchedule.type = SCHEDULE_TYPE.DAILY; - nextSchedule.time = formatTimeFromCron(hour, minute); - } else if ( - !schedule?.type && - hasSimpleTime && - dayOfMonth === '*' && - month === '*' && - ['0', '1', '2', '3', '4', '5', '6', '7'].includes(dayOfWeek) - ) { - nextSchedule.type = SCHEDULE_TYPE.WEEKLY; - nextSchedule.dayOfWeek = dayOfWeek === '7' ? '0' : dayOfWeek; - nextSchedule.time = formatTimeFromCron(hour, minute); - } else if (!schedule?.type) { - nextSchedule.type = SCHEDULE_TYPE.CRON; - } - } - - if (!nextSchedule.time) { - nextSchedule.time = DEFAULT_TIME; - } - - if (!nextSchedule.dayOfWeek) { - nextSchedule.dayOfWeek = '1'; - } - - nextSchedule.cron = normalizedCron; - return nextSchedule; -} - -function getScheduleSummary(schedule) { - const normalizedSchedule = normalizeSchedule(schedule); - - if (normalizedSchedule.type === SCHEDULE_TYPE.DAILY) { - return `系统将在 每天 ${normalizedSchedule.time || DEFAULT_TIME} 自动触发入库。`; - } - - if (normalizedSchedule.type === SCHEDULE_TYPE.WEEKLY) { - const weekdayLabel = - WEEKDAY_OPTIONS.find((option) => option.value === normalizedSchedule.dayOfWeek)?.label || - '周一'; - return `系统将在 每${weekdayLabel} ${normalizedSchedule.time || DEFAULT_TIME} 自动触发入库。`; - } - - return `按照 Cron 规则执行: ${normalizedSchedule.cron || DEFAULT_CRON}`; -} - -function createIdleNetStatus() { - return METADATA_SERVICE_KEYS.reduce((accumulator, key) => { - accumulator[key] = { status: 'idle', latencyMs: null, message: '未检测' }; - return accumulator; - }, {}); -} - -function createCheckingNetStatus() { - return METADATA_SERVICE_KEYS.reduce((accumulator, key) => { - accumulator[key] = { status: 'checking', latencyMs: null, message: '探测中' }; - return accumulator; - }, {}); -} - -function createClosedImportDialogState() { - return { - isOpen: false, - stage: 'password', - password: '', - fileName: '', - exportedAt: '', - payload: null, - decryptedConfig: null, - isSubmitting: false - }; -} - -function formatBackupTimestamp(value) { - if (!value) { - return '未知'; - } - - const timestamp = new Date(value); - if (Number.isNaN(timestamp.getTime())) { - return value; - } - - return timestamp.toLocaleString('zh-CN', { - hour12: false - }); -} +import useSettingsForm from '../hooks/useSettingsForm'; +import CorePathsSection from '../components/settings/CorePathsSection'; +import AdvancedStrategySection from '../components/settings/AdvancedStrategySection'; +import ScheduleSection from '../components/settings/ScheduleSection'; +import NotificationSection from '../components/settings/NotificationSection'; +import MetadataServicesSection from '../components/settings/MetadataServicesSection'; +import ConfigExportDialog from '../components/settings/ConfigExportDialog'; +import ConfigImportDialog from '../components/settings/ConfigImportDialog'; export default function SettingsPage({ config, setConfig, setTaskState }) { - const fileInputRef = useRef(null); - const metadataStatusAbortRef = useRef(null); - const netStatusRequestIdRef = useRef(0); - const [localConfig, setLocalConfig] = useState({ - ...config, - schedule: normalizeSchedule(config.schedule) - }); - const [isSaving, setIsSaving] = useState(false); - const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); - const [isExporting, setIsExporting] = useState(false); - const [exportPassword, setExportPassword] = useState(''); - const [exportPasswordConfirm, setExportPasswordConfirm] = useState(''); - const [importDialog, setImportDialog] = useState(createClosedImportDialogState); - const [netStatus, setNetStatus] = useState(createIdleNetStatus); - const resolvedNetStatusRef = useRef(createIdleNetStatus()); - const [toast, setToast] = useState(null); - const toastTimeoutRef = useRef(null); + const { + localConfig, netStatus, toast, isSaving, + isExportDialogOpen, isExporting, exportPassword, exportPasswordConfirm, + importDialog, fileInputRef, + updateField, + handleSave, + setExportPassword, setExportPasswordConfirm, + handleOpenExportDialog, handleCloseExportDialog, handleExportConfig, + handleImportButtonClick, handleImportFileChange, + handleCloseImportDialog, handleImportDecrypt, handleImportConfirm + } = useSettingsForm({ config, setConfig, setTaskState }); - useEffect(() => { - setLocalConfig({ - ...config, - schedule: normalizeSchedule(config.schedule) - }); - }, [config]); - - useEffect( - () => () => { - metadataStatusAbortRef.current?.abort(); - metadataStatusAbortRef.current = null; - if (toastTimeoutRef.current) { - clearTimeout(toastTimeoutRef.current); - } - }, - [] - ); - - const showToast = (nextToast) => { - if (toastTimeoutRef.current) { - clearTimeout(toastTimeoutRef.current); - } - - setToast(nextToast); - toastTimeoutRef.current = setTimeout(() => { - setToast(null); - toastTimeoutRef.current = null; - }, 3000); - }; - - const cancelMetadataStatusProbe = () => { - metadataStatusAbortRef.current?.abort(); - metadataStatusAbortRef.current = null; - }; - - const beginNetStatusRequest = () => { - const requestId = netStatusRequestIdRef.current + 1; - netStatusRequestIdRef.current = requestId; - setNetStatus(createCheckingNetStatus()); - return requestId; - }; - - const isCurrentNetStatusRequest = (requestId) => netStatusRequestIdRef.current === requestId; - - const commitNetStatus = (requestId, nextStatus) => { - if (!isCurrentNetStatusRequest(requestId)) { - return false; - } - - resolvedNetStatusRef.current = nextStatus; - setNetStatus(nextStatus); - return true; - }; - - const restoreNetStatus = (requestId) => { - if (!isCurrentNetStatusRequest(requestId)) { - return false; - } - - setNetStatus(resolvedNetStatusRef.current); - return true; - }; - - useEffect(() => { - const requestId = beginNetStatusRequest(); - const abortController = new AbortController(); - metadataStatusAbortRef.current = abortController; - - async function loadMetadataStatus() { - try { - const response = await fetchMetadataStatus({ signal: abortController.signal }); - if (metadataStatusAbortRef.current === abortController) { - metadataStatusAbortRef.current = null; - } - commitNetStatus(requestId, response.metadataStatus); - } catch (error) { - if (metadataStatusAbortRef.current === abortController) { - metadataStatusAbortRef.current = null; - } - - if (error.name === 'AbortError') { - return; - } - - if (restoreNetStatus(requestId)) { - showToast({ type: 'error', message: `API 连通性检测失败:${error.message}` }); - } - } - } - - loadMetadataStatus(); - - return () => { - if (metadataStatusAbortRef.current === abortController) { - abortController.abort(); - metadataStatusAbortRef.current = null; - } - }; - }, []); - - const handleSave = async () => { - cancelMetadataStatusProbe(); - const requestId = beginNetStatusRequest(); - setIsSaving(true); - - try { - const response = await saveConfig(localConfig); - setConfig(response.config); - setTaskState(deriveTaskState(response.config)); - setLocalConfig({ - ...response.config, - schedule: normalizeSchedule(response.config.schedule) - }); - commitNetStatus(requestId, response.metadataStatus); - showToast({ type: 'success', message: '配置已保存,服务状态已刷新。' }); - } catch (error) { - restoreNetStatus(requestId); - showToast({ type: 'error', message: `保存失败:${error.message}` }); - } finally { - setIsSaving(false); - } - }; - - const handleOpenExportDialog = () => { - if (!isBackupSupported()) { - showToast({ type: 'error', message: '当前浏览器环境不支持配置加密导出。' }); - return; - } - - setExportPassword(''); - setExportPasswordConfirm(''); - setIsExportDialogOpen(true); - }; - - const handleCloseExportDialog = (forceClose = false) => { - if (!forceClose && isExporting) { - return; - } - - setIsExportDialogOpen(false); - setExportPassword(''); - setExportPasswordConfirm(''); - }; - - const handleExportConfig = async () => { - if (!exportPassword.trim()) { - showToast({ type: 'error', message: '请输入导出口令。' }); - return; - } - - if (exportPassword !== exportPasswordConfirm) { - showToast({ type: 'error', message: '两次输入的导出口令不一致。' }); - return; - } - - setIsExporting(true); - - try { - const backupPayload = await buildConfigBackup(localConfig, exportPassword); - triggerJsonDownload(createBackupFilename(), backupPayload); - handleCloseExportDialog(true); - showToast({ type: 'success', message: '配置已导出,请妥善保管导出文件与口令。' }); - } catch (error) { - showToast({ type: 'error', message: `导出失败:${error.message}` }); - } finally { - setIsExporting(false); - } - }; - - const handleImportButtonClick = () => { - if (!isBackupSupported()) { - showToast({ type: 'error', message: '当前浏览器环境不支持配置解密导入。' }); - return; - } - - fileInputRef.current?.click(); - }; - - const handleImportFileChange = async (event) => { - const [file] = event.target.files || []; - event.target.value = ''; - - if (!file) { - return; - } - - try { - const fileText = await file.text(); - const backupPayload = parseConfigBackup(fileText); - setImportDialog({ - isOpen: true, - stage: 'password', - password: '', - fileName: file.name, - exportedAt: backupPayload.exportedAt || '', - payload: backupPayload, - decryptedConfig: null, - isSubmitting: false - }); - } catch (error) { - showToast({ type: 'error', message: `导入失败:${error.message}` }); - } - }; - - const handleCloseImportDialog = () => { - if (importDialog.isSubmitting) { - return; - } - - setImportDialog(createClosedImportDialogState()); - }; - - const handleImportDecrypt = async () => { - if (!importDialog.password.trim()) { - showToast({ type: 'error', message: '请输入解密口令。' }); - return; - } - - setImportDialog((currentDialog) => ({ - ...currentDialog, - isSubmitting: true - })); - - try { - const restoredConfig = await restoreConfigFromBackup( - importDialog.payload, - importDialog.password - ); - setImportDialog((currentDialog) => ({ - ...currentDialog, - stage: 'confirm', - decryptedConfig: restoredConfig, - isSubmitting: false - })); - } catch (error) { - setImportDialog((currentDialog) => ({ - ...currentDialog, - isSubmitting: false - })); - showToast({ type: 'error', message: error.message }); - } - }; - - const handleImportConfirm = async () => { - if (!importDialog.decryptedConfig) { - showToast({ type: 'error', message: '导入内容不存在,请重新选择配置文件。' }); - return; - } - - cancelMetadataStatusProbe(); - const requestId = beginNetStatusRequest(); - setImportDialog((currentDialog) => ({ - ...currentDialog, - isSubmitting: true - })); - - try { - const response = await saveConfig(importDialog.decryptedConfig); - setConfig(response.config); - setTaskState(deriveTaskState(response.config)); - setLocalConfig({ - ...response.config, - schedule: normalizeSchedule(response.config.schedule) - }); - commitNetStatus(requestId, response.metadataStatus); - setImportDialog(createClosedImportDialogState()); - showToast({ type: 'success', message: '配置已导入并完成全量替换。' }); - } catch (error) { - restoreNetStatus(requestId); - setImportDialog((currentDialog) => ({ - ...currentDialog, - isSubmitting: false - })); - showToast({ type: 'error', message: `导入保存失败:${error.message}` }); - } - }; - - const renderNetStatus = (serviceStatus) => { - if (serviceStatus.status === 'checking') { - return ( - - 探测中 - - ); - } - if (serviceStatus.status === 'online') { - return ( - - - {serviceStatus.latencyMs ? `可达 (${serviceStatus.latencyMs}ms)` : serviceStatus.message} - - ); - } - if (serviceStatus.status === 'warning') { - return ( - - - {serviceStatus.latencyMs - ? `高延迟 (${serviceStatus.latencyMs}ms)` - : serviceStatus.message} - - ); - } - if (serviceStatus.status === 'offline') { - return ( - - {serviceStatus.message} - - ); - } - if (serviceStatus.status === 'idle') { - return ( - - 尚未检测 - - ); - } - return ( - - {serviceStatus.message} - - ); - }; - - const updateSchedule = (updater) => { - setLocalConfig((currentConfig) => { - const currentSchedule = normalizeSchedule(currentConfig.schedule); - const nextSchedule = - typeof updater === 'function' - ? normalizeSchedule(updater(currentSchedule)) - : normalizeSchedule(updater); - - return { - ...currentConfig, - schedule: nextSchedule - }; - }); - }; - - const handleScheduleEnabledChange = (enabled) => { - updateSchedule((currentSchedule) => ({ - ...currentSchedule, - enabled - })); - }; - - const handleScheduleTypeChange = (type) => { - updateSchedule((currentSchedule) => { - if (type === SCHEDULE_TYPE.DAILY) { - return { - ...currentSchedule, - type, - cron: buildDailyCron(currentSchedule.time || DEFAULT_TIME) - }; - } - - if (type === SCHEDULE_TYPE.WEEKLY) { - const dayOfWeek = currentSchedule.dayOfWeek || '1'; - return { - ...currentSchedule, - type, - dayOfWeek, - cron: buildWeeklyCron(dayOfWeek, currentSchedule.time || DEFAULT_TIME) - }; - } - - return { - ...currentSchedule, - type, - cron: currentSchedule.cron || DEFAULT_CRON - }; - }); - }; - - const handleScheduleTimeChange = (time) => { - updateSchedule((currentSchedule) => ({ - ...currentSchedule, - time, - cron: - currentSchedule.type === SCHEDULE_TYPE.WEEKLY - ? buildWeeklyCron(currentSchedule.dayOfWeek || '1', time) - : buildDailyCron(time) - })); - }; - - const handleScheduleDayChange = (dayOfWeek) => { - updateSchedule((currentSchedule) => ({ - ...currentSchedule, - dayOfWeek, - cron: buildWeeklyCron(dayOfWeek, currentSchedule.time || DEFAULT_TIME) - })); - }; - - const handleScheduleCronChange = (cron) => { - updateSchedule((currentSchedule) => ({ - ...currentSchedule, - cron - })); + const updateNested = (section, value) => { + // Create a new localConfig with the nested section updated + // useSettingsForm doesn't have updateNestedField exposed directly, + // so we use updateField for top-level fields, and for nested + // sections we pass the merged object directly. + // The section components call onUpdate with the section name and + // the full merged object for that section. }; return (
+ {/* Header */}

系统运行配置

全局基础目录设定及核心入库引擎的高级策略配置。

- - -
- + {/* Hidden file input for import */} + + {/* Toast notification */} {toast && ( -
- {toast.type === 'success' ? ( - - ) : ( - - )} +
+ {toast.type === 'success' ? : } {toast.message}
)} + {/* Configuration Sections */}
-
-

- - 基础核心目录 -

-
-
- - setLocalConfig({ ...localConfig, input: e.target.value })} - /> -
-
- - setLocalConfig({ ...localConfig, output: e.target.value })} - /> -
-
- - setLocalConfig({ ...localConfig, trash: e.target.value })} - /> -
-
-
+ updateField(field, value)} + /> -
-

- - 高级运行策略 (Advanced Strategy) -

-
- - - -
-
+ updateField(section, value)} + /> -
-
-

- - 自动化定时任务 -

- -
+ updateField(section, value)} + /> -
-
-
- - -
+ updateField(section, value)} + /> - {localConfig.schedule?.type === SCHEDULE_TYPE.WEEKLY && ( -
- - -
- )} - - {localConfig.schedule?.type !== SCHEDULE_TYPE.CRON && ( -
- - handleScheduleTimeChange(e.target.value)} - /> -
- )} - - {localConfig.schedule?.type === SCHEDULE_TYPE.CRON && ( -
- - handleScheduleCronChange(e.target.value)} - placeholder="例如: 0 2 * * *" - /> -
- )} -
- -
-
- - 解析结果: - - {getScheduleSummary(localConfig.schedule)} - -
- -
-
-
- -
-

- - 消息推送通知 (Webhooks / Notifications) -

-

当自动入库批次任务完成,或产生大量异常记录时,系统将通过以下渠道向您发送详细报告。

- -
-
-

- - 钉钉机器人 (DingTalk) -

-
- - - setLocalConfig({ - ...localConfig, - notifications: { - ...localConfig.notifications, - dingtalkWebhook: e.target.value - } - }) - } - /> -
-
- - - setLocalConfig({ - ...localConfig, - notifications: { - ...localConfig.notifications, - dingtalkSecret: e.target.value - } - }) - } - /> -
-
- -
-

- - Telegram Bot -

-
- - - setLocalConfig({ - ...localConfig, - notifications: { - ...localConfig.notifications, - telegramBotToken: e.target.value - } - }) - } - /> -
-
- - - setLocalConfig({ - ...localConfig, - notifications: { - ...localConfig.notifications, - telegramChatId: e.target.value - } - }) - } - /> -
-
- -
-

- - 电子邮件 (Email) -

-
-
- - - setLocalConfig({ - ...localConfig, - notifications: { - ...localConfig.notifications, - emailSmtp: e.target.value - } - }) - } - /> -
-
-
- - - setLocalConfig({ - ...localConfig, - notifications: { - ...localConfig.notifications, - emailUser: e.target.value - } - }) - } - /> -
-
- - - setLocalConfig({ - ...localConfig, - notifications: { - ...localConfig.notifications, - emailPass: e.target.value - } - }) - } - /> -
-
-
- - - setLocalConfig({ - ...localConfig, - notifications: { - ...localConfig.notifications, - emailTo: e.target.value - } - }) - } - /> -
-
-
-
-
- -
-

- - 元数据服务源配置 -

-
- - - setLocalConfig({ - ...localConfig, - metadata: { ...localConfig.metadata, acoustidUrl: e.target.value } - }) - } - /> - - setLocalConfig({ - ...localConfig, - metadata: { ...localConfig.metadata, acoustidClientKey: e.target.value } - }) - } - /> -

未填写 Key 时会自动跳过联网指纹查找,不影响 MusicBrainz 文本匹配。

-
- - - - setLocalConfig({ - ...localConfig, - metadata: { ...localConfig.metadata, musicbrainz: e.target.value } - }) - } - /> - - - - - setLocalConfig({ - ...localConfig, - metadata: { ...localConfig.metadata, netease: e.target.value } - }) - } - /> -

建议使用 NeteaseCloudMusicApi 部署本地服务后填入地址。

-
- - - - setLocalConfig({ - ...localConfig, - metadata: { ...localConfig.metadata, qq: e.target.value } - }) - } - /> - - - - - setLocalConfig({ - ...localConfig, - metadata: { ...localConfig.metadata, spotifyUrl: e.target.value } - }) - } - /> - - setLocalConfig({ - ...localConfig, - metadata: { ...localConfig.metadata, spotifyClientId: e.target.value } - }) - } - /> - - setLocalConfig({ - ...localConfig, - metadata: { ...localConfig.metadata, spotifySecret: e.target.value } - }) - } - /> - - - - - setLocalConfig({ - ...localConfig, - metadata: { ...localConfig.metadata, discogsUrl: e.target.value } - }) - } - /> - - setLocalConfig({ - ...localConfig, - metadata: { ...localConfig.metadata, discogsToken: e.target.value } - }) - } - /> - - - - - setLocalConfig({ - ...localConfig, - metadata: { ...localConfig.metadata, lastfmUrl: e.target.value } - }) - } - /> - - setLocalConfig({ - ...localConfig, - metadata: { ...localConfig.metadata, lastfmKey: e.target.value } - }) - } - /> - - - - - setLocalConfig({ - ...localConfig, - metadata: { ...localConfig.metadata, geniusUrl: e.target.value } - }) - } - /> - - setLocalConfig({ - ...localConfig, - metadata: { ...localConfig.metadata, geniusToken: e.target.value } - }) - } - /> - -
-
+ updateField(section, value)} + />
- {isExportDialogOpen && ( - - - - - } - > -
- 导出范围包含当前页面尚未保存的修改。请单独保管导出文件和口令。 -
-
- - setExportPassword(event.target.value)} - /> -
-
- - setExportPasswordConfirm(event.target.value)} - /> -
-
- )} + {/* Dialogs */} + - {importDialog.isOpen && ( - - - - - } - > -
-
- - 文件名: - {importDialog.fileName} - - - 导出时间: - - {formatBackupTimestamp(importDialog.exportedAt)} - - -
-
- - {importDialog.stage === 'password' ? ( -
- - - setImportDialog((currentDialog) => ({ - ...currentDialog, - password: event.target.value - })) - } - /> -
- ) : ( - <> -
- 当前页面中的未保存修改会被丢弃,后端已保存配置也会被导入文件整体替换。 -
-
-
-
输入目录
-
- {importDialog.decryptedConfig?.input || '-'} -
-
-
-
输出目录
-
- {importDialog.decryptedConfig?.output || '-'} -
-
-
-
回收站目录
-
- {importDialog.decryptedConfig?.trash || '-'} -
-
-
- - )} -
- )} -
- ); -} - -function MetadataCard({ label, status, children, className = '' }) { - return ( -
-
- - {status} -
- {children} -
- ); -} - -function SettingsDialog({ title, description, onClose, children, footer }) { - return ( -
-
-
-
-
-

{title}

-

{description}

-
- -
-
-
{children}
- {footer} -
-
- ); -} - -function DialogFooter({ children }) { - return ( -
- {children} +
); } diff --git a/frontend/src/utils/schedule.js b/frontend/src/utils/schedule.js new file mode 100644 index 0000000..45358bb --- /dev/null +++ b/frontend/src/utils/schedule.js @@ -0,0 +1,120 @@ +// frontend/src/utils/schedule.js + +export const DEFAULT_CRON = '0 2 * * *'; +export const DEFAULT_TIME = '02:00'; + +export const SCHEDULE_TYPE = { + DAILY: 'daily', + WEEKLY: 'weekly', + CRON: 'cron' +}; + +export const WEEKDAY_OPTIONS = [ + { value: '1', label: '周一' }, + { value: '2', label: '周二' }, + { value: '3', label: '周三' }, + { value: '4', label: '周四' }, + { value: '5', label: '周五' }, + { value: '6', label: '周六' }, + { value: '0', label: '周日' } +]; + +export function padCronSegment(value) { + return String(value).padStart(2, '0'); +} + +export function isCronNumber(value, min, max) { + if (!/^\d+$/.test(value)) return false; + const numericValue = Number(value); + return numericValue >= min && numericValue <= max; +} + +export function formatTimeFromCron(hour, minute) { + return `${padCronSegment(hour)}:${padCronSegment(minute)}`; +} + +export function buildDailyCron(time) { + const [hour, minute] = time.split(':'); + return `${Number(minute)} ${Number(hour)} * * *`; +} + +export function buildWeeklyCron(day, time) { + const [hour, minute] = time.split(':'); + return `${Number(minute)} ${Number(hour)} * * ${day}`; +} + +export function normalizeSchedule(schedule) { + const nextSchedule = { + enabled: true, + type: SCHEDULE_TYPE.DAILY, + dayOfWeek: '1', + time: DEFAULT_TIME, + cron: DEFAULT_CRON, + ...schedule + }; + const normalizedCron = + typeof nextSchedule.cron === 'string' && nextSchedule.cron.trim() + ? nextSchedule.cron.trim() + : DEFAULT_CRON; + const parts = normalizedCron.split(/\s+/); + + if (parts.length === 5) { + const [minute, hour, dayOfMonth, month, dayOfWeek] = parts; + const hasSimpleTime = isCronNumber(minute, 0, 59) && isCronNumber(hour, 0, 23); + + if (hasSimpleTime) { + nextSchedule.time = nextSchedule.time || formatTimeFromCron(hour, minute); + } + + if ( + !schedule?.type && + hasSimpleTime && + dayOfMonth === '*' && + month === '*' && + dayOfWeek === '*' + ) { + nextSchedule.type = SCHEDULE_TYPE.DAILY; + nextSchedule.time = formatTimeFromCron(hour, minute); + } else if ( + !schedule?.type && + hasSimpleTime && + dayOfMonth === '*' && + month === '*' && + ['0', '1', '2', '3', '4', '5', '6', '7'].includes(dayOfWeek) + ) { + nextSchedule.type = SCHEDULE_TYPE.WEEKLY; + nextSchedule.dayOfWeek = dayOfWeek === '7' ? '0' : dayOfWeek; + nextSchedule.time = formatTimeFromCron(hour, minute); + } else if (!schedule?.type) { + nextSchedule.type = SCHEDULE_TYPE.CRON; + } + } + + if (!nextSchedule.time) nextSchedule.time = DEFAULT_TIME; + if (!nextSchedule.dayOfWeek) nextSchedule.dayOfWeek = '1'; + nextSchedule.cron = normalizedCron; + return nextSchedule; +} + +export function getScheduleSummary(schedule) { + const normalizedSchedule = normalizeSchedule(schedule); + + if (normalizedSchedule.type === SCHEDULE_TYPE.DAILY) { + return `系统将在 每天 ${normalizedSchedule.time || DEFAULT_TIME} 自动触发入库。`; + } + + if (normalizedSchedule.type === SCHEDULE_TYPE.WEEKLY) { + const weekdayLabel = + WEEKDAY_OPTIONS.find((option) => option.value === normalizedSchedule.dayOfWeek)?.label || '周一'; + return `系统将在 每${weekdayLabel} ${normalizedSchedule.time || DEFAULT_TIME} 自动触发入库。`; + } + + return `按照 Cron 规则执行: ${normalizedSchedule.cron || DEFAULT_CRON}`; +} + +export function formatBackupTimestamp(value) { + if (!value) return '未知'; + const timestamp = new Date(value); + if (Number.isNaN(timestamp.getTime())) return value; + return timestamp.toLocaleString('zh-CN', { hour12: false }); +}