import { useEffect, useRef, useState } from 'react'; import { AlertCircle, BellRing, CalendarClock, CheckCircle2, Clock, Database, Download, Folder, Mail, MessageSquare, PlayCircle, RefreshCw, Send, SlidersHorizontal, Upload, Wifi, 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 }); } 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); 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 })); }; return (

系统运行配置

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

{toast && (
{toast.type === 'success' ? ( ) : ( )} {toast.message}
)}

基础核心目录

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

高级运行策略 (Advanced Strategy)

自动化定时任务

{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 } }) } />
{isExportDialogOpen && ( } >
导出范围包含当前页面尚未保存的修改。请单独保管导出文件和口令。
setExportPassword(event.target.value)} />
setExportPasswordConfirm(event.target.value)} />
)} {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}
); }