'use client' import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react' import Image from 'next/image' import { useThemeStyles, useTheme, cn } from '@/lib/ui-styles' import type { SessionUser } from './types' import RestrictedToggle from '@/components/ui/RestrictedToggle' import { useLicenseStatus } from '@/hooks/useLicenseStatus' import StorageEditConfirmModal from '@/components/ui/StorageEditConfirmModal' type AppearanceSettingsPanelProps = { sessionUser: SessionUser | null | undefined variant?: 'embedded' | 'standalone' } type ToastState = { type: 'success' | 'error' message: string } const sectionConfig = [ { key: 'brand', label: '品牌与主题', hint: '站点名称与主题色' }, { key: 'logos', label: '标识管理', hint: '导航栏、登录页标识' }, { key: 'toggles', label: '功能开关', hint: '敏感功能开关' }, { key: 'storage', label: '对象存储', hint: 'S3 / MinIO 配置' }, ] as const type SectionKey = (typeof sectionConfig)[number]['key'] // 默认 LOGO 路径:与后端 /lib/config.ts 中的 defaultLogos 保持一致 const DEFAULT_HEADER_LOGO = process.env.NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL || '' const DEFAULT_LOGIN_LOGO = process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '' export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedded' }: AppearanceSettingsPanelProps) { const styles = useThemeStyles() const theme = useTheme() const { isFree, isFeatureEnabled } = useLicenseStatus() const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [headerBgColor, setHeaderBgColor] = useState('#1f2937') const [siteName, setSiteName] = useState('FLNXI素材库') const [headerLogoUrl, setHeaderLogoUrl] = useState(DEFAULT_HEADER_LOGO) const [headerLogoPreview, setHeaderLogoPreview] = useState(DEFAULT_HEADER_LOGO) const [loginLogoUrl, setLoginLogoUrl] = useState(DEFAULT_LOGIN_LOGO) const [loginLogoPreview, setLoginLogoPreview] = useState(DEFAULT_LOGIN_LOGO) const [uploadingHeader, setUploadingHeader] = useState(false) const [uploadingLogin, setUploadingLogin] = useState(false) const [s3Endpoint, setS3Endpoint] = useState('') const [s3AccessKey, setS3AccessKey] = useState('') const [s3SecretKey, setS3SecretKey] = useState('') const [s3Bucket, setS3Bucket] = useState('') const [s3Region, setS3Region] = useState('us-east-1') const [s3PublicUrl, setS3PublicUrl] = useState('') const [enableUserActivation, setEnableUserActivation] = useState(false) const [enableRegistration, setEnableRegistration] = useState(false) const [enableAiImageEdit, setEnableAiImageEdit] = useState(false) const [enableTunnelService, setEnableTunnelService] = useState(false) const [enablePublicShare, setEnablePublicShare] = useState(true) // 首页删除按钮:true = 仅管理员可见,false = 所有人可见 const [homeDeleteButtonAdminOnly, setHomeDeleteButtonAdminOnly] = useState(true) const [premiumRatio, setPremiumRatio] = useState(10) const [toast, setToast] = useState(null) const [lastSavedAt, setLastSavedAt] = useState(null) const [activeNav, setActiveNav] = useState('brand') // 对象存储配置编辑状态 const [storageEditEnabled, setStorageEditEnabled] = useState(false) const [showStorageConfirmModal, setShowStorageConfirmModal] = useState(false) const brandRef = useRef(null) const logoRef = useRef(null) const toggleRef = useRef(null) const storageRef = useRef(null) const sectionRefs: Record<(typeof sectionConfig)[number]['key'], RefObject> = useMemo( () => ({ brand: brandRef, logos: logoRef, toggles: toggleRef, storage: storageRef, }), [] ) const showToast = useCallback((next: ToastState) => { setToast(next) setTimeout(() => setToast(null), 3600) }, []) const fetchSettings = useCallback(async () => { setLoading(true) try { const response = await fetch('/api/system-settings') if (response.ok) { const data = await response.json() setHeaderBgColor(data.headerBgColor || '#1f2937') setSiteName(data.siteName || 'FLNXI素材库') setHeaderLogoUrl(data.headerLogoUrl || DEFAULT_HEADER_LOGO) setHeaderLogoPreview(data.headerLogoUrl || DEFAULT_HEADER_LOGO) setLoginLogoUrl(data.loginLogoUrl || DEFAULT_LOGIN_LOGO) setLoginLogoPreview(data.loginLogoUrl || DEFAULT_LOGIN_LOGO) setS3Endpoint(data.s3Endpoint || '') setS3AccessKey(data.s3AccessKey || '') setS3SecretKey(data.s3SecretKey || '') setS3Bucket(data.s3Bucket || '') setS3Region(data.s3Region || 'us-east-1') setS3PublicUrl(data.s3PublicUrl || '') setEnableUserActivation(data.enableUserActivation ?? false) setEnableRegistration(data.enableRegistration ?? false) setEnableAiImageEdit(data.enableAiImageEdit ?? false) setEnableTunnelService(data.enableTunnelService ?? false) setEnablePublicShare(data.enablePublicShare ?? true) setHomeDeleteButtonAdminOnly((data.homeDeleteButtonMode ?? 'ADMIN_ONLY') !== 'ALL') setPremiumRatio(typeof data.premiumRatio === 'number' ? data.premiumRatio : 10) setLastSavedAt(data.updatedAt ? new Date(data.updatedAt) : null) } } catch (error) { console.error('Error fetching settings:', error) showToast({ type: 'error', message: '加载设置失败,请稍后再试' }) } finally { setLoading(false) } }, [showToast]) useEffect(() => { if (sessionUser?.role === 'ADMIN') { fetchSettings() } }, [sessionUser, fetchSettings]) const handleSave = async () => { setSaving(true) try { const numericRatio = Number(premiumRatio) if (!Number.isFinite(numericRatio)) { showToast({ type: 'error', message: '精品比例必须为数字' }) setSaving(false) return } const normalizedRatio = Math.min(100, Math.max(0, Number(numericRatio.toFixed(2)))) const response = await fetch('/api/system-settings', { method: 'PUT', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ headerBgColor, siteName, headerLogoUrl, loginLogoUrl, s3Endpoint, s3AccessKey, s3SecretKey, s3Bucket, s3Region, s3PublicUrl, enableUserActivation, enableRegistration, enableAiImageEdit, enableTunnelService, enablePublicShare, homeDeleteButtonMode: homeDeleteButtonAdminOnly ? 'ADMIN_ONLY' : 'ALL', premiumRatio: normalizedRatio, }), }) if (response.ok) { showToast({ type: 'success', message: '保存成功,配置已更新' }) setLastSavedAt(new Date()) } else { const error = await response.json() throw new Error(error?.error || '保存失败') } } catch (error) { console.error('Save settings error:', error) showToast({ type: 'error', message: '保存失败,请稍后重试' }) } finally { setSaving(false) } } const handleUpload = async (file: File, type: 'header' | 'login') => { if (!file.type.startsWith('image/')) { showToast({ type: 'error', message: '请选择图片文件' }) return } if (file.size > 5 * 1024 * 1024) { showToast({ type: 'error', message: 'LOGO 文件不能超过 5MB' }) return } type === 'header' ? setUploadingHeader(true) : setUploadingLogin(true) try { const formData = new FormData() formData.append('logo', file) formData.append('type', type) const response = await fetch('/api/system-settings/upload-logo', { method: 'POST', body: formData, }) if (!response.ok) { const error = await response.json() throw new Error(error?.error || '上传失败') } const data = await response.json() if (type === 'header') { setHeaderLogoUrl(data.logoUrl) setHeaderLogoPreview(data.logoUrl) } else { setLoginLogoUrl(data.logoUrl) setLoginLogoPreview(data.logoUrl) } showToast({ type: 'success', message: 'LOGO 上传成功' }) } catch (error) { console.error('Upload logo error:', error) showToast({ type: 'error', message: '上传失败,请稍后重试' }) } finally { type === 'header' ? setUploadingHeader(false) : setUploadingLogin(false) } } const handleColorReset = () => setHeaderBgColor('#1f2937') const handleHeaderLogoReset = () => { setHeaderLogoPreview(DEFAULT_HEADER_LOGO) setHeaderLogoUrl(DEFAULT_HEADER_LOGO) } const handleLoginLogoReset = () => { setLoginLogoPreview(DEFAULT_LOGIN_LOGO) setLoginLogoUrl(DEFAULT_LOGIN_LOGO) } const scrollToSection = (key: SectionKey) => { const ref = sectionRefs[key] if (!ref?.current) return ref.current.scrollIntoView({ behavior: 'smooth', block: 'start', }) setActiveNav(key) } const navButtonClass = (key: SectionKey) => cn( 'w-full text-left group', activeNav === key ? styles.navItemStyles.active : styles.navItemStyles.hover ) if (!sessionUser || sessionUser.role !== 'ADMIN') { return (
当前账号无权查看外观设置。
) } if (loading) { return (

正在加载外观设置...

) } return (

外观设置

管理站点名称、LOGO、功能开关和对象存储配置。

{lastSavedAt && (

最近保存时间:{lastSavedAt.toLocaleString()}

)}
{variant === 'standalone' && ( )}

品牌

品牌与主题

同步修改站点名称以及顶部导航栏主色。

setSiteName(e.target.value)} placeholder="FLNXI素材库" className={styles.inputStyles.base} />

将同步至后台导航、登录页和浏览器标题。

setHeaderBgColor(e.target.value)} className={cn(styles.inputStyles.base, 'flex-1 text-sm font-mono')} />
{['#1f2937', '#334155', '#0f766e', '#166534', '#b45309', '#b91c1c'].map((color) => (

标识

标识管理

为导航栏和登录页分别上传标识。

{[ { title: '导航栏 LOGO(深色背景)', tip: '建议浅色 LOGO,200×200px,≤5MB', preview: headerLogoPreview, background: headerBgColor, uploading: uploadingHeader, onUpload: handleUpload, reset: handleHeaderLogoReset, type: 'header' as const, }, { title: '登录页 LOGO(浅色背景)', tip: '建议深色 LOGO,200×200px,≤5MB', preview: loginLogoPreview, background: '#ffffff', uploading: uploadingLogin, onUpload: handleUpload, reset: handleLoginLogoReset, type: 'login' as const, }, ].map((item) => (

{item.title}

{item.tip}

{item.preview ? ( {item.title} ) : ( )}
))}

开关

功能开关

敏感功能仅限超级管理员调整,其他开关即时生效。

{[ { title: '用户激活功能', desc: '开启后新注册用户需管理员激活才能登录。', value: enableUserActivation, onChange: setEnableUserActivation, disabled: !sessionUser?.isSuperAdmin, }, { title: '用户手动注册', desc: '关闭后仅能通过邀请进入系统。', value: enableRegistration, onChange: setEnableRegistration, disabled: !sessionUser?.isSuperAdmin, }, { title: '首页删除按钮仅管理员可见', desc: '开启后首页素材卡片的删除按钮仅管理员可见;关闭后所有用户都能看到(实际删除权限仍受角色控制)。', value: homeDeleteButtonAdminOnly, onChange: setHomeDeleteButtonAdminOnly, disabled: false, }, { title: '公开分享链接', desc: '关闭后不能新建外部分享链接,已有分享链接也会停止公开访问。', value: enablePublicShare, onChange: setEnablePublicShare, disabled: !sessionUser?.isSuperAdmin, }, { title: '图片编辑能力(预留)', desc: '控制素材详情页的图片编辑入口(功能预留)。', value: enableAiImageEdit, onChange: setEnableAiImageEdit, disabled: !sessionUser?.isSuperAdmin, }, ].map((item) => (

{item.title}

{item.desc}

{item.disabled && (

仅超级管理员可修改

)}
))}

精品作品比例

{ const value = Number(event.target.value) setPremiumRatio(Number.isFinite(value) ? value : 0) }} disabled={!sessionUser?.isSuperAdmin} className={cn( '!w-16 text-center font-semibold text-sm px-2 py-1 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white focus:border-slate-500 dark:focus:border-slate-300 focus:ring-2 focus:ring-slate-200 dark:focus:ring-slate-700 outline-none transition-colors', '[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none', !sessionUser?.isSuperAdmin && 'opacity-60 cursor-not-allowed' )} /> %

系统会根据素材总量 × 该比例取整作为允许加精的数量,默认 10%。举例:总素材 320 条且比例 10%,则加精上限为 ⌊320 × 10%⌋ = 32 条

{!sessionUser?.isSuperAdmin && (

仅超级管理员可修改

)}
{/* 付费功能开关 - 使用 RestrictedToggle */}

存储

对象存储配置

用于托管上传素材与 LOGO,可对接 S3 / MinIO。

{/* 修改配置按钮 */} {sessionUser?.isSuperAdmin && !storageEditEnabled && ( )} {storageEditEnabled && ( )}
{/* 锁定状态提示 */} {!storageEditEnabled && (

配置已锁定

对象存储是系统核心配置,修改需要超级管理员密码验证

)}
setS3Endpoint(e.target.value)} placeholder="s3.example.com" disabled={!storageEditEnabled} className={cn(styles.inputStyles.base, 'text-sm', !storageEditEnabled && 'opacity-60 cursor-not-allowed')} />
setS3PublicUrl(e.target.value)} placeholder="https://cdn.example.com" disabled={!storageEditEnabled} className={cn(styles.inputStyles.base, 'text-sm', !storageEditEnabled && 'opacity-60 cursor-not-allowed')} />
setS3AccessKey(e.target.value)} placeholder="访问密钥" disabled={!storageEditEnabled} className={cn(styles.inputStyles.base, 'text-sm font-mono', !storageEditEnabled && 'opacity-60 cursor-not-allowed')} />
setS3SecretKey(e.target.value)} placeholder="安全密钥" disabled={!storageEditEnabled} className={cn(styles.inputStyles.base, 'text-sm font-mono', !storageEditEnabled && 'opacity-60 cursor-not-allowed')} />
setS3Bucket(e.target.value)} placeholder="uploads-bucket" disabled={!storageEditEnabled} className={cn(styles.inputStyles.base, 'text-sm', !storageEditEnabled && 'opacity-60 cursor-not-allowed')} />
setS3Region(e.target.value)} placeholder="us-east-1" disabled={!storageEditEnabled} className={cn(styles.inputStyles.base, 'text-sm', !storageEditEnabled && 'opacity-60 cursor-not-allowed')} />

配置提示

  • S3 端点通常为对象存储服务器地址
  • Access/Secret Key 需保证与存储桶权限一致
  • 如使用 CDN,请将加速域名填入公网 URL
  • 修改后建议重启后台服务确保配置加载
{/* 对象存储修改确认弹窗 */} setShowStorageConfirmModal(false)} onConfirm={() => setStorageEditEnabled(true)} />
) }