Files
MusicWorkshop/frontend/src/pages/SettingsPage.jsx
T
2026-04-30 14:34:28 +08:00

1488 lines
59 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}