'use client' import { useEffect, useState } from 'react' import { useSession } from 'next-auth/react' import { useRouter } from 'next/navigation' import MaterialsHeader from '@/components/board/MaterialsHeader' import BrandLogo from '@/components/BrandLogo' import { useToast } from '@/hooks/useToast' import ToastBubble from '@/components/ToastBubble' import { parseFetchError, handleError } from '@/lib/error-handler' import { DEFAULT_HEADER_LOGO_URL, DEFAULT_LOGIN_LOGO_URL } from '@/lib/brand-assets' const BRAND_LOGO_ACCEPT = 'image/png,image/jpeg,image/webp' const SUPPORTED_BRAND_LOGO_MIME_TYPES = new Set(BRAND_LOGO_ACCEPT.split(',')) type SettingsSessionUser = { role?: string | null isSuperAdmin?: boolean | null } function canManageSystemSettings(user: SettingsSessionUser | null | undefined) { return ( user?.role === 'ADMIN' || user?.role === 'SYSTEM_ADMIN' || user?.isSuperAdmin === true ) } export default function SystemSettings() { const { data: session, status } = useSession() const router = useRouter() const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [uploading, setUploading] = useState(false) // 设置状态 const [headerBgColor, setHeaderBgColor] = useState('#1f2937') const [siteName, setSiteName] = useState('由壹点3D素材') const [headerLogoUrl, setHeaderLogoUrl] = useState('') const [headerLogoPreview, setHeaderLogoPreview] = useState('') const [loginLogoUrl, setLoginLogoUrl] = useState('') const [loginLogoPreview, setLoginLogoPreview] = useState('') const [uploadingHeader, setUploadingHeader] = useState(false) const [uploadingLogin, setUploadingLogin] = useState(false) const { toast, showToast } = useToast() // S3配置状态 const [s3Endpoint, setS3Endpoint] = useState('') const [newS3AccessKey, setNewS3AccessKey] = useState('') const [newS3SecretKey, setNewS3SecretKey] = useState('') const [s3CredentialsConfigured, setS3CredentialsConfigured] = useState(false) const [s3Bucket, setS3Bucket] = useState('') const [s3Region, setS3Region] = useState('us-east-1') const [s3PublicUrl, setS3PublicUrl] = useState('') // 维护通知状态 const [maintenanceEnabled, setMaintenanceEnabled] = useState(false) const [maintenanceMessage, setMaintenanceMessage] = useState('') const [maintenanceStartTime, setMaintenanceStartTime] = useState('') const [maintenanceEndTime, setMaintenanceEndTime] = useState('') // 权限检查 useEffect(() => { if (status === 'unauthenticated') { router.push('/login') } else if ( status === 'authenticated' && !canManageSystemSettings(session?.user as SettingsSessionUser) ) { router.push('/') } }, [status, session, router]) // 加载设置 useEffect(() => { if ( status === 'authenticated' && canManageSystemSettings(session?.user as SettingsSessionUser) ) { fetchSettings() } }, [status, session]) const fetchSettings = async () => { try { const response = await fetch('/api/system-settings') if (response.ok) { const data = await response.json() setHeaderBgColor(data.headerBgColor || '#1f2937') setSiteName(data.siteName || '由壹点3D素材') setHeaderLogoUrl(data.headerLogoUrl || DEFAULT_HEADER_LOGO_URL) setHeaderLogoPreview(data.headerLogoPreviewUrl || data.headerLogoUrl || DEFAULT_HEADER_LOGO_URL) setLoginLogoUrl(data.loginLogoUrl || DEFAULT_LOGIN_LOGO_URL) setLoginLogoPreview(data.loginLogoPreviewUrl || data.loginLogoUrl || DEFAULT_LOGIN_LOGO_URL) setS3Endpoint(data.s3Endpoint || '') setNewS3AccessKey('') setNewS3SecretKey('') setS3CredentialsConfigured(data.s3CredentialsConfigured === true) setS3Bucket(data.s3Bucket || '') setS3Region(data.s3Region || 'us-east-1') setS3PublicUrl(data.s3PublicUrl || '') setMaintenanceEnabled(data.maintenanceEnabled || false) setMaintenanceMessage(data.maintenanceMessage || '') setMaintenanceStartTime(data.maintenanceStartTime ? new Date(data.maintenanceStartTime).toISOString().slice(0, 16) : '') setMaintenanceEndTime(data.maintenanceEndTime ? new Date(data.maintenanceEndTime).toISOString().slice(0, 16) : '') } } catch (error) { console.error('Error fetching settings:', error) const { message } = handleError(error) showToast(message, { variant: 'error' }) } finally { setLoading(false) } } const handleSave = async () => { const nextAccessKey = newS3AccessKey.trim() const nextSecretKey = newS3SecretKey.trim() if (Boolean(nextAccessKey) !== Boolean(nextSecretKey)) { showToast('如需更新存储凭据,请同时填写 Access Key 和 Secret Key', { variant: 'error' }) return } setSaving(true) try { const payload: Record = { headerBgColor, siteName, headerLogoUrl, loginLogoUrl, s3Endpoint, s3Bucket, s3Region, s3PublicUrl, maintenanceEnabled, maintenanceMessage: maintenanceMessage || null, maintenanceStartTime: maintenanceStartTime || null, maintenanceEndTime: maintenanceEndTime || null, } if (nextAccessKey && nextSecretKey) { payload.s3AccessKey = nextAccessKey payload.s3SecretKey = nextSecretKey } const response = await fetch('/api/system-settings', { method: 'PUT', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(payload), }) if (response.ok) { const data = await response.json() setS3CredentialsConfigured(data.s3CredentialsConfigured === true) setNewS3AccessKey('') setNewS3SecretKey('') showToast('保存成功!页面将自动刷新以应用新设置', { variant: 'success', duration: 3000 }) // 刷新页面以应用新设置 setTimeout(() => { window.location.reload() }, 1000) } else { const parsedError = await parseFetchError(response) showToast(parsedError.message, { variant: 'error' }) } } catch (error) { console.error('Save settings error:', error) const { message } = handleError(error) showToast(message, { variant: 'error' }) } finally { setSaving(false) } } const handleHeaderLogoUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (!file) return // 验证文件类型 if (!SUPPORTED_BRAND_LOGO_MIME_TYPES.has(file.type.toLowerCase())) { showToast('LOGO仅支持 PNG、JPEG 或 WebP 格式', { variant: 'error' }) return } // 验证文件大小(最大5MB) if (file.size > 5 * 1024 * 1024) { showToast('LOGO文件大小不能超过5MB', { variant: 'error' }) return } setUploadingHeader(true) try { const formData = new FormData() formData.append('logo', file) formData.append('type', 'header') const response = await fetch('/api/system-settings/upload-logo', { method: 'POST', body: formData, }) if (response.ok) { const data = await response.json() setHeaderLogoUrl(data.logoUrl) setHeaderLogoPreview(data.logoUrl) showToast('导航栏LOGO上传成功!', { variant: 'success' }) } else { const parsedError = await parseFetchError(response) showToast(parsedError.message, { variant: 'error' }) } } catch (error) { console.error('Upload logo error:', error) const { message } = handleError(error) showToast(message, { variant: 'error' }) } finally { setUploadingHeader(false) } } const handleLoginLogoUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (!file) return // 验证文件类型 if (!SUPPORTED_BRAND_LOGO_MIME_TYPES.has(file.type.toLowerCase())) { showToast('LOGO仅支持 PNG、JPEG 或 WebP 格式', { variant: 'error' }) return } // 验证文件大小(最大5MB) if (file.size > 5 * 1024 * 1024) { showToast('LOGO文件大小不能超过5MB', { variant: 'error' }) return } setUploadingLogin(true) try { const formData = new FormData() formData.append('logo', file) formData.append('type', 'login') const response = await fetch('/api/system-settings/upload-logo', { method: 'POST', body: formData, }) if (response.ok) { const data = await response.json() setLoginLogoUrl(data.logoUrl) setLoginLogoPreview(data.logoUrl) showToast('登录页LOGO上传成功!', { variant: 'success' }) } else { const parsedError = await parseFetchError(response) showToast(parsedError.message, { variant: 'error' }) } } catch (error) { console.error('Upload logo error:', error) const { message } = handleError(error) showToast(message, { variant: 'error' }) } finally { setUploadingLogin(false) } } const handleColorReset = () => { setHeaderBgColor('#1f2937') } const handleHeaderLogoReset = () => { setHeaderLogoPreview(DEFAULT_HEADER_LOGO_URL) setHeaderLogoUrl(DEFAULT_HEADER_LOGO_URL) } const handleLoginLogoReset = () => { setLoginLogoPreview(DEFAULT_LOGIN_LOGO_URL) setLoginLogoUrl(DEFAULT_LOGIN_LOGO_URL) } if (status === 'loading' || loading) { return (
加载中...
) } if ( !session || !canManageSystemSettings(session.user as SettingsSessionUser) ) { return null } return (

System settings

系统设置

管理品牌外观、存储连接与维护通知。

{/* 网站名称设置 */}

网站名称

setSiteName(e.target.value)} placeholder="由壹点3D素材" className="w-full px-4 py-3 rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-600 outline-none transition-all" />

该名称将显示在顶部导航栏和登录页面

{/* 导航栏LOGO设置 */}

导航栏LOGO(深色背景)

{/* LOGO预览 */}
{headerLogoPreview ? ( ) : ( )}

用于深色导航栏,建议使用浅色LOGO

建议尺寸:200x200px,支持 PNG、JPEG、WebP 格式

文件大小不超过5MB

{/* 上传按钮 */}
{/* 登录页LOGO设置 */}

登录页LOGO(浅色背景)

{/* LOGO预览 */}
{loginLogoPreview ? ( ) : ( )}

用于浅色登录页面,建议使用深色LOGO

建议尺寸:200x200px,支持 PNG、JPEG、WebP 格式

文件大小不超过5MB

{/* 上传按钮 */}
{/* 主题颜色设置 */}

主题颜色

{/* 颜色预览 */}
顶部导航栏预览
{/* 颜色选择器 */}
setHeaderBgColor(e.target.value)} className="w-16 h-12 rounded cursor-pointer border border-gray-300 dark:border-gray-600" /> setHeaderBgColor(e.target.value)} placeholder="#1f2937" className="flex-1 px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-600 outline-none transition-all font-mono" />
{/* 预设颜色 */}
{[ { name: '默认灰', color: '#1f2937' }, { name: '深蓝', color: '#1e3a8a' }, { name: '靛蓝', color: '#4f46e5' }, { name: '紫色', color: '#7c3aed' }, { name: '粉色', color: '#db2777' }, { name: '红色', color: '#dc2626' }, { name: '橙色', color: '#ea580c' }, { name: '绿色', color: '#059669' }, { name: '青色', color: '#0891b2' }, { name: '深灰', color: '#374151' }, ].map((preset) => ( ))}
{/* 保存按钮 */}
{/* 提示信息 */}

温馨提示:

  • 修改设置后点击“保存设置”按钮,页面会自动刷新以应用新的设置
  • LOGO会自动上传到MinIO S3存储桶,确保全站可访问
  • 建议使用高质量的LOGO图片以获得最佳显示效果
  • 主题颜色建议选择深色系,以确保白色文字清晰可见;主题颜色会应用到导航栏、按钮等UI元素
{/* S3存储桶配置 */}

S3存储桶配置

配置S3对象存储,用于存储上传的图片和文件

{/* S3端点 */}
setS3Endpoint(e.target.value)} placeholder="例如: https://storage.example.com" className="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-600 outline-none transition-all" />
{/* Access Key & Secret Key:只允许覆盖写入,不回填原文 */}

访问凭据

已保存的凭据不会返回浏览器;仅在轮换时填写新值。

{s3CredentialsConfigured ? '已配置' : '未配置'}
setNewS3AccessKey(e.target.value)} placeholder={s3CredentialsConfigured ? '留空则保持当前配置' : '输入新的 Access Key'} className="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-600 outline-none transition-all font-mono text-sm" />
setNewS3SecretKey(e.target.value)} placeholder={s3CredentialsConfigured ? '留空则保持当前配置' : '输入新的 Secret Key'} className="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-600 outline-none transition-all font-mono text-sm" />

两项必须同时填写。保存后输入框会清空,页面只显示配置状态。

{/* Bucket & Region */}
setS3Bucket(e.target.value)} placeholder="例如: my-bucket" className="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-600 outline-none transition-all" />
setS3Region(e.target.value)} placeholder="例如: us-east-1" className="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-600 outline-none transition-all" />
{/* 公网访问URL */}
setS3PublicUrl(e.target.value)} placeholder="例如: https://cdn.example.com" className="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-600 outline-none transition-all" />

如果配置了CDN或自定义域名,请填写此项

{/* 提示信息 */}

配置说明:

  • S3端点通常是您的对象存储服务器地址
  • Access Key 和 Secret Key 只可覆盖写入,不会显示已保存的原文
  • 存储桶需要预先创建并设置公开读取权限
  • 修改配置后需要重启应用才能生效
{/* 系统维护通知 */}

系统维护通知

{/* 启用维护通知 */}
setMaintenanceEnabled(e.target.checked)} className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
{maintenanceEnabled && ( <> {/* 维护通知内容 */}