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 (
全局基础目录设定及核心入库引擎的高级策略配置。
当自动入库批次任务完成,或产生大量异常记录时,系统将通过以下渠道向您发送详细报告。
未填写 Key 时会自动跳过联网指纹查找,不影响 MusicBrainz 文本匹配。
建议使用 NeteaseCloudMusicApi 部署本地服务后填入地址。
{description}