chenpei 7326e273e1 fix: harden media upload and release safety
Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
2026-08-12 18:04:08 +08:00

891 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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>修改设置后点击&ldquo;保存设置&rdquo;按钮,页面会自动刷新以应用新的设置</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>
)
}