diff --git a/frontend/src/components/settings/AdvancedStrategySection.jsx b/frontend/src/components/settings/AdvancedStrategySection.jsx
new file mode 100644
index 0000000..80787eb
--- /dev/null
+++ b/frontend/src/components/settings/AdvancedStrategySection.jsx
@@ -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 (
+
+ {/* Header */}
系统运行配置
全局基础目录设定及核心入库引擎的高级策略配置。
-
-
+ {/* Hidden file input for import */}
+
+ {/* Toast notification */}
{toast && (
-
- {toast.type === 'success' ? (
-
- ) : (
-
- )}
+
+ {toast.type === 'success' ? : }
{toast.message}
)}
+ {/* Configuration Sections */}
-
+
updateField(field, value)}
+ />
-
-
-
- 高级运行策略 (Advanced Strategy)
-
-
-
+ updateField(section, value)}
+ />
-
-
-
-
- 自动化定时任务
-
-
-
+
updateField(section, value)}
+ />
-
-
-
-
-
-
+
updateField(section, value)}
+ />
- {localConfig.schedule?.type === SCHEDULE_TYPE.WEEKLY && (
-
-
-
-
- )}
-
- {localConfig.schedule?.type !== SCHEDULE_TYPE.CRON && (
-
-
- handleScheduleTimeChange(e.target.value)}
- />
-
- )}
-
- {localConfig.schedule?.type === SCHEDULE_TYPE.CRON && (
-
-
- handleScheduleCronChange(e.target.value)}
- placeholder="例如: 0 2 * * *"
- />
-
- )}
-
-
-
-
-
- 解析结果:
-
- {getScheduleSummary(localConfig.schedule)}
-
-
-
- 立即运行测试
-
-
-
-
-
-
-
-
- 消息推送通知 (Webhooks / Notifications)
-
-
当自动入库批次任务完成,或产生大量异常记录时,系统将通过以下渠道向您发送详细报告。
-
-
-
-
-
- 钉钉机器人 (DingTalk)
-
-
-
-
- setLocalConfig({
- ...localConfig,
- notifications: {
- ...localConfig.notifications,
- dingtalkWebhook: e.target.value
- }
- })
- }
- />
-
-
-
-
- setLocalConfig({
- ...localConfig,
- notifications: {
- ...localConfig.notifications,
- dingtalkSecret: e.target.value
- }
- })
- }
- />
-
-
-
-
-
-
- Telegram Bot
-
-
-
-
- setLocalConfig({
- ...localConfig,
- notifications: {
- ...localConfig.notifications,
- telegramBotToken: e.target.value
- }
- })
- }
- />
-
-
-
-
- setLocalConfig({
- ...localConfig,
- notifications: {
- ...localConfig.notifications,
- telegramChatId: e.target.value
- }
- })
- }
- />
-
-
-
-
-
-
- 电子邮件 (Email)
-
-
-
-
-
- setLocalConfig({
- ...localConfig,
- notifications: {
- ...localConfig.notifications,
- emailSmtp: e.target.value
- }
- })
- }
- />
-
-
-
-
-
- setLocalConfig({
- ...localConfig,
- notifications: {
- ...localConfig.notifications,
- emailTo: e.target.value
- }
- })
- }
- />
-
-
-
-
-
-
-
-
-
- 元数据服务源配置
-
-
-
-
- setLocalConfig({
- ...localConfig,
- metadata: { ...localConfig.metadata, acoustidUrl: e.target.value }
- })
- }
- />
-
- setLocalConfig({
- ...localConfig,
- metadata: { ...localConfig.metadata, acoustidClientKey: e.target.value }
- })
- }
- />
- 未填写 Key 时会自动跳过联网指纹查找,不影响 MusicBrainz 文本匹配。
-
-
-
-
- setLocalConfig({
- ...localConfig,
- metadata: { ...localConfig.metadata, musicbrainz: e.target.value }
- })
- }
- />
-
-
-
-
- setLocalConfig({
- ...localConfig,
- metadata: { ...localConfig.metadata, netease: e.target.value }
- })
- }
- />
- 建议使用 NeteaseCloudMusicApi 部署本地服务后填入地址。
-
-
-
-
- setLocalConfig({
- ...localConfig,
- metadata: { ...localConfig.metadata, qq: e.target.value }
- })
- }
- />
-
-
-
-
- setLocalConfig({
- ...localConfig,
- metadata: { ...localConfig.metadata, spotifyUrl: e.target.value }
- })
- }
- />
-
- setLocalConfig({
- ...localConfig,
- metadata: { ...localConfig.metadata, spotifyClientId: e.target.value }
- })
- }
- />
-
- setLocalConfig({
- ...localConfig,
- metadata: { ...localConfig.metadata, spotifySecret: e.target.value }
- })
- }
- />
-
-
-
-
- setLocalConfig({
- ...localConfig,
- metadata: { ...localConfig.metadata, discogsUrl: e.target.value }
- })
- }
- />
-
- setLocalConfig({
- ...localConfig,
- metadata: { ...localConfig.metadata, discogsToken: e.target.value }
- })
- }
- />
-
-
-
-
- setLocalConfig({
- ...localConfig,
- metadata: { ...localConfig.metadata, lastfmUrl: e.target.value }
- })
- }
- />
-
- setLocalConfig({
- ...localConfig,
- metadata: { ...localConfig.metadata, lastfmKey: e.target.value }
- })
- }
- />
-
-
-
-
- setLocalConfig({
- ...localConfig,
- metadata: { ...localConfig.metadata, geniusUrl: e.target.value }
- })
- }
- />
-
- setLocalConfig({
- ...localConfig,
- metadata: { ...localConfig.metadata, geniusToken: e.target.value }
- })
- }
- />
-
-
-
+ updateField(section, value)}
+ />
- {isExportDialogOpen && (
-
- 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"
- >
- 取消
-
-
- {isExporting ? (
-
- ) : (
-
- )}
- {isExporting ? '导出中...' : '确认导出'}
-
-
- }
- >
-
- 导出范围包含当前页面尚未保存的修改。请单独保管导出文件和口令。
-
-
-
- setExportPassword(event.target.value)}
- />
-
-
-
- setExportPasswordConfirm(event.target.value)}
- />
-
-
- )}
+ {/* Dialogs */}
+
- {importDialog.isOpen && (
-
-
- 取消
-
-
- {importDialog.isSubmitting ? (
-
- ) : (
-
- )}
- {importDialog.stage === 'password'
- ? importDialog.isSubmitting
- ? '解密中...'
- : '验证口令'
- : importDialog.isSubmitting
- ? '导入中...'
- : '确认导入'}
-
-
- }
- >
-
-
-
- 文件名:
- {importDialog.fileName}
-
-
- 导出时间:
-
- {formatBackupTimestamp(importDialog.exportedAt)}
-
-
-
-
-
- {importDialog.stage === 'password' ? (
-
-
-
- setImportDialog((currentDialog) => ({
- ...currentDialog,
- password: event.target.value
- }))
- }
- />
-
- ) : (
- <>
-
- 当前页面中的未保存修改会被丢弃,后端已保存配置也会被导入文件整体替换。
-
-
-
-
输入目录
-
- {importDialog.decryptedConfig?.input || '-'}
-
-
-
-
输出目录
-
- {importDialog.decryptedConfig?.output || '-'}
-
-
-
-
回收站目录
-
- {importDialog.decryptedConfig?.trash || '-'}
-
-
-
- >
- )}
-
- )}
-
- );
-}
-
-function MetadataCard({ label, status, children, className = '' }) {
- return (
-
-
-
- {status}
-
- {children}
-
- );
-}
-
-function SettingsDialog({ title, description, onClose, children, footer }) {
- return (
-
-
-
-
-
-
{title}
-
{description}
-
-
- 关闭
-
-
-
-
{children}
- {footer}
-
-
- );
-}
-
-function DialogFooter({ children }) {
- return (
-
- {children}
+
);
}
diff --git a/frontend/src/utils/schedule.js b/frontend/src/utils/schedule.js
new file mode 100644
index 0000000..45358bb
--- /dev/null
+++ b/frontend/src/utils/schedule.js
@@ -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 });
+}