import { FormEvent, useEffect, useState } from 'react' import { Loader2, Save, SettingsIcon } from 'lucide-react' import toast from 'react-hot-toast' import { adminAPI } from '../api/admin' import type { Setting } from '../types' // SettingsPage replaces the Vue SettingsView's General / Organize / // Scrape / Adult tabs. The Go backend stores settings as a single // key/value table; we group the most useful keys client-side and let // the operator edit them with typed widgets (select / toggle / input). // // Any key not in the curated list still works via the AdminPage generic // key/value editor — this page is the curated UX over the same store. interface SettingDef { key: string label: string type: 'text' | 'select' | 'toggle' | 'number' | 'textarea' hint?: string options?: { value: string; label: string }[] placeholder?: string } interface SettingGroup { key: string label: string description?: string items: SettingDef[] } const GROUPS: SettingGroup[] = [ { key: 'general', label: '常规', description: 'TMDb / 转码引擎默认参数', items: [ { key: 'tmdb.language', label: 'TMDb 元数据语言', type: 'select', options: [ { value: 'zh-CN', label: '简体中文' }, { value: 'zh-TW', label: '繁体中文' }, { value: 'en-US', label: 'English' }, { value: 'ja-JP', label: '日本語' }, ], }, { key: 'transcode.enabled', label: '启用转码', type: 'toggle', hint: '关闭后所有视频直连播放', }, { key: 'transcode.hw_accel', label: '硬件加速', type: 'select', options: [ { value: 'auto', label: '自动检测' }, { value: 'none', label: '软件转码' }, { value: 'nvenc', label: 'NVIDIA NVENC' }, { value: 'qsv', label: 'Intel QSV' }, { value: 'vaapi', label: 'VAAPI (Linux)' }, { value: 'videotoolbox', label: 'VideoToolbox (macOS)' }, ], }, { key: 'transcode.max_jobs', label: '最大并发转码任务', type: 'number', hint: '建议 1-4', }, { key: 'ffmpeg.path', label: 'FFmpeg 路径', type: 'text', placeholder: 'ffmpeg', }, { key: 'ffprobe.path', label: 'FFprobe 路径', type: 'text', placeholder: 'ffprobe', }, ], }, { key: 'organize', label: '整理 & 刮削', description: '媒体文件命名 + 自动刮削开关', items: [ { key: 'organize.auto', label: '入库时自动整理', type: 'toggle', }, { key: 'organize.movie_format', label: '电影命名格式', type: 'text', hint: '例: {title} ({year})/{title} ({year})', placeholder: '{title} ({year})/{title} ({year})', }, { key: 'organize.tv_format', label: '剧集命名格式', type: 'text', placeholder: '{title} ({year})/Season {season}/{title} S{season:02}E{episode:02}', }, { key: 'organize.anime_format', label: '动漫命名格式', type: 'text', placeholder: '{title}/Season {season}/{title} S{season:02}E{episode:02}', }, { key: 'scrape.auto_on_scan', label: '扫描后自动刮削', type: 'toggle', }, { key: 'scrape.providers', label: '刮削源优先级', type: 'text', hint: '逗号分隔: tmdb,bangumi,thetvdb,fanart', placeholder: 'tmdb,bangumi,thetvdb,fanart', }, { key: 'scrape.language', label: '刮削首选语言', type: 'text', placeholder: 'zh-CN', }, ], }, { key: 'adult', label: 'Adult / NSFW', description: '成人内容隔离开关 (默认隐藏)', items: [ { key: 'adult.enabled', label: '启用成人内容', type: 'toggle', hint: '关闭后 NSFW 媒体不会出现在列表中', }, { key: 'adult.require_pin', label: '访问需要 PIN', type: 'toggle', }, { key: 'adult.pin', label: 'PIN 码', type: 'text', hint: '4-8 位数字', }, ], }, { key: 'qbittorrent', label: 'qBittorrent', description: '默认下载器配置', items: [ { key: 'qbittorrent.url', label: 'WebUI URL', type: 'text', placeholder: 'http://127.0.0.1:8080' }, { key: 'qbittorrent.username', label: '用户名', type: 'text' }, { key: 'qbittorrent.password', label: '密码', type: 'text' }, { key: 'qbittorrent.savepath', label: '默认保存目录', type: 'text' }, ], }, ] const ALL_KEYS = new Set(GROUPS.flatMap((g) => g.items.map((i) => i.key))) export function SettingsPage() { const [activeGroup, setActiveGroup] = useState(GROUPS[0].key) const [values, setValues] = useState>({}) const [dirty, setDirty] = useState>(new Set()) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const refresh = async () => { setLoading(true) try { const all = await adminAPI.listSettings() const idx: Record = {} for (const s of all as Setting[]) { if (ALL_KEYS.has(s.key)) idx[s.key] = s.value } setValues(idx) setDirty(new Set()) } finally { setLoading(false) } } useEffect(() => { refresh().catch(() => undefined) }, []) const onChange = (key: string, value: string) => { setValues((v) => ({ ...v, [key]: value })) setDirty((d) => new Set(d).add(key)) } const onSave = async (e: FormEvent) => { e.preventDefault() if (dirty.size === 0) return setSaving(true) try { // Backend exposes a single-key updater; loop through dirty keys. for (const key of dirty) { await adminAPI.updateSetting(key, values[key] ?? '') } toast.success(`已保存 ${dirty.size} 项配置`) setDirty(new Set()) } catch (err: unknown) { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '保存失败' toast.error(msg) } finally { setSaving(false) } } const group = GROUPS.find((g) => g.key === activeGroup)! return (

系统设置

按分组编辑 TMDb / 整理 / 刮削 / 下载器等关键配置

{GROUPS.map((g) => ( ))}
{loading && (
)} {!loading && (
{group.description &&

{group.description}

} {group.items.map((it) => ( onChange(it.key, v)} /> ))}
{dirty.size > 0 ? `有 ${dirty.size} 项未保存` : '所有更改已保存'}
)}
) } function SettingRow({ def, value, onChange, }: { def: SettingDef value: string onChange: (v: string) => void }) { return (
{def.type === 'text' && ( onChange(e.target.value)} /> )} {def.type === 'number' && ( onChange(e.target.value)} /> )} {def.type === 'textarea' && (