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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
+88
-1448
File diff suppressed because it is too large
Load Diff
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user