1488 lines
59 KiB
React
1488 lines
59 KiB
React
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 (
|
||
<span className="flex items-center rounded border border-slate-700/50 bg-slate-800/50 px-2 py-0.5 text-xs text-slate-400">
|
||
<RefreshCw className="mr-1.5 h-3 w-3 animate-spin" /> 探测中
|
||
</span>
|
||
);
|
||
}
|
||
if (serviceStatus.status === 'online') {
|
||
return (
|
||
<span className="flex items-center rounded border border-emerald-500/20 bg-emerald-500/10 px-2 py-0.5 text-xs text-emerald-400">
|
||
<Wifi className="mr-1.5 h-3 w-3" />
|
||
{serviceStatus.latencyMs ? `可达 (${serviceStatus.latencyMs}ms)` : serviceStatus.message}
|
||
</span>
|
||
);
|
||
}
|
||
if (serviceStatus.status === 'warning') {
|
||
return (
|
||
<span className="flex items-center rounded border border-amber-500/20 bg-amber-500/10 px-2 py-0.5 text-xs text-amber-400">
|
||
<AlertCircle className="mr-1.5 h-3 w-3" />
|
||
{serviceStatus.latencyMs
|
||
? `高延迟 (${serviceStatus.latencyMs}ms)`
|
||
: serviceStatus.message}
|
||
</span>
|
||
);
|
||
}
|
||
if (serviceStatus.status === 'offline') {
|
||
return (
|
||
<span className="flex items-center rounded border border-rose-500/20 bg-rose-500/10 px-2 py-0.5 text-xs text-rose-400">
|
||
<XCircle className="mr-1.5 h-3 w-3" /> {serviceStatus.message}
|
||
</span>
|
||
);
|
||
}
|
||
if (serviceStatus.status === 'idle') {
|
||
return (
|
||
<span className="flex items-center rounded border border-slate-700 bg-slate-800 px-2 py-0.5 text-xs text-slate-500">
|
||
尚未检测
|
||
</span>
|
||
);
|
||
}
|
||
return (
|
||
<span className="flex items-center rounded border border-slate-700 bg-slate-800 px-2 py-0.5 text-xs text-slate-500">
|
||
{serviceStatus.message}
|
||
</span>
|
||
);
|
||
};
|
||
|
||
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 (
|
||
<div className="w-full py-6">
|
||
<div className="mb-8 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||
<div>
|
||
<h2 className="mb-2 text-2xl font-bold text-white">系统运行配置</h2>
|
||
<p className="text-slate-400">全局基础目录设定及核心入库引擎的高级策略配置。</p>
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={handleImportButtonClick}
|
||
disabled={isSaving}
|
||
className="flex items-center whitespace-nowrap rounded-lg border border-slate-700 bg-slate-900 px-4 py-2.5 text-sm font-medium text-slate-200 transition hover:border-blue-500/50 hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-60"
|
||
>
|
||
<Upload className="mr-2 h-4 w-4" />
|
||
导入配置
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={handleOpenExportDialog}
|
||
disabled={isSaving}
|
||
className="flex items-center whitespace-nowrap rounded-lg border border-slate-700 bg-slate-900 px-4 py-2.5 text-sm font-medium text-slate-200 transition hover:border-emerald-500/50 hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-60"
|
||
>
|
||
<Download className="mr-2 h-4 w-4" />
|
||
导出配置
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={handleSave}
|
||
disabled={isSaving}
|
||
className="flex items-center whitespace-nowrap rounded-lg bg-blue-600 px-6 py-2.5 text-sm font-medium text-white shadow-lg shadow-blue-900/20 transition hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-70"
|
||
>
|
||
{isSaving ? (
|
||
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
||
) : (
|
||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||
)}
|
||
{isSaving ? '校验并保存...' : '保存系统配置'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept=".json,application/json"
|
||
className="hidden"
|
||
onChange={handleImportFileChange}
|
||
/>
|
||
|
||
{toast && (
|
||
<div
|
||
className={`fixed left-1/2 top-6 z-[100] flex -translate-x-1/2 items-center rounded-xl border px-5 py-3 shadow-2xl backdrop-blur-md animate-in slide-in-from-top-4 fade-in duration-300 ${
|
||
toast.type === 'success'
|
||
? 'border-emerald-500/50 bg-emerald-950/90 text-emerald-400'
|
||
: 'border-rose-500/50 bg-rose-950/90 text-rose-400'
|
||
}`}
|
||
>
|
||
{toast.type === 'success' ? (
|
||
<CheckCircle2 className="mr-2 h-5 w-5" />
|
||
) : (
|
||
<XCircle className="mr-2 h-5 w-5" />
|
||
)}
|
||
<span className="text-sm font-medium">{toast.message}</span>
|
||
</div>
|
||
)}
|
||
|
||
<div className="mb-8 space-y-6">
|
||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-6 shadow-lg">
|
||
<h3 className="mb-5 flex items-center border-b border-slate-800 pb-3 text-base font-semibold text-white">
|
||
<Folder className="mr-2 h-5 w-5 text-blue-400" />
|
||
基础核心目录
|
||
</h3>
|
||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||
<div>
|
||
<label className="mb-2 flex items-center text-sm font-medium text-white">输入目录 (待处理源)</label>
|
||
<input
|
||
type="text"
|
||
className="w-full rounded border border-slate-700 bg-slate-950 px-3 py-2 text-sm font-mono text-slate-200 focus:border-blue-500 focus:outline-none focus:ring-blue-500"
|
||
value={localConfig.input}
|
||
onChange={(e) => setLocalConfig({ ...localConfig, input: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="mb-2 flex items-center text-sm font-medium text-white">输出目录 (Navidrome主库)</label>
|
||
<input
|
||
type="text"
|
||
className="w-full rounded border border-slate-700 bg-slate-950 px-3 py-2 text-sm font-mono text-slate-200 focus:border-emerald-500 focus:outline-none focus:ring-emerald-500"
|
||
value={localConfig.output}
|
||
onChange={(e) => setLocalConfig({ ...localConfig, output: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="mb-2 flex items-center text-sm font-medium text-white">回收站目录 (异常与重复隔离)</label>
|
||
<input
|
||
type="text"
|
||
className="w-full rounded border border-slate-700 bg-slate-950 px-3 py-2 text-sm font-mono text-slate-200 focus:border-rose-500 focus:outline-none focus:ring-rose-500"
|
||
value={localConfig.trash}
|
||
onChange={(e) => setLocalConfig({ ...localConfig, trash: e.target.value })}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-6 shadow-lg">
|
||
<h3 className="mb-5 flex items-center border-b border-slate-800 pb-3 text-base font-semibold text-white">
|
||
<SlidersHorizontal className="mr-2 h-5 w-5 text-purple-400" />
|
||
高级运行策略 (Advanced Strategy)
|
||
</h3>
|
||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||
<label className="group flex h-full cursor-pointer items-start space-x-3 rounded-lg border border-slate-800/50 bg-slate-950/50 p-4 transition-colors hover:border-blue-500/30 hover:bg-slate-900">
|
||
<input
|
||
type="checkbox"
|
||
checked={localConfig.advancedStrategy?.metadataFallback ?? true}
|
||
onChange={(e) =>
|
||
setLocalConfig({
|
||
...localConfig,
|
||
advancedStrategy: {
|
||
...localConfig.advancedStrategy,
|
||
metadataFallback: e.target.checked
|
||
}
|
||
})
|
||
}
|
||
className="mt-1 shrink-0 cursor-pointer rounded border-slate-700 bg-slate-950 text-blue-500 focus:ring-blue-500"
|
||
/>
|
||
<div>
|
||
<span className="block text-sm font-medium text-white transition-colors group-hover:text-blue-400">开启多源元数据轮询回退</span>
|
||
<span className="mt-1.5 block text-xs leading-relaxed text-slate-500">仅控制 Netease / QQ / Spotify 的身份兜底查询;AcoustID 与 MusicBrainz 主链路始终参与匹配。</span>
|
||
</div>
|
||
</label>
|
||
<label className="group flex h-full cursor-pointer items-start space-x-3 rounded-lg border border-slate-800/50 bg-slate-950/50 p-4 transition-colors hover:border-blue-500/30 hover:bg-slate-900">
|
||
<input
|
||
type="checkbox"
|
||
checked={localConfig.advancedStrategy?.downloadAssets ?? true}
|
||
onChange={(e) =>
|
||
setLocalConfig({
|
||
...localConfig,
|
||
advancedStrategy: {
|
||
...localConfig.advancedStrategy,
|
||
downloadAssets: e.target.checked
|
||
}
|
||
})
|
||
}
|
||
className="mt-1 shrink-0 cursor-pointer rounded border-slate-700 bg-slate-950 text-blue-500 focus:ring-blue-500"
|
||
/>
|
||
<div>
|
||
<span className="block text-sm font-medium text-white transition-colors group-hover:text-blue-400">自动下载并补全资产 (封面/歌词)</span>
|
||
<span className="mt-1.5 block text-xs leading-relaxed text-slate-500">仅控制 Discogs / Last.fm / Genius 的增强信息查询与来源决策,本轮不会真实下载文件。</span>
|
||
</div>
|
||
</label>
|
||
<label className="group flex h-full cursor-pointer items-start space-x-3 rounded-lg border border-slate-800/50 bg-slate-950/50 p-4 transition-colors hover:border-blue-500/30 hover:bg-slate-900">
|
||
<input
|
||
type="checkbox"
|
||
checked={localConfig.advancedStrategy?.replaceLowQualityDuplicates ?? false}
|
||
onChange={(e) =>
|
||
setLocalConfig({
|
||
...localConfig,
|
||
advancedStrategy: {
|
||
...localConfig.advancedStrategy,
|
||
replaceLowQualityDuplicates: e.target.checked
|
||
}
|
||
})
|
||
}
|
||
className="mt-1 shrink-0 cursor-pointer rounded border-slate-700 bg-slate-950 text-blue-500 focus:ring-blue-500"
|
||
/>
|
||
<div>
|
||
<span className="block text-sm font-medium text-white transition-colors group-hover:text-blue-400">重复项自动替换低音质 (慎用)</span>
|
||
<span className="mt-1.5 block text-xs leading-relaxed text-slate-500">若发现重复曲目且新文件比特率更高,自动覆盖库中旧文件,而非移入回收站产生冲突。</span>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-slate-900 border border-slate-800 rounded-xl p-6 shadow-lg">
|
||
<div className="flex justify-between items-center mb-5 border-b border-slate-800 pb-3">
|
||
<h3 className="text-base font-semibold text-white flex items-center">
|
||
<CalendarClock className="w-5 h-5 mr-2 text-amber-400" />
|
||
自动化定时任务
|
||
</h3>
|
||
<label className="flex items-center cursor-pointer">
|
||
<div className="relative">
|
||
<input
|
||
type="checkbox"
|
||
className="sr-only"
|
||
checked={localConfig.schedule?.enabled}
|
||
onChange={(e) => handleScheduleEnabledChange(e.target.checked)}
|
||
/>
|
||
<div className={`block w-10 h-6 rounded-full transition-colors ${localConfig.schedule?.enabled ? 'bg-amber-500' : 'bg-slate-700'}`}></div>
|
||
<div className={`dot absolute left-1 top-1 bg-white w-4 h-4 rounded-full transition-transform ${localConfig.schedule?.enabled ? 'transform translate-x-4' : ''}`}></div>
|
||
</div>
|
||
<span className="ml-3 text-sm font-medium text-slate-300">
|
||
{localConfig.schedule?.enabled ? '已启用自动入库' : '已暂停自动入库'}
|
||
</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div className={`flex flex-col gap-4 transition-opacity ${localConfig.schedule?.enabled ? 'opacity-100' : 'opacity-40 pointer-events-none'}`}>
|
||
<div className="flex flex-wrap gap-4 items-end">
|
||
<div className="w-44 shrink-0">
|
||
<label className="flex items-center text-xs font-medium text-slate-400 mb-1.5">执行频率</label>
|
||
<select
|
||
className="w-full px-3 py-2 rounded bg-slate-950 border border-slate-700 text-slate-200 text-sm focus:ring-amber-500 focus:border-amber-500 focus:outline-none"
|
||
value={localConfig.schedule?.type || SCHEDULE_TYPE.DAILY}
|
||
onChange={(e) => handleScheduleTypeChange(e.target.value)}
|
||
>
|
||
<option value={SCHEDULE_TYPE.DAILY}>每天执行</option>
|
||
<option value={SCHEDULE_TYPE.WEEKLY}>每周执行</option>
|
||
<option value={SCHEDULE_TYPE.CRON}>专家模式 (Cron)</option>
|
||
</select>
|
||
</div>
|
||
|
||
{localConfig.schedule?.type === SCHEDULE_TYPE.WEEKLY && (
|
||
<div className="w-32 shrink-0">
|
||
<label className="flex items-center text-xs font-medium text-slate-400 mb-1.5">星期</label>
|
||
<select
|
||
className="w-full px-3 py-2 rounded bg-slate-950 border border-slate-700 text-slate-200 text-sm focus:ring-amber-500 focus:border-amber-500 focus:outline-none"
|
||
value={localConfig.schedule?.dayOfWeek || '1'}
|
||
onChange={(e) => handleScheduleDayChange(e.target.value)}
|
||
>
|
||
<option value="1">星期一</option>
|
||
<option value="2">星期二</option>
|
||
<option value="3">星期三</option>
|
||
<option value="4">星期四</option>
|
||
<option value="5">星期五</option>
|
||
<option value="6">星期六</option>
|
||
<option value="0">星期日</option>
|
||
</select>
|
||
</div>
|
||
)}
|
||
|
||
{localConfig.schedule?.type !== SCHEDULE_TYPE.CRON && (
|
||
<div className="w-32 shrink-0">
|
||
<label className="flex items-center text-xs font-medium text-slate-400 mb-1.5">具体时间</label>
|
||
<input
|
||
type="time"
|
||
className="w-full px-3 py-2 rounded bg-slate-950 border border-slate-700 text-slate-200 text-sm font-mono focus:ring-amber-500 focus:border-amber-500 focus:outline-none"
|
||
style={{ colorScheme: 'dark' }}
|
||
value={localConfig.schedule?.time || DEFAULT_TIME}
|
||
onChange={(e) => handleScheduleTimeChange(e.target.value)}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{localConfig.schedule?.type === SCHEDULE_TYPE.CRON && (
|
||
<div className="flex-1 min-w-[200px]">
|
||
<label className="flex items-center text-xs font-medium text-slate-400 mb-1.5">Cron 表达式</label>
|
||
<input
|
||
type="text"
|
||
className="w-full px-3 py-2 rounded bg-slate-950 border border-slate-700 text-amber-400 text-sm font-mono focus:ring-amber-500 focus:border-amber-500 focus:outline-none"
|
||
value={localConfig.schedule?.cron || DEFAULT_CRON}
|
||
onChange={(e) => handleScheduleCronChange(e.target.value)}
|
||
placeholder="例如: 0 2 * * *"
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between gap-4 rounded-xl border border-slate-800/90 bg-[#0b1328] px-4 py-3.5">
|
||
<div className="flex min-w-0 items-center text-sm">
|
||
<Clock className="mr-2 h-4 w-4 shrink-0 text-amber-400" />
|
||
<span className="mr-2 shrink-0 text-slate-200">解析结果:</span>
|
||
<span className="truncate font-medium text-amber-400">
|
||
{getScheduleSummary(localConfig.schedule)}
|
||
</span>
|
||
</div>
|
||
<button className="flex shrink-0 items-center justify-center rounded-lg border border-slate-600 bg-slate-700/70 px-4 py-2 text-sm font-medium text-slate-100 shadow-inner shadow-slate-950/30 transition hover:bg-slate-600/80">
|
||
<PlayCircle className="mr-1.5 h-4 w-4" /> 立即运行测试
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-6 shadow-lg">
|
||
<h3 className="mb-5 flex items-center border-b border-slate-800 pb-3 text-base font-semibold text-white">
|
||
<BellRing className="mr-2 h-5 w-5 text-sky-400" />
|
||
消息推送通知 (Webhooks / Notifications)
|
||
</h3>
|
||
<p className="mb-6 text-xs text-slate-400">当自动入库批次任务完成,或产生大量异常记录时,系统将通过以下渠道向您发送详细报告。</p>
|
||
|
||
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
|
||
<div className="flex flex-col gap-4 rounded-lg border border-slate-800/50 bg-slate-950/50 p-5">
|
||
<h4 className="flex items-center text-sm font-semibold text-white">
|
||
<MessageSquare className="mr-2 h-4 w-4 text-sky-500" />
|
||
钉钉机器人 (DingTalk)
|
||
</h4>
|
||
<div>
|
||
<label className="mb-1.5 block text-xs font-medium text-slate-400">Webhook 地址</label>
|
||
<input
|
||
type="text"
|
||
className="w-full rounded border border-slate-700 bg-slate-950 px-3 py-2 text-xs font-mono text-slate-200 focus:border-sky-500 focus:outline-none focus:ring-sky-500"
|
||
placeholder="https://oapi.dingtalk.com/robot/send?access_token=..."
|
||
value={localConfig.notifications?.dingtalkWebhook}
|
||
onChange={(e) =>
|
||
setLocalConfig({
|
||
...localConfig,
|
||
notifications: {
|
||
...localConfig.notifications,
|
||
dingtalkWebhook: e.target.value
|
||
}
|
||
})
|
||
}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="mb-1.5 block text-xs font-medium text-slate-400">加签密钥 (Secret - 可选)</label>
|
||
<input
|
||
type="password"
|
||
className="w-full rounded border border-slate-700 bg-slate-950 px-3 py-2 text-xs font-mono text-slate-200 focus:border-sky-500 focus:outline-none focus:ring-sky-500"
|
||
placeholder="SEC..."
|
||
value={localConfig.notifications?.dingtalkSecret}
|
||
onChange={(e) =>
|
||
setLocalConfig({
|
||
...localConfig,
|
||
notifications: {
|
||
...localConfig.notifications,
|
||
dingtalkSecret: e.target.value
|
||
}
|
||
})
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-4 rounded-lg border border-slate-800/50 bg-slate-950/50 p-5">
|
||
<h4 className="flex items-center text-sm font-semibold text-white">
|
||
<Send className="mr-2 h-4 w-4 text-sky-500" />
|
||
Telegram Bot
|
||
</h4>
|
||
<div>
|
||
<label className="mb-1.5 block text-xs font-medium text-slate-400">Bot Token</label>
|
||
<input
|
||
type="password"
|
||
className="w-full rounded border border-slate-700 bg-slate-950 px-3 py-2 text-xs font-mono text-slate-200 focus:border-sky-500 focus:outline-none focus:ring-sky-500"
|
||
placeholder="123456789:ABCdefGHIjkl..."
|
||
value={localConfig.notifications?.telegramBotToken}
|
||
onChange={(e) =>
|
||
setLocalConfig({
|
||
...localConfig,
|
||
notifications: {
|
||
...localConfig.notifications,
|
||
telegramBotToken: e.target.value
|
||
}
|
||
})
|
||
}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="mb-1.5 block text-xs font-medium text-slate-400">Chat ID (接收者)</label>
|
||
<input
|
||
type="text"
|
||
className="w-full rounded border border-slate-700 bg-slate-950 px-3 py-2 text-xs font-mono text-slate-200 focus:border-sky-500 focus:outline-none focus:ring-sky-500"
|
||
placeholder="例如: 12345678"
|
||
value={localConfig.notifications?.telegramChatId}
|
||
onChange={(e) =>
|
||
setLocalConfig({
|
||
...localConfig,
|
||
notifications: {
|
||
...localConfig.notifications,
|
||
telegramChatId: e.target.value
|
||
}
|
||
})
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-4 rounded-lg border border-slate-800/50 bg-slate-950/50 p-5">
|
||
<h4 className="flex items-center text-sm font-semibold text-white">
|
||
<Mail className="mr-2 h-4 w-4 text-sky-500" />
|
||
电子邮件 (Email)
|
||
</h4>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div className="col-span-2">
|
||
<label className="mb-1.5 block text-xs font-medium text-slate-400">SMTP 服务器</label>
|
||
<input
|
||
type="text"
|
||
className="w-full rounded border border-slate-700 bg-slate-950 px-3 py-2 text-xs font-mono text-slate-200 focus:border-sky-500 focus:outline-none focus:ring-sky-500"
|
||
placeholder="smtp.example.com:465"
|
||
value={localConfig.notifications?.emailSmtp}
|
||
onChange={(e) =>
|
||
setLocalConfig({
|
||
...localConfig,
|
||
notifications: {
|
||
...localConfig.notifications,
|
||
emailSmtp: e.target.value
|
||
}
|
||
})
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="col-span-2 flex gap-3">
|
||
<div className="flex-1">
|
||
<label className="mb-1.5 block text-xs font-medium text-slate-400">发件账号</label>
|
||
<input
|
||
type="text"
|
||
className="w-full rounded border border-slate-700 bg-slate-950 px-3 py-2 text-xs font-mono text-slate-200 focus:border-sky-500 focus:outline-none focus:ring-sky-500"
|
||
placeholder="bot@example.com"
|
||
value={localConfig.notifications?.emailUser}
|
||
onChange={(e) =>
|
||
setLocalConfig({
|
||
...localConfig,
|
||
notifications: {
|
||
...localConfig.notifications,
|
||
emailUser: e.target.value
|
||
}
|
||
})
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="flex-1">
|
||
<label className="mb-1.5 block text-xs font-medium text-slate-400">授权密码</label>
|
||
<input
|
||
type="password"
|
||
className="w-full rounded border border-slate-700 bg-slate-950 px-3 py-2 text-xs font-mono text-slate-200 focus:border-sky-500 focus:outline-none focus:ring-sky-500"
|
||
placeholder="********"
|
||
value={localConfig.notifications?.emailPass}
|
||
onChange={(e) =>
|
||
setLocalConfig({
|
||
...localConfig,
|
||
notifications: {
|
||
...localConfig.notifications,
|
||
emailPass: e.target.value
|
||
}
|
||
})
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="col-span-2">
|
||
<label className="mb-1.5 block text-xs font-medium text-slate-400">目标收件人</label>
|
||
<input
|
||
type="text"
|
||
className="w-full rounded border border-slate-700 bg-slate-950 px-3 py-2 text-xs font-mono text-slate-200 focus:border-sky-500 focus:outline-none focus:ring-sky-500"
|
||
placeholder="admin@example.com"
|
||
value={localConfig.notifications?.emailTo}
|
||
onChange={(e) =>
|
||
setLocalConfig({
|
||
...localConfig,
|
||
notifications: {
|
||
...localConfig.notifications,
|
||
emailTo: e.target.value
|
||
}
|
||
})
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-6 shadow-lg">
|
||
<h3 className="mb-5 flex items-center border-b border-slate-800 pb-3 text-base font-semibold text-white">
|
||
<Database className="mr-2 h-5 w-5 text-rose-400" />
|
||
元数据服务源配置
|
||
</h3>
|
||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||
<MetadataCard label="AcoustID (Chromaprint 指纹主链路)" status={renderNetStatus(netStatus.acoustid)}>
|
||
<input
|
||
type="text"
|
||
className="w-full rounded border border-slate-700 bg-slate-950 px-3 py-2 text-sm font-mono text-slate-400 focus:border-rose-500 focus:outline-none focus:ring-rose-500"
|
||
placeholder="API 地址"
|
||
value={localConfig.metadata?.acoustidUrl}
|
||
onChange={(e) =>
|
||
setLocalConfig({
|
||
...localConfig,
|
||
metadata: { ...localConfig.metadata, acoustidUrl: e.target.value }
|
||
})
|
||
}
|
||
/>
|
||
<input
|
||
type="password"
|
||
className="mt-auto w-full rounded border border-slate-700 bg-slate-950 px-3 py-2 text-sm font-mono text-slate-300 focus:border-rose-500 focus:outline-none focus:ring-rose-500"
|
||
placeholder="Client Key"
|
||
value={localConfig.metadata?.acoustidClientKey || ''}
|
||
onChange={(e) =>
|
||
setLocalConfig({
|
||
...localConfig,
|
||
metadata: { ...localConfig.metadata, acoustidClientKey: e.target.value }
|
||
})
|
||
}
|
||
/>
|
||
<p className="text-[11px] leading-tight text-slate-500">未填写 Key 时会自动跳过联网指纹查找,不影响 MusicBrainz 文本匹配。</p>
|
||
</MetadataCard>
|
||
|
||
<MetadataCard label="MusicBrainz (开放服务 - 默认推荐)" status={renderNetStatus(netStatus.musicbrainz)}>
|
||
<input
|
||
type="text"
|
||
className="mt-auto w-full rounded border border-slate-700 bg-slate-950 px-3 py-2 text-sm font-mono text-slate-300 focus:border-rose-500 focus:outline-none focus:ring-rose-500"
|
||
value={localConfig.metadata?.musicbrainz}
|
||
onChange={(e) =>
|
||
setLocalConfig({
|
||
...localConfig,
|
||
metadata: { ...localConfig.metadata, musicbrainz: e.target.value }
|
||
})
|
||
}
|
||
/>
|
||
</MetadataCard>
|
||
|
||
<MetadataCard label="网易云音乐 API (需自建服务代理)" status={renderNetStatus(netStatus.netease)}>
|
||
<input
|
||
type="text"
|
||
className="w-full rounded border border-slate-700 bg-slate-950 px-3 py-2 text-sm font-mono text-slate-300 focus:border-rose-500 focus:outline-none focus:ring-rose-500"
|
||
placeholder="例如: http://localhost:3000"
|
||
value={localConfig.metadata?.netease}
|
||
onChange={(e) =>
|
||
setLocalConfig({
|
||
...localConfig,
|
||
metadata: { ...localConfig.metadata, netease: e.target.value }
|
||
})
|
||
}
|
||
/>
|
||
<p className="mt-auto text-[11px] leading-tight text-slate-500">建议使用 NeteaseCloudMusicApi 部署本地服务后填入地址。</p>
|
||
</MetadataCard>
|
||
|
||
<MetadataCard label="QQ音乐 API (需自建服务代理)" status={renderNetStatus(netStatus.qq)}>
|
||
<input
|
||
type="text"
|
||
className="mt-auto w-full rounded border border-slate-700 bg-slate-950 px-3 py-2 text-sm font-mono text-slate-300 focus:border-rose-500 focus:outline-none focus:ring-rose-500"
|
||
placeholder="例如: http://localhost:3300"
|
||
value={localConfig.metadata?.qq}
|
||
onChange={(e) =>
|
||
setLocalConfig({
|
||
...localConfig,
|
||
metadata: { ...localConfig.metadata, qq: e.target.value }
|
||
})
|
||
}
|
||
/>
|
||
</MetadataCard>
|
||
|
||
<MetadataCard label="Spotify (官方 API)" status={renderNetStatus(netStatus.spotify)}>
|
||
<input
|
||
type="text"
|
||
className="w-full rounded border border-slate-700 bg-slate-950 px-3 py-2 text-sm font-mono text-slate-400 focus:border-rose-500 focus:outline-none focus:ring-rose-500"
|
||
placeholder="API 地址"
|
||
value={localConfig.metadata?.spotifyUrl}
|
||
onChange={(e) =>
|
||
setLocalConfig({
|
||
...localConfig,
|
||
metadata: { ...localConfig.metadata, spotifyUrl: e.target.value }
|
||
})
|
||
}
|
||
/>
|
||
<input
|
||
type="text"
|
||
className="mt-auto w-full rounded border border-slate-700 bg-slate-950 px-3 py-2 text-sm font-mono text-slate-300 focus:border-rose-500 focus:outline-none focus:ring-rose-500"
|
||
placeholder="Client ID"
|
||
value={localConfig.metadata?.spotifyClientId}
|
||
onChange={(e) =>
|
||
setLocalConfig({
|
||
...localConfig,
|
||
metadata: { ...localConfig.metadata, spotifyClientId: e.target.value }
|
||
})
|
||
}
|
||
/>
|
||
<input
|
||
type="password"
|
||
className="w-full rounded border border-slate-700 bg-slate-950 px-3 py-2 text-sm font-mono text-slate-300 focus:border-rose-500 focus:outline-none focus:ring-rose-500"
|
||
placeholder="Client Secret"
|
||
value={localConfig.metadata?.spotifySecret}
|
||
onChange={(e) =>
|
||
setLocalConfig({
|
||
...localConfig,
|
||
metadata: { ...localConfig.metadata, spotifySecret: e.target.value }
|
||
})
|
||
}
|
||
/>
|
||
</MetadataCard>
|
||
|
||
<MetadataCard label="Discogs (高精度实体唱片库)" status={renderNetStatus(netStatus.discogs)}>
|
||
<input
|
||
type="text"
|
||
className="w-full rounded border border-slate-700 bg-slate-950 px-3 py-2 text-sm font-mono text-slate-400 focus:border-rose-500 focus:outline-none focus:ring-rose-500"
|
||
placeholder="API 地址"
|
||
value={localConfig.metadata?.discogsUrl}
|
||
onChange={(e) =>
|
||
setLocalConfig({
|
||
...localConfig,
|
||
metadata: { ...localConfig.metadata, discogsUrl: e.target.value }
|
||
})
|
||
}
|
||
/>
|
||
<input
|
||
type="password"
|
||
className="mt-auto w-full rounded border border-slate-700 bg-slate-950 px-3 py-2 text-sm font-mono text-slate-300 focus:border-rose-500 focus:outline-none focus:ring-rose-500"
|
||
placeholder="Personal Access Token"
|
||
value={localConfig.metadata?.discogsToken || ''}
|
||
onChange={(e) =>
|
||
setLocalConfig({
|
||
...localConfig,
|
||
metadata: { ...localConfig.metadata, discogsToken: e.target.value }
|
||
})
|
||
}
|
||
/>
|
||
</MetadataCard>
|
||
|
||
<MetadataCard label="Last.fm (丰富的流派与标签库)" status={renderNetStatus(netStatus.lastfm)}>
|
||
<input
|
||
type="text"
|
||
className="w-full rounded border border-slate-700 bg-slate-950 px-3 py-2 text-sm font-mono text-slate-400 focus:border-rose-500 focus:outline-none focus:ring-rose-500"
|
||
placeholder="API 地址"
|
||
value={localConfig.metadata?.lastfmUrl}
|
||
onChange={(e) =>
|
||
setLocalConfig({
|
||
...localConfig,
|
||
metadata: { ...localConfig.metadata, lastfmUrl: e.target.value }
|
||
})
|
||
}
|
||
/>
|
||
<input
|
||
type="password"
|
||
className="mt-auto w-full rounded border border-slate-700 bg-slate-950 px-3 py-2 text-sm font-mono text-slate-300 focus:border-rose-500 focus:outline-none focus:ring-rose-500"
|
||
placeholder="API Key"
|
||
value={localConfig.metadata?.lastfmKey || ''}
|
||
onChange={(e) =>
|
||
setLocalConfig({
|
||
...localConfig,
|
||
metadata: { ...localConfig.metadata, lastfmKey: e.target.value }
|
||
})
|
||
}
|
||
/>
|
||
</MetadataCard>
|
||
|
||
<MetadataCard
|
||
label="Genius (精准歌词支持)"
|
||
status={renderNetStatus(netStatus.genius)}
|
||
className="md:col-span-2 lg:col-span-1"
|
||
>
|
||
<input
|
||
type="text"
|
||
className="w-full rounded border border-slate-700 bg-slate-950 px-3 py-2 text-sm font-mono text-slate-400 focus:border-rose-500 focus:outline-none focus:ring-rose-500"
|
||
placeholder="API 地址"
|
||
value={localConfig.metadata?.geniusUrl}
|
||
onChange={(e) =>
|
||
setLocalConfig({
|
||
...localConfig,
|
||
metadata: { ...localConfig.metadata, geniusUrl: e.target.value }
|
||
})
|
||
}
|
||
/>
|
||
<input
|
||
type="password"
|
||
className="mt-auto w-full rounded border border-slate-700 bg-slate-950 px-3 py-2 text-sm font-mono text-slate-300 focus:border-rose-500 focus:outline-none focus:ring-rose-500"
|
||
placeholder="Client Access Token"
|
||
value={localConfig.metadata?.geniusToken || ''}
|
||
onChange={(e) =>
|
||
setLocalConfig({
|
||
...localConfig,
|
||
metadata: { ...localConfig.metadata, geniusToken: e.target.value }
|
||
})
|
||
}
|
||
/>
|
||
</MetadataCard>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{isExportDialogOpen && (
|
||
<SettingsDialog
|
||
title="导出系统配置"
|
||
description="导出当前页面中的配置快照。敏感凭据会单独加密,导出文件不会明文包含密钥。"
|
||
onClose={handleCloseExportDialog}
|
||
footer={
|
||
<DialogFooter>
|
||
<button
|
||
type="button"
|
||
onClick={() => handleCloseExportDialog()}
|
||
disabled={isExporting}
|
||
className="rounded-lg border border-slate-700 bg-slate-900 px-4 py-2 text-sm font-medium text-slate-300 transition hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-60"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={handleExportConfig}
|
||
disabled={isExporting}
|
||
className="flex items-center rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-70"
|
||
>
|
||
{isExporting ? (
|
||
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Download className="mr-2 h-4 w-4" />
|
||
)}
|
||
{isExporting ? '导出中...' : '确认导出'}
|
||
</button>
|
||
</DialogFooter>
|
||
}
|
||
>
|
||
<div className="rounded-lg border border-emerald-500/20 bg-emerald-950/20 px-4 py-3 text-sm text-emerald-300">
|
||
导出范围包含当前页面尚未保存的修改。请单独保管导出文件和口令。
|
||
</div>
|
||
<div>
|
||
<label className="mb-2 block text-sm font-medium text-white">导出口令</label>
|
||
<input
|
||
type="password"
|
||
className="w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-slate-200 focus:border-emerald-500 focus:outline-none focus:ring-emerald-500"
|
||
placeholder="请输入用于加密敏感字段的口令"
|
||
value={exportPassword}
|
||
onChange={(event) => setExportPassword(event.target.value)}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="mb-2 block text-sm font-medium text-white">确认口令</label>
|
||
<input
|
||
type="password"
|
||
className="w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-slate-200 focus:border-emerald-500 focus:outline-none focus:ring-emerald-500"
|
||
placeholder="请再次输入相同口令"
|
||
value={exportPasswordConfirm}
|
||
onChange={(event) => setExportPasswordConfirm(event.target.value)}
|
||
/>
|
||
</div>
|
||
</SettingsDialog>
|
||
)}
|
||
|
||
{importDialog.isOpen && (
|
||
<SettingsDialog
|
||
title={importDialog.stage === 'password' ? '导入系统配置' : '确认导入配置'}
|
||
description={
|
||
importDialog.stage === 'password'
|
||
? '仅支持导入由本系统导出的 JSON 配置文件。'
|
||
: '导入会调用现有保存接口,并用导入内容整体覆盖当前系统配置。'
|
||
}
|
||
onClose={handleCloseImportDialog}
|
||
footer={
|
||
<DialogFooter>
|
||
<button
|
||
type="button"
|
||
onClick={handleCloseImportDialog}
|
||
disabled={importDialog.isSubmitting}
|
||
className="rounded-lg border border-slate-700 bg-slate-900 px-4 py-2 text-sm font-medium text-slate-300 transition hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-60"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={
|
||
importDialog.stage === 'password' ? handleImportDecrypt : handleImportConfirm
|
||
}
|
||
disabled={importDialog.isSubmitting}
|
||
className="flex items-center rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-70"
|
||
>
|
||
{importDialog.isSubmitting ? (
|
||
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Upload className="mr-2 h-4 w-4" />
|
||
)}
|
||
{importDialog.stage === 'password'
|
||
? importDialog.isSubmitting
|
||
? '解密中...'
|
||
: '验证口令'
|
||
: importDialog.isSubmitting
|
||
? '导入中...'
|
||
: '确认导入'}
|
||
</button>
|
||
</DialogFooter>
|
||
}
|
||
>
|
||
<div className="rounded-lg border border-slate-800 bg-slate-950/80 px-4 py-3 text-sm text-slate-300">
|
||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||
<span>
|
||
文件名:
|
||
<span className="ml-2 font-mono text-slate-200">{importDialog.fileName}</span>
|
||
</span>
|
||
<span>
|
||
导出时间:
|
||
<span className="ml-2 font-mono text-slate-200">
|
||
{formatBackupTimestamp(importDialog.exportedAt)}
|
||
</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{importDialog.stage === 'password' ? (
|
||
<div>
|
||
<label className="mb-2 block text-sm font-medium text-white">解密口令</label>
|
||
<input
|
||
type="password"
|
||
className="w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-slate-200 focus:border-blue-500 focus:outline-none focus:ring-blue-500"
|
||
placeholder="请输入导出时使用的口令"
|
||
value={importDialog.password}
|
||
onChange={(event) =>
|
||
setImportDialog((currentDialog) => ({
|
||
...currentDialog,
|
||
password: event.target.value
|
||
}))
|
||
}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className="rounded-lg border border-amber-500/30 bg-amber-950/20 px-4 py-3 text-sm text-amber-200">
|
||
当前页面中的未保存修改会被丢弃,后端已保存配置也会被导入文件整体替换。
|
||
</div>
|
||
<div className="grid gap-3 rounded-lg border border-slate-800 bg-slate-950/80 p-4 text-sm text-slate-300 md:grid-cols-3">
|
||
<div>
|
||
<div className="mb-1 text-xs uppercase tracking-wide text-slate-500">输入目录</div>
|
||
<div className="break-all font-mono text-slate-200">
|
||
{importDialog.decryptedConfig?.input || '-'}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="mb-1 text-xs uppercase tracking-wide text-slate-500">输出目录</div>
|
||
<div className="break-all font-mono text-slate-200">
|
||
{importDialog.decryptedConfig?.output || '-'}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="mb-1 text-xs uppercase tracking-wide text-slate-500">回收站目录</div>
|
||
<div className="break-all font-mono text-slate-200">
|
||
{importDialog.decryptedConfig?.trash || '-'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</SettingsDialog>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MetadataCard({ label, status, children, className = '' }) {
|
||
return (
|
||
<div className={`flex h-full flex-col gap-3 rounded-lg border border-slate-800/50 bg-slate-950/50 p-5 ${className}`}>
|
||
<div className="mb-1 flex items-center justify-between">
|
||
<label className="text-sm font-medium text-white">{label}</label>
|
||
{status}
|
||
</div>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SettingsDialog({ title, description, onClose, children, footer }) {
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/80 p-6 backdrop-blur-sm">
|
||
<div className="w-full max-w-lg overflow-hidden rounded-2xl border border-slate-700 bg-slate-900 shadow-2xl">
|
||
<div className="border-b border-slate-800 bg-slate-900/80 px-6 py-4">
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div>
|
||
<h3 className="text-lg font-semibold text-white">{title}</h3>
|
||
<p className="mt-1 text-sm leading-relaxed text-slate-400">{description}</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="rounded-lg border border-slate-700 px-3 py-1.5 text-xs font-medium text-slate-400 transition hover:bg-slate-800 hover:text-white"
|
||
>
|
||
关闭
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-4 px-6 py-5">{children}</div>
|
||
{footer}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function DialogFooter({ children }) {
|
||
return (
|
||
<div className="flex items-center justify-end gap-3 border-t border-slate-800 bg-slate-950/50 px-6 py-4">
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|