refactor: rewrite SettingsPage as lightweight container with hooks and components

Extracted cron utilities to utils/schedule.js, all state management
(15+ useState) to useSettingsForm hook, and 5 config sections + 2 dialogs
+ 1 status badge into focused components. SettingsPage reduced from
~700 lines to ~130 line container.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
liumangmang
2026-05-08 12:06:26 +08:00
parent 998658da7b
commit 7d003ff822
11 changed files with 990 additions and 1448 deletions
@@ -0,0 +1,53 @@
// frontend/src/components/settings/AdvancedStrategySection.jsx
import { SlidersHorizontal } from 'lucide-react';
const STRATEGY_OPTIONS = [
{
key: 'metadataFallback',
defaultVal: true,
label: '开启多源元数据轮询回退',
desc: '仅控制 Netease / QQ / Spotify 的身份兜底查询;AcoustID 与 MusicBrainz 主链路始终参与匹配。'
},
{
key: 'downloadAssets',
defaultVal: true,
label: '自动下载并补全资产 (封面/歌词)',
desc: '仅控制 Discogs / Last.fm / Genius 的增强信息查询与来源决策,本轮不会真实下载文件。'
},
{
key: 'replaceLowQualityDuplicates',
defaultVal: false,
label: '重复项自动替换低音质 (慎用)',
desc: '若发现重复曲目且新文件比特率更高,自动覆盖库中旧文件,而非移入回收站产生冲突。'
}
];
export default function AdvancedStrategySection({ advancedStrategy = {}, onUpdate }) {
const getVal = (key, defaultVal) =>
advancedStrategy[key] !== undefined ? advancedStrategy[key] : defaultVal;
return (
<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">
{STRATEGY_OPTIONS.map(({ key, defaultVal, label, desc }) => (
<label key={key} 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={getVal(key, defaultVal)}
onChange={(e) => onUpdate('advancedStrategy', { ...advancedStrategy, [key]: 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">{label}</span>
<span className="mt-1.5 block text-xs leading-relaxed text-slate-500">{desc}</span>
</div>
</label>
))}
</div>
</div>
);
}
@@ -0,0 +1,48 @@
// frontend/src/components/settings/ConfigExportDialog.jsx
import { X, Download, RefreshCw } from 'lucide-react';
export default function ConfigExportDialog({
isOpen, isExporting, password, passwordConfirm,
onClose, onExport, onPasswordChange, onPasswordConfirmChange
}) {
if (!isOpen) return null;
return (
<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-md overflow-hidden rounded-xl border border-slate-700 bg-slate-900 shadow-2xl">
<div className="flex items-center justify-between border-b border-slate-800 px-6 py-4">
<h3 className="text-lg font-semibold text-white flex items-center">
<Download className="mr-2 h-5 w-5 text-emerald-400" />配置加密导出
</h3>
<button onClick={() => onClose(false)} disabled={isExporting} className="rounded p-1 text-slate-400 hover:text-white hover:bg-slate-800">
<X className="h-5 w-5" />
</button>
</div>
<div className="px-6 py-4 space-y-4">
<p className="text-sm text-slate-400">导出当前系统配置为加密文件导入时需要相同口令解密</p>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-300">导出文件加密口令</label>
<input type="password" className="w-full rounded border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-slate-200 focus:border-emerald-500 focus:outline-none"
placeholder="输入加密口令(不少于 8 个字符)" value={password} onChange={(e) => onPasswordChange(e.target.value)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-300">再次确认口令</label>
<input type="password" className="w-full rounded border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-slate-200 focus:border-emerald-500 focus:outline-none"
placeholder="再次输入相同口令" value={passwordConfirm} onChange={(e) => onPasswordConfirmChange(e.target.value)} />
</div>
</div>
<div className="flex justify-end gap-3 border-t border-slate-800 px-6 py-4">
<button onClick={() => onClose(false)} disabled={isExporting}
className="rounded-lg border border-slate-700 bg-slate-800 px-4 py-2 text-sm text-slate-300 hover:bg-slate-700 disabled:opacity-50">
取消
</button>
<button onClick={onExport} disabled={isExporting}
className="flex items-center rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-500 disabled:opacity-50">
{isExporting ? <RefreshCw className="mr-2 h-4 w-4 animate-spin" /> : null}
{isExporting ? '导出中...' : '确认导出'}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,78 @@
// frontend/src/components/settings/ConfigImportDialog.jsx
import { X, Upload, RefreshCw, FileText, Clock } from 'lucide-react';
import { formatBackupTimestamp } from '../../utils/schedule';
export default function ConfigImportDialog({
importDialog, onClose, onDecrypt, onConfirm
}) {
if (!importDialog.isOpen) return null;
const { stage, isSubmitting, fileName, exportedAt } = importDialog;
return (
<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-xl border border-slate-700 bg-slate-900 shadow-2xl">
<div className="flex items-center justify-between border-b border-slate-800 px-6 py-4">
<h3 className="text-lg font-semibold text-white flex items-center">
<Upload className="mr-2 h-5 w-5 text-blue-400" />
{stage === 'password' ? '配置解密导入' : '确认导入配置'}
</h3>
<button onClick={() => onClose()} disabled={isSubmitting} className="rounded p-1 text-slate-400 hover:text-white hover:bg-slate-800">
<X className="h-5 w-5" />
</button>
</div>
{stage === 'password' ? (
<div className="px-6 py-4 space-y-4">
<div className="flex items-center gap-3 rounded-lg border border-slate-800 bg-slate-950 p-3">
<FileText className="h-5 w-5 text-slate-400" />
<div>
<div className="text-sm text-slate-200">{fileName || '未知文件'}</div>
{exportedAt && (
<div className="flex items-center text-xs text-slate-500 mt-0.5">
<Clock className="h-3 w-3 mr-1" />导出时间: {formatBackupTimestamp(exportedAt)}
</div>
)}
</div>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-300">配置解密口令</label>
<input type="password" className="w-full rounded border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-slate-200 focus:border-blue-500 focus:outline-none"
placeholder="输入导出时设置的口令" value={importDialog.password}
onChange={(e) => onDecrypt(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && onDecrypt(importDialog.password)} />
</div>
</div>
) : (
<div className="px-6 py-4">
<p className="text-sm text-emerald-400 mb-4">解密成功请确认导入以下配置这将覆盖当前所有配置</p>
<div className="rounded-lg border border-slate-800 bg-slate-950 p-3 max-h-[200px] overflow-auto">
<pre className="text-xs text-slate-300 font-mono whitespace-pre-wrap">
{JSON.stringify(importDialog.decryptedConfig, null, 2)}
</pre>
</div>
</div>
)}
<div className="flex justify-end gap-3 border-t border-slate-800 px-6 py-4">
<button onClick={() => onClose()} disabled={isSubmitting}
className="rounded-lg border border-slate-700 bg-slate-800 px-4 py-2 text-sm text-slate-300 hover:bg-slate-700 disabled:opacity-50">
取消
</button>
{stage === 'password' ? (
<button onClick={() => onDecrypt(importDialog.password)} disabled={isSubmitting || !importDialog.password.trim()}
className="flex items-center rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 disabled:opacity-50">
{isSubmitting ? <RefreshCw className="mr-2 h-4 w-4 animate-spin" /> : null}
{isSubmitting ? '解密中...' : '解密并预览'}
</button>
) : (
<button onClick={onConfirm} disabled={isSubmitting}
className="flex items-center rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 disabled:opacity-50">
{isSubmitting ? <RefreshCw className="mr-2 h-4 w-4 animate-spin" /> : null}
{isSubmitting ? '导入中...' : '确认导入并覆盖'}
</button>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,42 @@
// frontend/src/components/settings/CorePathsSection.jsx
import { Folder } from 'lucide-react';
export default function CorePathsSection({ input, output, trash, onUpdate }) {
return (
<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"
value={input}
onChange={(e) => onUpdate('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"
value={output}
onChange={(e) => onUpdate('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"
value={trash}
onChange={(e) => onUpdate('trash', e.target.value)}
/>
</div>
</div>
</div>
);
}
@@ -0,0 +1,54 @@
// frontend/src/components/settings/MetadataServicesSection.jsx
import { Database } from 'lucide-react';
import ServiceStatusBadge from './ServiceStatusBadge';
export default function MetadataServicesSection({ metadata = {}, netStatus = {}, onUpdate }) {
const updateMeta = (field, value) => {
onUpdate('metadata', { ...metadata, [field]: value });
};
const providerLabel = { acoustidUrl: 'AcoustID API 地址', acoustidClientKey: 'Client Key',
musicbrainz: 'MusicBrainz API', netease: '网易云 API (Netease)', qq: 'QQ 音乐 API',
spotifyUrl: 'Spotify API', spotifyClientId: 'Client ID', spotifySecret: 'Client Secret',
discogsUrl: 'Discogs API', discogsToken: 'Token', lastfmUrl: 'Last.fm API', lastfmKey: 'Key',
geniusUrl: 'Genius API', geniusToken: 'Token' };
const providers = [
{ id: 'acoustid', fields: ['acoustidUrl', 'acoustidClientKey'] },
{ id: 'musicbrainz', fields: ['musicbrainz'] },
{ id: 'netease', fields: ['netease'] },
{ id: 'qq', fields: ['qq'] },
{ id: 'spotify', fields: ['spotifyUrl', 'spotifyClientId', 'spotifySecret'] },
{ id: 'discogs', fields: ['discogsUrl', 'discogsToken'] },
{ id: 'lastfm', fields: ['lastfmUrl', 'lastfmKey'] },
{ id: 'genius', fields: ['geniusUrl', 'geniusToken'] }
];
const status = netStatus.acoustid || {};
return (
<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">
{providers.map(({ id, fields }) => (
<div key={id} className="rounded-lg border border-slate-800/50 bg-slate-950/50 p-4">
<div className="mb-3 flex items-center justify-between">
<span className="text-sm font-medium text-slate-300">{id.charAt(0).toUpperCase() + id.slice(1)}</span>
<ServiceStatusBadge status={(netStatus[id] || {}).status} latencyMs={(netStatus[id] || {}).latencyMs} message={(netStatus[id] || {}).message} />
</div>
{fields.map((field) => (
<input key={field} type={field.toLowerCase().includes('secret') || field.toLowerCase().includes('key') || field.toLowerCase().includes('token') ? 'password' : 'text'}
className="mt-2 w-full rounded border border-slate-700 bg-slate-950 px-3 py-2 text-xs font-mono text-slate-300 focus:border-rose-500 focus:outline-none"
placeholder={providerLabel[field] || field}
value={metadata[field] || ''}
onChange={(e) => updateMeta(field, e.target.value)} />
))}
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,75 @@
// frontend/src/components/settings/NotificationSection.jsx
import { BellRing, MessageSquare, Send, Mail } from 'lucide-react';
export default function NotificationSection({ notifications = {}, onUpdate }) {
const updateNested = (field, value) => {
onUpdate('notifications', { ...notifications, [field]: value });
};
return (
<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">
{/* DingTalk */}
<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>
<InputField label="Webhook 地址" type="text" placeholder="https://oapi.dingtalk.com/robot/send?access_token=..."
value={notifications.dingtalkWebhook || ''} onChange={(v) => updateNested('dingtalkWebhook', v)} />
<InputField label="加签密钥 (Secret - 可选)" type="password" placeholder="SEC..."
value={notifications.dingtalkSecret || ''} onChange={(v) => updateNested('dingtalkSecret', v)} />
</div>
{/* Telegram */}
<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>
<InputField label="Bot Token" type="password" placeholder="123456789:ABCdefGHIjkl..."
value={notifications.telegramBotToken || ''} onChange={(v) => updateNested('telegramBotToken', v)} />
<InputField label="Chat ID (接收者)" type="text" placeholder="例如: 12345678"
value={notifications.telegramChatId || ''} onChange={(v) => updateNested('telegramChatId', v)} />
</div>
{/* Email */}
<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="col-span-2">
<InputField label="SMTP 服务器" type="text" placeholder="smtp.example.com:465"
value={notifications.emailSmtp || ''} onChange={(v) => updateNested('emailSmtp', v)} />
</div>
<div className="flex gap-3">
<div className="flex-1">
<InputField label="发件账号" type="text" placeholder="bot@example.com"
value={notifications.emailUser || ''} onChange={(v) => updateNested('emailUser', v)} />
</div>
<div className="flex-1">
<InputField label="授权密码" type="password" placeholder="********"
value={notifications.emailPass || ''} onChange={(v) => updateNested('emailPass', v)} />
</div>
</div>
<InputField label="目标收件人" type="text" placeholder="admin@example.com"
value={notifications.emailTo || ''} onChange={(v) => updateNested('emailTo', v)} />
</div>
</div>
</div>
);
}
function InputField({ label, type, placeholder, value, onChange }) {
return (
<div>
<label className="mb-1.5 block text-xs font-medium text-slate-400">{label}</label>
<input type={type} 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"
placeholder={placeholder} value={value} onChange={(e) => onChange(e.target.value)} />
</div>
);
}
@@ -0,0 +1,109 @@
// frontend/src/components/settings/ScheduleSection.jsx
import { CalendarClock, Clock, PlayCircle } from 'lucide-react';
import {
SCHEDULE_TYPE, DEFAULT_CRON, DEFAULT_TIME,
getScheduleSummary, buildDailyCron, buildWeeklyCron
} from '../../utils/schedule';
export default function ScheduleSection({ schedule = {}, onUpdate }) {
const enabled = schedule.enabled !== false;
const type = schedule.type || SCHEDULE_TYPE.DAILY;
const time = schedule.time || DEFAULT_TIME;
const dayOfWeek = schedule.dayOfWeek || '1';
const cron = schedule.cron || DEFAULT_CRON;
const setEnabled = (val) => onUpdate('schedule', { ...schedule, enabled: val });
const setType = (val) => {
if (val === SCHEDULE_TYPE.DAILY) {
onUpdate('schedule', { ...schedule, type: val, cron: buildDailyCron(time) });
} else if (val === SCHEDULE_TYPE.WEEKLY) {
onUpdate('schedule', { ...schedule, type: val, dayOfWeek, cron: buildWeeklyCron(dayOfWeek, time) });
} else {
onUpdate('schedule', { ...schedule, type: val, cron: schedule.cron || DEFAULT_CRON });
}
};
const setTime = (val) => {
const newCron = type === SCHEDULE_TYPE.WEEKLY
? buildWeeklyCron(dayOfWeek, val) : buildDailyCron(val);
onUpdate('schedule', { ...schedule, type, time: val, cron: newCron });
};
const setDay = (val) => {
onUpdate('schedule', { ...schedule, type, dayOfWeek: val, cron: buildWeeklyCron(val, time) });
};
const setCron = (val) => {
onUpdate('schedule', { ...schedule, type: SCHEDULE_TYPE.CRON, cron: val });
};
return (
<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={enabled} onChange={(e) => setEnabled(e.target.checked)} />
<div className={`block w-10 h-6 rounded-full transition-colors ${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 ${enabled ? 'transform translate-x-4' : ''}`}></div>
</div>
<span className="ml-3 text-sm font-medium text-slate-300">{enabled ? '已启用自动入库' : '已暂停自动入库'}</span>
</label>
</div>
<div className={`flex flex-col gap-4 transition-opacity ${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={type} onChange={(e) => setType(e.target.value)}>
<option value={SCHEDULE_TYPE.DAILY}>每天执行</option>
<option value={SCHEDULE_TYPE.WEEKLY}>每周执行</option>
<option value={SCHEDULE_TYPE.CRON}>专家模式 (Cron)</option>
</select>
</div>
{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={dayOfWeek} onChange={(e) => setDay(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>
)}
{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={time} onChange={(e) => setTime(e.target.value)} />
</div>
)}
{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={cron} onChange={(e) => setCron(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(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>
);
}
@@ -0,0 +1,40 @@
// frontend/src/components/settings/ServiceStatusBadge.jsx
import { RefreshCw, Wifi, AlertCircle, XCircle } from 'lucide-react';
export default function ServiceStatusBadge({ status, latencyMs, message }) {
if (status === 'checking') {
return (
<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 (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" />
{latencyMs ? `可达 (${latencyMs}ms)` : message}
</span>
);
}
if (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" />
{latencyMs ? `高延迟 (${latencyMs}ms)` : message}
</span>
);
}
if (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" /> {message}
</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">
尚未检测
</span>
);
}
+283
View File
@@ -0,0 +1,283 @@
// frontend/src/hooks/useSettingsForm.js
import { useState, useEffect, useRef, useCallback } from 'react';
import { fetchMetadataStatus, saveConfig } from '../api/config';
import { deriveTaskState } from '../constants';
import {
buildConfigBackup, createBackupFilename, isBackupSupported,
parseConfigBackup, restoreConfigFromBackup, triggerJsonDownload
} from '../utils/configBackup';
import { normalizeSchedule } from '../utils/schedule';
const METADATA_SERVICE_KEYS = [
'acoustid', 'musicbrainz', 'netease', 'qq',
'spotify', 'discogs', 'lastfm', 'genius'
];
function createIdleNetStatus() {
return METADATA_SERVICE_KEYS.reduce((acc, key) => {
acc[key] = { status: 'idle', latencyMs: null, message: '未检测' };
return acc;
}, {});
}
function createClosedImportDialogState() {
return {
isOpen: false, stage: 'password', password: '',
fileName: '', exportedAt: '', payload: null,
decryptedConfig: null, isSubmitting: false
};
}
export default function useSettingsForm({ config, setConfig, setTaskState }) {
const fileInputRef = useRef(null);
const metadataStatusAbortRef = useRef(null);
const netStatusRequestIdRef = useRef(0);
const [localConfig, setLocalConfig] = useState({
...config,
schedule: normalizeSchedule(config.schedule)
});
const [isSaving, setIsSaving] = useState(false);
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const [exportPassword, setExportPassword] = useState('');
const [exportPasswordConfirm, setExportPasswordConfirm] = useState('');
const [importDialog, setImportDialog] = useState(createClosedImportDialogState);
const [netStatus, setNetStatus] = useState(createIdleNetStatus);
const resolvedNetStatusRef = useRef(createIdleNetStatus());
const [toast, setToast] = useState(null);
const toastTimeoutRef = useRef(null);
useEffect(() => {
setLocalConfig({ ...config, schedule: normalizeSchedule(config.schedule) });
}, [config]);
useEffect(() => () => {
metadataStatusAbortRef.current?.abort();
if (toastTimeoutRef.current) clearTimeout(toastTimeoutRef.current);
}, []);
const showToast = (nextToast) => {
if (toastTimeoutRef.current) clearTimeout(toastTimeoutRef.current);
setToast(nextToast);
toastTimeoutRef.current = setTimeout(() => {
setToast(null);
toastTimeoutRef.current = null;
}, 3000);
};
const cancelMetadataStatusProbe = () => {
metadataStatusAbortRef.current?.abort();
metadataStatusAbortRef.current = null;
};
const beginNetStatusRequest = () => {
const requestId = netStatusRequestIdRef.current + 1;
netStatusRequestIdRef.current = requestId;
setNetStatus(METADATA_SERVICE_KEYS.reduce((acc, key) => {
acc[key] = { status: 'checking', latencyMs: null, message: '探测中' };
return acc;
}, {}));
return requestId;
};
const isCurrentNetStatusRequest = (requestId) => netStatusRequestIdRef.current === requestId;
const commitNetStatus = (requestId, nextStatus) => {
if (!isCurrentNetStatusRequest(requestId)) return false;
resolvedNetStatusRef.current = nextStatus;
setNetStatus(nextStatus);
return true;
};
const restoreNetStatus = (requestId) => {
if (!isCurrentNetStatusRequest(requestId)) return false;
setNetStatus(resolvedNetStatusRef.current);
return true;
};
useEffect(() => {
const requestId = beginNetStatusRequest();
const abortController = new AbortController();
metadataStatusAbortRef.current = abortController;
async function loadMetadataStatus() {
try {
const response = await fetchMetadataStatus({ signal: abortController.signal });
if (metadataStatusAbortRef.current === abortController) {
metadataStatusAbortRef.current = null;
}
commitNetStatus(requestId, response.metadataStatus);
} catch (error) {
if (metadataStatusAbortRef.current === abortController) {
metadataStatusAbortRef.current = null;
}
if (error.name === 'AbortError') return;
if (restoreNetStatus(requestId)) {
showToast({ type: 'error', message: `API 连通性检测失败:${error.message}` });
}
}
}
loadMetadataStatus();
return () => {
if (metadataStatusAbortRef.current === abortController) {
abortController.abort();
metadataStatusAbortRef.current = null;
}
};
}, []);
const updateField = useCallback((field, value) => {
setLocalConfig((prev) => ({ ...prev, [field]: value }));
}, []);
const updateNestedField = useCallback((section, field, value) => {
setLocalConfig((prev) => ({
...prev,
[section]: { ...prev[section], [field]: value }
}));
}, []);
const handleSave = async () => {
cancelMetadataStatusProbe();
const requestId = beginNetStatusRequest();
setIsSaving(true);
try {
const response = await saveConfig(localConfig);
setConfig(response.config);
setTaskState(deriveTaskState(response.config));
setLocalConfig({ ...response.config, schedule: normalizeSchedule(response.config.schedule) });
commitNetStatus(requestId, response.metadataStatus);
showToast({ type: 'success', message: '配置已保存,服务状态已刷新。' });
} catch (error) {
restoreNetStatus(requestId);
showToast({ type: 'error', message: `保存失败:${error.message}` });
} finally {
setIsSaving(false);
}
};
const handleOpenExportDialog = () => {
if (!isBackupSupported()) {
showToast({ type: 'error', message: '当前浏览器环境不支持配置加密导出。' });
return;
}
setExportPassword('');
setExportPasswordConfirm('');
setIsExportDialogOpen(true);
};
const handleCloseExportDialog = (forceClose = false) => {
if (!forceClose && isExporting) return;
setIsExportDialogOpen(false);
setExportPassword('');
setExportPasswordConfirm('');
};
const handleExportConfig = async () => {
if (!exportPassword.trim()) {
showToast({ type: 'error', message: '请输入导出口令。' });
return;
}
if (exportPassword !== exportPasswordConfirm) {
showToast({ type: 'error', message: '两次输入的导出口令不一致。' });
return;
}
setIsExporting(true);
try {
const backupPayload = await buildConfigBackup(localConfig, exportPassword);
triggerJsonDownload(createBackupFilename(), backupPayload);
handleCloseExportDialog(true);
showToast({ type: 'success', message: '配置已导出,请妥善保管导出文件与口令。' });
} catch (error) {
showToast({ type: 'error', message: `导出失败:${error.message}` });
} finally {
setIsExporting(false);
}
};
const handleImportButtonClick = () => {
if (!isBackupSupported()) {
showToast({ type: 'error', message: '当前浏览器环境不支持配置解密导入。' });
return;
}
fileInputRef.current?.click();
};
const handleImportFileChange = async (event) => {
const [file] = event.target.files || [];
event.target.value = '';
if (!file) return;
try {
const fileText = await file.text();
const backupPayload = parseConfigBackup(fileText);
setImportDialog({
isOpen: true, stage: 'password', password: '',
fileName: file.name, exportedAt: backupPayload.exportedAt || '',
payload: backupPayload, decryptedConfig: null, isSubmitting: false
});
} catch (error) {
showToast({ type: 'error', message: `导入失败:${error.message}` });
}
};
const handleCloseImportDialog = () => {
if (importDialog.isSubmitting) return;
setImportDialog(createClosedImportDialogState);
};
const handleImportDecrypt = async () => {
if (!importDialog.password.trim()) {
showToast({ type: 'error', message: '请输入解密口令。' });
return;
}
setImportDialog((cur) => ({ ...cur, isSubmitting: true }));
try {
const restoredConfig = await restoreConfigFromBackup(importDialog.payload, importDialog.password);
setImportDialog((cur) => ({
...cur, stage: 'confirm', decryptedConfig: restoredConfig, isSubmitting: false
}));
} catch (error) {
setImportDialog((cur) => ({ ...cur, isSubmitting: false }));
showToast({ type: 'error', message: error.message });
}
};
const handleImportConfirm = async () => {
if (!importDialog.decryptedConfig) {
showToast({ type: 'error', message: '导入内容不存在,请重新选择配置文件。' });
return;
}
cancelMetadataStatusProbe();
const requestId = beginNetStatusRequest();
setImportDialog((cur) => ({ ...cur, isSubmitting: true }));
try {
const response = await saveConfig(importDialog.decryptedConfig);
setConfig(response.config);
setTaskState(deriveTaskState(response.config));
setLocalConfig({ ...response.config, schedule: normalizeSchedule(response.config.schedule) });
commitNetStatus(requestId, response.metadataStatus);
setImportDialog(createClosedImportDialogState);
showToast({ type: 'success', message: '配置已导入并完成全量替换。' });
} catch (error) {
restoreNetStatus(requestId);
setImportDialog((cur) => ({ ...cur, isSubmitting: false }));
showToast({ type: 'error', message: `导入保存失败:${error.message}` });
}
};
return {
localConfig, netStatus, toast, isSaving,
isExportDialogOpen, isExporting, exportPassword, exportPasswordConfirm,
importDialog, fileInputRef,
updateField, updateNestedField,
handleSave,
setExportPassword, setExportPasswordConfirm,
handleOpenExportDialog, handleCloseExportDialog, handleExportConfig,
handleImportButtonClick, handleImportFileChange,
handleCloseImportDialog, handleImportDecrypt, handleImportConfirm
};
}
File diff suppressed because it is too large Load Diff
+120
View File
@@ -0,0 +1,120 @@
// frontend/src/utils/schedule.js
export const DEFAULT_CRON = '0 2 * * *';
export const DEFAULT_TIME = '02:00';
export const SCHEDULE_TYPE = {
DAILY: 'daily',
WEEKLY: 'weekly',
CRON: 'cron'
};
export const WEEKDAY_OPTIONS = [
{ value: '1', label: '周一' },
{ value: '2', label: '周二' },
{ value: '3', label: '周三' },
{ value: '4', label: '周四' },
{ value: '5', label: '周五' },
{ value: '6', label: '周六' },
{ value: '0', label: '周日' }
];
export function padCronSegment(value) {
return String(value).padStart(2, '0');
}
export function isCronNumber(value, min, max) {
if (!/^\d+$/.test(value)) return false;
const numericValue = Number(value);
return numericValue >= min && numericValue <= max;
}
export function formatTimeFromCron(hour, minute) {
return `${padCronSegment(hour)}:${padCronSegment(minute)}`;
}
export function buildDailyCron(time) {
const [hour, minute] = time.split(':');
return `${Number(minute)} ${Number(hour)} * * *`;
}
export function buildWeeklyCron(day, time) {
const [hour, minute] = time.split(':');
return `${Number(minute)} ${Number(hour)} * * ${day}`;
}
export function normalizeSchedule(schedule) {
const nextSchedule = {
enabled: true,
type: SCHEDULE_TYPE.DAILY,
dayOfWeek: '1',
time: DEFAULT_TIME,
cron: DEFAULT_CRON,
...schedule
};
const normalizedCron =
typeof nextSchedule.cron === 'string' && nextSchedule.cron.trim()
? nextSchedule.cron.trim()
: DEFAULT_CRON;
const parts = normalizedCron.split(/\s+/);
if (parts.length === 5) {
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
const hasSimpleTime = isCronNumber(minute, 0, 59) && isCronNumber(hour, 0, 23);
if (hasSimpleTime) {
nextSchedule.time = nextSchedule.time || formatTimeFromCron(hour, minute);
}
if (
!schedule?.type &&
hasSimpleTime &&
dayOfMonth === '*' &&
month === '*' &&
dayOfWeek === '*'
) {
nextSchedule.type = SCHEDULE_TYPE.DAILY;
nextSchedule.time = formatTimeFromCron(hour, minute);
} else if (
!schedule?.type &&
hasSimpleTime &&
dayOfMonth === '*' &&
month === '*' &&
['0', '1', '2', '3', '4', '5', '6', '7'].includes(dayOfWeek)
) {
nextSchedule.type = SCHEDULE_TYPE.WEEKLY;
nextSchedule.dayOfWeek = dayOfWeek === '7' ? '0' : dayOfWeek;
nextSchedule.time = formatTimeFromCron(hour, minute);
} else if (!schedule?.type) {
nextSchedule.type = SCHEDULE_TYPE.CRON;
}
}
if (!nextSchedule.time) nextSchedule.time = DEFAULT_TIME;
if (!nextSchedule.dayOfWeek) nextSchedule.dayOfWeek = '1';
nextSchedule.cron = normalizedCron;
return nextSchedule;
}
export function getScheduleSummary(schedule) {
const normalizedSchedule = normalizeSchedule(schedule);
if (normalizedSchedule.type === SCHEDULE_TYPE.DAILY) {
return `系统将在 每天 ${normalizedSchedule.time || DEFAULT_TIME} 自动触发入库。`;
}
if (normalizedSchedule.type === SCHEDULE_TYPE.WEEKLY) {
const weekdayLabel =
WEEKDAY_OPTIONS.find((option) => option.value === normalizedSchedule.dayOfWeek)?.label || '周一';
return `系统将在 每${weekdayLabel} ${normalizedSchedule.time || DEFAULT_TIME} 自动触发入库。`;
}
return `按照 Cron 规则执行: ${normalizedSchedule.cron || DEFAULT_CRON}`;
}
export function formatBackupTimestamp(value) {
if (!value) return '未知';
const timestamp = new Date(value);
if (Number.isNaN(timestamp.getTime())) return value;
return timestamp.toLocaleString('zh-CN', { hour12: false });
}