Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
891 lines
40 KiB
TypeScript
891 lines
40 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useState } from 'react'
|
||
import { useSession } from 'next-auth/react'
|
||
import { useRouter } from 'next/navigation'
|
||
import Header from '@/components/Header'
|
||
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<string, unknown> = {
|
||
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<HTMLInputElement>) => {
|
||
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<HTMLInputElement>) => {
|
||
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 (
|
||
<div className="min-h-screen flex items-center justify-center dark:bg-gray-900">
|
||
<div className="text-xl text-gray-600 dark:text-gray-400">加载中...</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (
|
||
!session ||
|
||
!canManageSystemSettings(session.user as SettingsSessionUser)
|
||
) {
|
||
return null
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||
<Header session={session} />
|
||
|
||
<main className="container mx-auto px-4 py-8 max-w-4xl">
|
||
<div className="mb-6">
|
||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">系统设置</h1>
|
||
<p className="text-gray-600 dark:text-gray-400">自定义网站外观和品牌信息</p>
|
||
</div>
|
||
|
||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 space-y-8">
|
||
{/* 网站名称设置 */}
|
||
<div>
|
||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
|
||
<svg className="w-6 h-6 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||
</svg>
|
||
网站名称
|
||
</h2>
|
||
<div className="space-y-2">
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||
站点名称
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={siteName}
|
||
onChange={(e) => 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"
|
||
/>
|
||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||
该名称将显示在顶部导航栏和登录页面
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 导航栏LOGO设置 */}
|
||
<div className="border-t border-gray-200 dark:border-gray-700 pt-8">
|
||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
|
||
<svg className="w-6 h-6 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||
</svg>
|
||
导航栏LOGO(深色背景)
|
||
</h2>
|
||
<div className="space-y-4">
|
||
{/* LOGO预览 */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
当前LOGO预览
|
||
</label>
|
||
<div className="flex items-center gap-4">
|
||
<div className="w-32 h-32 rounded-lg border-2 border-gray-300 dark:border-gray-600 flex items-center justify-center overflow-hidden" style={{ backgroundColor: headerBgColor }}>
|
||
{headerLogoPreview ? (
|
||
<BrandLogo
|
||
src={headerLogoPreview}
|
||
fallbackSrc={DEFAULT_HEADER_LOGO_URL}
|
||
alt="导航栏LOGO"
|
||
width={160}
|
||
height={64}
|
||
unoptimized
|
||
className="max-h-full max-w-full object-contain"
|
||
/>
|
||
) : (
|
||
<svg className="w-16 h-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||
</svg>
|
||
)}
|
||
</div>
|
||
<div className="flex-1">
|
||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||
用于深色导航栏,建议使用浅色LOGO
|
||
</p>
|
||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||
建议尺寸:200x200px,支持 PNG、JPEG、WebP 格式
|
||
</p>
|
||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||
文件大小不超过5MB
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 上传按钮 */}
|
||
<div className="flex gap-3">
|
||
<label className="btn-primary cursor-pointer inline-flex items-center gap-2 disabled:opacity-50">
|
||
{uploadingHeader ? (
|
||
<>
|
||
<svg className="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||
</svg>
|
||
上传中...
|
||
</>
|
||
) : (
|
||
<>
|
||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||
</svg>
|
||
上传导航栏LOGO
|
||
</>
|
||
)}
|
||
<input
|
||
type="file"
|
||
accept={BRAND_LOGO_ACCEPT}
|
||
onChange={handleHeaderLogoUpload}
|
||
disabled={uploadingHeader}
|
||
className="hidden"
|
||
/>
|
||
</label>
|
||
<button
|
||
onClick={handleHeaderLogoReset}
|
||
className="btn-secondary"
|
||
disabled={uploadingHeader}
|
||
>
|
||
恢复默认
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 登录页LOGO设置 */}
|
||
<div className="border-t border-gray-200 dark:border-gray-700 pt-8">
|
||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
|
||
<svg className="w-6 h-6 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||
</svg>
|
||
登录页LOGO(浅色背景)
|
||
</h2>
|
||
<div className="space-y-4">
|
||
{/* LOGO预览 */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
当前LOGO预览
|
||
</label>
|
||
<div className="flex items-center gap-4">
|
||
<div className="w-32 h-32 rounded-lg border-2 border-gray-300 dark:border-gray-600 flex items-center justify-center overflow-hidden bg-white">
|
||
{loginLogoPreview ? (
|
||
<BrandLogo
|
||
src={loginLogoPreview}
|
||
fallbackSrc={DEFAULT_LOGIN_LOGO_URL}
|
||
alt="登录页LOGO"
|
||
width={160}
|
||
height={96}
|
||
unoptimized
|
||
className="max-h-full max-w-full object-contain"
|
||
/>
|
||
) : (
|
||
<svg className="w-16 h-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||
</svg>
|
||
)}
|
||
</div>
|
||
<div className="flex-1">
|
||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||
用于浅色登录页面,建议使用深色LOGO
|
||
</p>
|
||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||
建议尺寸:200x200px,支持 PNG、JPEG、WebP 格式
|
||
</p>
|
||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||
文件大小不超过5MB
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 上传按钮 */}
|
||
<div className="flex gap-3">
|
||
<label className="btn-primary cursor-pointer inline-flex items-center gap-2 disabled:opacity-50">
|
||
{uploadingLogin ? (
|
||
<>
|
||
<svg className="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||
</svg>
|
||
上传中...
|
||
</>
|
||
) : (
|
||
<>
|
||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||
</svg>
|
||
上传登录页LOGO
|
||
</>
|
||
)}
|
||
<input
|
||
type="file"
|
||
accept={BRAND_LOGO_ACCEPT}
|
||
onChange={handleLoginLogoUpload}
|
||
disabled={uploadingLogin}
|
||
className="hidden"
|
||
/>
|
||
</label>
|
||
<button
|
||
onClick={handleLoginLogoReset}
|
||
className="btn-secondary"
|
||
disabled={uploadingLogin}
|
||
>
|
||
恢复默认
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 主题颜色设置 */}
|
||
<div className="border-t border-gray-200 dark:border-gray-700 pt-8">
|
||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
|
||
<svg className="w-6 h-6 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
|
||
</svg>
|
||
主题颜色
|
||
</h2>
|
||
<div className="space-y-4">
|
||
{/* 颜色预览 */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
颜色预览
|
||
</label>
|
||
<div
|
||
className="w-full h-16 rounded-lg border-2 border-gray-300 dark:border-gray-600 shadow-inner"
|
||
style={{ backgroundColor: headerBgColor }}
|
||
>
|
||
<div className="flex items-center h-full px-4">
|
||
<span className="text-white font-medium" style={{ textShadow: '0 1px 2px rgba(0,0,0,0.3)' }}>
|
||
顶部导航栏预览
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 颜色选择器 */}
|
||
<div className="flex items-center gap-4">
|
||
<div className="flex-1">
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
选择颜色
|
||
</label>
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
type="color"
|
||
value={headerBgColor}
|
||
onChange={(e) => setHeaderBgColor(e.target.value)}
|
||
className="w-16 h-12 rounded cursor-pointer border border-gray-300 dark:border-gray-600"
|
||
/>
|
||
<input
|
||
type="text"
|
||
value={headerBgColor}
|
||
onChange={(e) => 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"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 opacity-0">
|
||
操作
|
||
</label>
|
||
<button
|
||
onClick={handleColorReset}
|
||
className="btn-secondary whitespace-nowrap"
|
||
>
|
||
恢复默认
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 预设颜色 */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
快速选择
|
||
</label>
|
||
<div className="flex flex-wrap gap-2">
|
||
{[
|
||
{ 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) => (
|
||
<button
|
||
key={preset.color}
|
||
onClick={() => setHeaderBgColor(preset.color)}
|
||
className={`px-4 py-2 rounded-lg border-2 transition-all ${
|
||
headerBgColor === preset.color
|
||
? 'border-blue-500 ring-2 ring-blue-200'
|
||
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400'
|
||
}`}
|
||
style={{ backgroundColor: preset.color }}
|
||
title={preset.color}
|
||
>
|
||
<span className="text-white text-sm font-medium" style={{ textShadow: '0 1px 2px rgba(0,0,0,0.5)' }}>
|
||
{preset.name}
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 保存按钮 */}
|
||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6 flex justify-between items-center">
|
||
<button
|
||
onClick={() => router.push('/admin')}
|
||
className="btn-secondary"
|
||
>
|
||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||
</svg>
|
||
返回
|
||
</button>
|
||
<button
|
||
onClick={handleSave}
|
||
disabled={saving}
|
||
className="btn-primary text-lg px-8 py-3 disabled:opacity-50"
|
||
>
|
||
{saving ? (
|
||
<>
|
||
<svg className="w-5 h-5 mr-2 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||
</svg>
|
||
保存中...
|
||
</>
|
||
) : (
|
||
<>
|
||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||
</svg>
|
||
保存设置
|
||
</>
|
||
)}
|
||
</button>
|
||
</div>
|
||
|
||
{/* 提示信息 */}
|
||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||
<div className="flex items-start gap-3">
|
||
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||
</svg>
|
||
<div className="text-sm text-blue-800 dark:text-blue-300">
|
||
<p className="font-medium mb-1">温馨提示:</p>
|
||
<ul className="list-disc list-inside space-y-1">
|
||
<li>修改设置后点击“保存设置”按钮,页面会自动刷新以应用新的设置</li>
|
||
<li>LOGO会自动上传到MinIO S3存储桶,确保全站可访问</li>
|
||
<li>建议使用高质量的LOGO图片以获得最佳显示效果</li>
|
||
<li>主题颜色建议选择深色系,以确保白色文字清晰可见;主题颜色会应用到导航栏、按钮等UI元素</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* S3存储桶配置 */}
|
||
<div className="border-t border-gray-200 dark:border-gray-700 pt-8">
|
||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
|
||
<svg className="w-6 h-6 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
|
||
</svg>
|
||
S3存储桶配置
|
||
</h2>
|
||
<div className="space-y-4">
|
||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||
配置S3对象存储,用于存储上传的图片和文件
|
||
</p>
|
||
|
||
{/* S3端点 */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
S3端点 (Endpoint)
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={s3Endpoint}
|
||
onChange={(e) => 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"
|
||
/>
|
||
</div>
|
||
|
||
{/* Access Key & Secret Key:只允许覆盖写入,不回填原文 */}
|
||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||
<div>
|
||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||
访问凭据
|
||
</p>
|
||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||
已保存的凭据不会返回浏览器;仅在轮换时填写新值。
|
||
</p>
|
||
</div>
|
||
<span
|
||
className={`inline-flex w-fit items-center rounded-full px-2.5 py-1 text-xs font-medium ${
|
||
s3CredentialsConfigured
|
||
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
|
||
: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||
}`}
|
||
aria-label={`S3 凭据状态:${s3CredentialsConfigured ? '已配置' : '未配置'}`}
|
||
>
|
||
{s3CredentialsConfigured ? '已配置' : '未配置'}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||
<div>
|
||
<label
|
||
htmlFor="newS3AccessKey"
|
||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
||
>
|
||
新 Access Key
|
||
</label>
|
||
<input
|
||
id="newS3AccessKey"
|
||
name="newS3AccessKey"
|
||
type="password"
|
||
autoComplete="new-password"
|
||
spellCheck={false}
|
||
value={newS3AccessKey}
|
||
onChange={(e) => 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"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label
|
||
htmlFor="newS3SecretKey"
|
||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
||
>
|
||
新 Secret Key
|
||
</label>
|
||
<input
|
||
id="newS3SecretKey"
|
||
name="newS3SecretKey"
|
||
type="password"
|
||
autoComplete="new-password"
|
||
spellCheck={false}
|
||
value={newS3SecretKey}
|
||
onChange={(e) => 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"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||
两项必须同时填写。保存后输入框会清空,页面只显示配置状态。
|
||
</p>
|
||
|
||
{/* Bucket & Region */}
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
存储桶名称 (Bucket)
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={s3Bucket}
|
||
onChange={(e) => 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"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
区域 (Region)
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={s3Region}
|
||
onChange={(e) => 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"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 公网访问URL */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
公网访问URL (Public URL)
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={s3PublicUrl}
|
||
onChange={(e) => 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"
|
||
/>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||
如果配置了CDN或自定义域名,请填写此项
|
||
</p>
|
||
</div>
|
||
|
||
{/* 提示信息 */}
|
||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mt-4">
|
||
<div className="flex items-start gap-3">
|
||
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||
</svg>
|
||
<div className="text-sm text-blue-800 dark:text-blue-300">
|
||
<p className="font-semibold mb-2">配置说明:</p>
|
||
<ul className="space-y-1 list-disc list-inside">
|
||
<li>S3端点通常是您的对象存储服务器地址</li>
|
||
<li>Access Key 和 Secret Key 只可覆盖写入,不会显示已保存的原文</li>
|
||
<li>存储桶需要预先创建并设置公开读取权限</li>
|
||
<li>修改配置后需要重启应用才能生效</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 系统维护通知 */}
|
||
<div className="card-apple">
|
||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-6">系统维护通知</h2>
|
||
<div className="space-y-6">
|
||
{/* 启用维护通知 */}
|
||
<div className="flex items-center gap-3">
|
||
<input
|
||
type="checkbox"
|
||
id="maintenanceEnabled"
|
||
checked={maintenanceEnabled}
|
||
onChange={(e) => setMaintenanceEnabled(e.target.checked)}
|
||
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||
/>
|
||
<label htmlFor="maintenanceEnabled" className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||
启用系统维护通知
|
||
</label>
|
||
</div>
|
||
|
||
{maintenanceEnabled && (
|
||
<>
|
||
{/* 维护通知内容 */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
维护通知内容 <span className="text-red-500">*</span>
|
||
</label>
|
||
<textarea
|
||
value={maintenanceMessage}
|
||
onChange={(e) => setMaintenanceMessage(e.target.value)}
|
||
placeholder="例如:系统将于今晚22:00-24:00进行维护升级,期间可能无法正常访问,请提前做好准备。"
|
||
rows={4}
|
||
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"
|
||
/>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||
此通知将显示在所有页面的顶部横幅中
|
||
</p>
|
||
</div>
|
||
|
||
{/* 维护开始时间 */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
维护开始时间(可选)
|
||
</label>
|
||
<input
|
||
type="datetime-local"
|
||
value={maintenanceStartTime}
|
||
onChange={(e) => setMaintenanceStartTime(e.target.value)}
|
||
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"
|
||
/>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||
留空则立即显示通知
|
||
</p>
|
||
</div>
|
||
|
||
{/* 维护结束时间 */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
维护结束时间(可选)
|
||
</label>
|
||
<input
|
||
type="datetime-local"
|
||
value={maintenanceEndTime}
|
||
onChange={(e) => setMaintenanceEndTime(e.target.value)}
|
||
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"
|
||
/>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||
到达结束时间后,通知将自动隐藏
|
||
</p>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
|
||
{/* 统一的Toast提示 */}
|
||
{toast && toast.show && (
|
||
<ToastBubble message={toast.message} variant={toast.variant} />
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|