flnxi-Wu/app/admin/_components/AppearanceSettingsPanel.tsx
WuYongJie e4dcb6260a
Some checks failed
CI Check / check (push) Has been cancelled
feat: Admin页面重构 & UI三层架构系统
- 创建统一UI三层架构系统(基础层、颜色层、组合层)
- 拆分Admin页面为独立Tab组件(MonitorTab、UsersTab、DepartmentsTab等)
- 提取公共逻辑到Hooks(useToast、useDataFetch、useUserOperations等)
- 提取工具函数(validators、formatters、api等)
- 创建统一弹窗组件(Modal)和useModal Hook
- 添加错误边界和统一状态组件
- 完善类型定义和可访问性支持
- 更新文档(优化记录、优化计划、更新日志)
2025-11-22 15:17:10 +08:00

859 lines
37 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 { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react'
import Image from 'next/image'
import { useThemeStyles, useTheme, cn } from '@/lib/ui-styles'
import { animations, gradientBackgrounds, glowEffects, textAnimations } from '@/lib/ui-animations'
import type { SessionUser } from './types'
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: 'LOGO 管理', hint: '导航栏、登录页 LOGO' },
{ key: 'toggles', label: '功能开关', hint: '敏感功能开关' },
{ key: 'storage', label: '对象存储', hint: 'S3 / MinIO 配置' },
] as const
type SectionKey = (typeof sectionConfig)[number]['key']
export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedded' }: AppearanceSettingsPanelProps) {
const styles = useThemeStyles()
const theme = useTheme()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = 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 [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(true)
const [enableAiImageEdit, setEnableAiImageEdit] = useState(false)
const [enableTunnelService, setEnableTunnelService] = useState(false)
const [toast, setToast] = useState<ToastState | null>(null)
const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null)
const [heroGlowPos, setHeroGlowPos] = useState({ x: 50, y: 50 })
const [activeNav, setActiveNav] = useState<SectionKey>('brand')
const [visibleSections, setVisibleSections] = useState<Record<SectionKey, boolean>>({
brand: true,
logos: true,
toggles: true,
storage: true,
})
const [isDarkMode, setIsDarkMode] = useState(false)
const brandRef = useRef<HTMLDivElement | null>(null)
const logoRef = useRef<HTMLDivElement | null>(null)
const toggleRef = useRef<HTMLDivElement | null>(null)
const storageRef = useRef<HTMLDivElement | null>(null)
const sectionRefs: Record<(typeof sectionConfig)[number]['key'], RefObject<HTMLDivElement>> = useMemo(
() => ({
brand: brandRef,
logos: logoRef,
toggles: toggleRef,
storage: storageRef,
}),
[]
)
const showToast = useCallback((next: ToastState) => {
setToast(next)
setTimeout(() => setToast(null), 3600)
}, [])
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const key = entry.target.getAttribute('data-section') as SectionKey | null
if (!key) return
if (entry.isIntersecting) {
setVisibleSections((prev) => (prev[key] ? prev : { ...prev, [key]: true }))
setActiveNav(key)
}
})
},
{ threshold: 0.35, rootMargin: '0px 0px -10% 0px' }
)
const nodes = Object.values(sectionRefs)
.map((ref) => ref.current)
.filter((node): node is HTMLDivElement => Boolean(node))
nodes.forEach((node) => observer.observe(node))
return () => nodes.forEach((node) => observer.unobserve(node))
}, [sectionRefs])
useEffect(() => {
if (typeof document === 'undefined') return
const root = document.documentElement
const update = () => setIsDarkMode(root.classList.contains('dark'))
update()
const observer = new MutationObserver(update)
observer.observe(root, { attributes: true, attributeFilter: ['class'] })
return () => observer.disconnect()
}, [])
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 || '由壹点3D素材')
setHeaderLogoUrl(data.headerLogoUrl || '')
setHeaderLogoPreview(data.headerLogoUrl || '')
setLoginLogoUrl(data.loginLogoUrl || '')
setLoginLogoPreview(data.loginLogoUrl || '')
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 ?? true)
setEnableAiImageEdit(data.enableAiImageEdit ?? false)
setEnableTunnelService(data.enableTunnelService ?? false)
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 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,
}),
})
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 = () => {
const def = process.env.NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL || ''
setHeaderLogoPreview(def)
setHeaderLogoUrl(def)
}
const handleLoginLogoReset = () => {
const def = process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || ''
setLoginLogoPreview(def)
setLoginLogoUrl(def)
}
const scrollToSection = (key: SectionKey) => {
const ref = sectionRefs[key]
ref?.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
setActiveNav(key)
}
const [isMouseOver, setIsMouseOver] = useState(false)
const handleHeroMouseMove = (event: React.MouseEvent<HTMLDivElement>) => {
const rect = event.currentTarget.getBoundingClientRect()
const x = ((event.clientX - rect.left) / rect.width) * 100
const y = ((event.clientY - rect.top) / rect.height) * 100
setHeroGlowPos({ x, y })
setIsMouseOver(true)
}
const handleHeroMouseLeave = () => {
setIsMouseOver(false)
// 鼠标离开时,光晕应该平滑消失,而不是闪现到中心
// 通过设置 isMouseOver 为 false,背景渐变会自动消失
}
// 统一的 Hero 卡片背景渐变 - 使用统一颜色定义(与「组织脉络」卡片相同)
const heroDynamicBackground = {
backgroundImage: isMouseOver
? `radial-gradient(circle 300px at ${heroGlowPos.x}% ${heroGlowPos.y}%, ${
isDarkMode
? 'rgba(255,255,255,0.2), rgba(255,255,255,0.1), rgba(255,255,255,0.05), transparent 75%'
: 'rgba(255,255,255,0.3), rgba(255,255,255,0.15), rgba(255,255,255,0.08), transparent 75%'
}), ${isDarkMode ? gradientBackgrounds.heroBase.dark : gradientBackgrounds.heroBase.light}`
: isDarkMode ? gradientBackgrounds.heroBase.dark : gradientBackgrounds.heroBase.light,
transition: 'background-image 0.4s ease-out',
}
const sectionRevealClass = (key: SectionKey) =>
cn(
'transition-all duration-700 ease-out transform',
visibleSections[key] ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 translate-y-6 scale-95'
)
const navButtonClass = (key: SectionKey) =>
cn(
'w-full text-left group',
activeNav === key ? styles.navItemStyles.active : styles.navItemStyles.hover
)
// 更精致的光晕效果 - 使用更相近的颜色,减少数量
const floatingOrbs = (
<>
{/* 精致光球 - 右上角 - 使用相近的蓝色系 */}
<div className={cn('absolute -top-8 -right-8 h-24 w-24 rounded-full blur-3xl pointer-events-none animate-pulse-slow', theme === 'dark' ? 'bg-indigo-500/15' : 'bg-indigo-400/20')} />
{/* 精致光球 - 左下角 - 使用相近的紫色系 */}
<div className={cn('absolute -bottom-8 -left-8 h-20 w-20 rounded-full blur-3xl pointer-events-none animate-[pulse_8s_ease-in-out_infinite]', theme === 'dark' ? 'bg-purple-500/15' : 'bg-purple-400/20')} style={{ animationDelay: '2s' }} />
</>
)
const staggerDelays: Record<SectionKey, string> = {
brand: 'delay-[120ms]',
logos: 'delay-[220ms]',
toggles: 'delay-[320ms]',
storage: 'delay-[420ms]',
}
if (!sessionUser || sessionUser.role !== 'ADMIN') {
return (
<div className={cn(
'rounded-2xl border px-6 py-4 text-sm',
theme === 'dark'
? 'border-red-800 bg-red-900/30 text-red-400'
: 'border-red-200 bg-red-50/60 text-red-600'
)}>
当前账号无权查看外观设置。
</div>
)
}
if (loading) {
return (
<div className={cn(styles.cardStyles.base, 'flex flex-col items-center gap-4')}>
<div className="h-12 w-12 rounded-full border-4 border-slate-200 border-t-indigo-500 animate-spin" />
<p className={cn('text-sm', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>正在加载外观设置...</p>
</div>
)
}
return (
<div className="space-y-8">
<section
className={cn(
'relative overflow-hidden rounded-3xl border',
theme === 'dark'
? 'border-slate-600/60 text-white shadow-[0_28px_60px_rgba(139,92,246,0.2)] hover:shadow-[0_35px_65px_rgba(139,92,246,0.3)]'
: 'border-slate-200/70 text-slate-900 shadow-[0_28px_60px_rgba(15,23,42,0.28)] hover:shadow-[0_35px_65px_rgba(15,23,42,0.35)]',
'transition-all duration-500 p-6',
glowEffects.glowHoverRainbow
)}
onMouseMove={handleHeroMouseMove}
onMouseLeave={handleHeroMouseLeave}
style={heroDynamicBackground}
>
{floatingOrbs}
<div className="relative z-10 flex flex-col gap-5 xl:flex-row xl:items-center xl:justify-between">
<div className="space-y-3 max-w-2xl">
<p className={cn('text-sm font-semibold tracking-[0.2em] uppercase', textAnimations.gradientText)}>Appearance Studio</p>
<h2 className={cn('text-2xl font-semibold', textAnimations.gradientTextRainbow)}>外观设置工作台</h2>
<p className={cn('text-sm leading-relaxed', theme === 'dark' ? 'text-slate-100/90' : 'text-slate-700/90')}>
沿用模板管理的分区式结构,这里集中处理品牌主题、LOGO、功能开关与对象存储。每次保存都会即时同步到后台界面与素材上传入口。
</p>
{lastSavedAt && (
<p className={cn('text-xs', theme === 'dark' ? 'text-slate-300' : 'text-slate-600')}>
最近保存时间:{lastSavedAt.toLocaleString()}
</p>
)}
</div>
<div className="flex flex-wrap gap-3">
{variant === 'standalone' && (
<button
onClick={() => scrollToSection('brand')}
className={cn(
'inline-flex items-center gap-2 rounded-xl border px-5 py-3 text-sm font-medium transition-all hover:bg-white/20',
theme === 'dark'
? 'border-white/20 bg-white/5 text-white/80 hover:bg-white/10'
: 'border-white/40 bg-white/10 text-white/90'
)}
>
快速定位
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M9 5l7 7-7 7" />
</svg>
</button>
)}
<button
type="button"
onClick={handleSave}
disabled={saving}
className={cn(
'inline-flex items-center gap-2 rounded-xl bg-white/90 px-6 py-3 font-semibold shadow-lg shadow-indigo-500/25 hover:bg-white transition-all',
theme === 'dark' ? 'text-indigo-200' : 'text-indigo-700',
'disabled:opacity-60'
)}
>
{saving ? (
<>
<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="M5 13l4 4L19 7" />
</svg>
保存设置
</>
)}
</button>
</div>
</div>
</section>
<div className="grid gap-6 xl:grid-cols-[320px,1fr]">
<div className="space-y-6">
<div className={cn(styles.cardStyles.base, 'p-5 backdrop-blur-sm')}>
<p className={cn('text-xs font-semibold tracking-[0.3em] uppercase mb-4', theme === 'dark' ? 'text-slate-200' : 'text-slate-500')}>
导航
</p>
<div className="space-y-3">
{sectionConfig.map((section) => (
<button
key={section.key}
type="button"
onClick={() => scrollToSection(section.key)}
className={navButtonClass(section.key)}
aria-current={activeNav === section.key}
>
<p className={cn('text-sm font-semibold flex items-center justify-between', theme === 'dark' ? 'text-white' : 'text-slate-900')}>
{section.label}
<svg
className={`w-4 h-4 text-slate-400 transition-colors duration-300 ${
activeNav === section.key ? 'text-indigo-500' : 'group-hover:text-indigo-500'
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M9 5l7 7-7 7" />
</svg>
</p>
<p className={cn('text-xs mt-1', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>{section.hint}</p>
</button>
))}
</div>
</div>
<div className={cn(
styles.cardStyles.base,
'p-5 space-y-4 backdrop-blur-sm',
theme === 'dark'
? 'bg-gradient-to-br from-slate-900 to-slate-800'
: 'bg-gradient-to-br from-white to-slate-50'
)}>
<div>
<p className="text-xs font-semibold text-slate-500 tracking-[0.3em] uppercase">状态</p>
<p className={cn('text-lg font-semibold mt-1', theme === 'dark' ? 'text-white' : 'text-slate-900')}>当前主题色</p>
</div>
<div className={cn(
'w-full h-24 rounded-2xl border shadow-inner',
theme === 'dark' ? 'border-slate-700/60' : 'border-slate-200/50'
)} style={{ backgroundColor: headerBgColor }}>
<div className="flex items-center justify-between h-full px-4 text-white/90">
<div>
<p className="text-sm font-semibold">{siteName}</p>
<p className="text-xs opacity-80 tracking-widest uppercase">{headerBgColor}</p>
</div>
<span className={cn(
'text-xs font-semibold px-3 py-1 rounded-full',
theme === 'dark' ? 'bg-white/15 text-white/80' : 'bg-white/25 text-white/90'
)}>导航示例</span>
</div>
</div>
<button
onClick={handleColorReset}
className={cn(styles.buttonStyles.secondary, 'w-full')}
>
恢复默认主题色
</button>
</div>
</div>
<div className="space-y-8">
<div
ref={brandRef}
data-section="brand"
className={cn(
styles.cardStyles.large,
'space-y-6',
sectionRevealClass('brand'),
staggerDelays.brand
)}
>
<header className="space-y-1">
<p className="text-xs font-semibold text-indigo-500 tracking-[0.4em] uppercase">Section 01</p>
<h3 className={cn('text-xl font-semibold', theme === 'dark' ? 'text-white' : 'text-slate-900')}>品牌与主题</h3>
<p className={cn('text-sm', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>同步修改站点名称以及顶部导航栏主色。</p>
</header>
<div className="grid gap-6 lg:grid-cols-2">
<div className="space-y-3">
<label className={cn('text-sm font-medium', theme === 'dark' ? 'text-slate-200' : 'text-slate-700')}>站点名称</label>
<input
type="text"
value={siteName}
onChange={(e) => setSiteName(e.target.value)}
placeholder="由壹点3D素材"
className={styles.inputStyles.base}
/>
<p className={cn('text-xs', theme === 'dark' ? 'text-slate-500' : 'text-slate-400')}>将同步至后台导航、登录页和浏览器标题。</p>
</div>
<div className="space-y-3">
<label className={cn('text-sm font-medium', theme === 'dark' ? 'text-slate-200' : 'text-slate-700')}>主题色</label>
<div className="flex items-center gap-3">
<label
className={cn(
'relative w-16 h-16 border rounded-none shadow-inner cursor-pointer',
theme === 'dark' ? 'border-slate-600 shadow-black/60' : 'border-slate-200 shadow-black/10'
)}
style={{ backgroundColor: headerBgColor }}
>
<span className="sr-only">选择主题色</span>
<input
type="color"
value={headerBgColor}
onChange={(e) => setHeaderBgColor(e.target.value)}
className="absolute inset-0 opacity-0 cursor-pointer"
/>
</label>
<input
type="text"
value={headerBgColor}
onChange={(e) => setHeaderBgColor(e.target.value)}
className={cn(styles.inputStyles.base, 'flex-1 text-sm font-mono')}
/>
</div>
<div className="flex flex-wrap gap-2 pt-2">
{['#1f2937', '#4f46e5', '#1e40af', '#7c3aed', '#0891b2', '#059669', '#dc2626', '#d97706'].map((color) => (
<button
key={color}
onClick={() => setHeaderBgColor(color)}
type="button"
className={`w-10 h-10 rounded-none border ${headerBgColor === color ? 'border-white/80 ring-2 ring-indigo-400 shadow-lg shadow-indigo-500/40' : 'border-white/20'} transition-all`}
style={{ backgroundColor: color }}
/>
))}
</div>
</div>
</div>
</div>
<div
ref={logoRef}
data-section="logos"
className={cn(
styles.cardStyles.large,
'space-y-6',
sectionRevealClass('logos'),
staggerDelays.logos
)}
>
<header className="space-y-1">
<p className="text-xs font-semibold text-indigo-500 tracking-[0.4em] uppercase">Section 02</p>
<h3 className={cn('text-xl font-semibold', theme === 'dark' ? 'text-white' : 'text-slate-900')}>LOGO 管理</h3>
<p className={cn('text-sm', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>为深色导航与浅色登录界面分别上传 LOGO。</p>
</header>
<div className="grid gap-6 lg:grid-cols-2">
{[
{
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) => (
<div key={item.title} className="space-y-4">
<div>
<p className={cn('text-sm font-semibold', theme === 'dark' ? 'text-white' : 'text-slate-900')}>{item.title}</p>
<p className={cn('text-xs mt-1', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>{item.tip}</p>
</div>
<div
className={cn(
'w-full aspect-square max-w-[220px] rounded-2xl border-2 border-dashed flex items-center justify-center overflow-hidden',
theme === 'dark' ? 'border-slate-700' : 'border-slate-200'
)}
style={{ backgroundColor: item.background }}
>
{item.preview ? (
<Image src={item.preview} alt={item.title} width={200} height={200} unoptimized className="object-contain max-h-full" />
) : (
<svg className={cn('w-12 h-12', theme === 'dark' ? 'text-slate-400' : 'text-white/80')} 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 flex-wrap gap-3">
<label className={cn(styles.buttonStyles.primary, 'px-4 py-2 inline-flex items-center gap-2 cursor-pointer')}>
{item.uploading ? (
<>
<svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
上传中...
</>
) : (
<>
<svg className="w-4 h-4" 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="image/*"
disabled={item.uploading}
onChange={(event) => {
const file = event.target.files?.[0]
if (file) item.onUpload(file, item.type)
}}
className="hidden"
/>
</label>
<button
type="button"
onClick={item.reset}
className={cn(styles.buttonStyles.secondary, 'px-4 py-2 text-sm')}
>
恢复默认
</button>
</div>
</div>
))}
</div>
</div>
<div
ref={toggleRef}
data-section="toggles"
className={cn(
styles.cardStyles.large,
'space-y-6',
sectionRevealClass('toggles'),
staggerDelays.toggles
)}
>
<header className="space-y-1">
<p className="text-xs font-semibold text-indigo-500 tracking-[0.4em] uppercase">Section 03</p>
<h3 className={cn('text-xl font-semibold', theme === 'dark' ? 'text-white' : 'text-slate-900')}>功能开关</h3>
<p className={cn('text-sm', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>敏感功能仅限超级管理员调整,其他开关即时生效。</p>
</header>
<div className="space-y-4">
{[
{
title: '用户激活功能',
desc: '开启后新注册用户需管理员激活才能登录。',
value: enableUserActivation,
onChange: setEnableUserActivation,
disabled: !sessionUser?.isSuperAdmin,
},
{
title: '用户手动注册',
desc: '关闭后仅能通过邀请进入系统。',
value: enableRegistration,
onChange: setEnableRegistration,
disabled: false,
},
{
title: 'AI 改图能力(预留)',
desc: '控制素材详情页的 AI 改图入口(功能预留)。',
value: enableAiImageEdit,
onChange: setEnableAiImageEdit,
disabled: !sessionUser?.isSuperAdmin,
},
{
title: '付费内网穿透',
desc: '接入第三方服务以支持公网访问(功能预留)。',
value: enableTunnelService,
onChange: setEnableTunnelService,
disabled: !sessionUser?.isSuperAdmin,
},
].map((item) => (
<div key={item.title} className={cn(
'flex items-center justify-between gap-4 rounded-2xl border px-4 py-3',
theme === 'dark' ? 'border-slate-700' : 'border-slate-200'
)}>
<div>
<p className={cn('text-sm font-semibold', theme === 'dark' ? 'text-white' : 'text-slate-900')}>{item.title}</p>
<p className={cn('text-xs mt-1', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>{item.desc}</p>
{item.disabled && (
<p className="text-[10px] text-amber-500/90 mt-1">仅超级管理员可修改</p>
)}
</div>
<label className={`relative inline-flex items-center ${item.disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}>
<input
type="checkbox"
className="sr-only peer"
checked={item.value}
onChange={(event) => item.onChange(event.target.checked)}
disabled={item.disabled}
/>
<div className={cn(
'w-11 h-6 peer-focus:outline-none peer-focus:ring-4 rounded-full peer peer-checked:after:translate-x-full after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-indigo-600',
theme === 'dark'
? 'bg-slate-700 peer-focus:ring-indigo-800'
: 'bg-slate-200 peer-focus:ring-indigo-200'
)}></div>
</label>
</div>
))}
</div>
</div>
<div
ref={storageRef}
data-section="storage"
className={cn(
styles.cardStyles.large,
'space-y-6',
sectionRevealClass('storage'),
staggerDelays.storage
)}
>
<header className="space-y-1">
<p className="text-xs font-semibold text-indigo-500 tracking-[0.4em] uppercase">Section 04</p>
<h3 className={cn('text-xl font-semibold', theme === 'dark' ? 'text-white' : 'text-slate-900')}>对象存储配置</h3>
<p className={cn('text-sm', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>用于托管上传素材与 LOGO,可对接 S3 / MinIO。</p>
</header>
<div className="grid gap-4 lg:grid-cols-2">
<div className="space-y-2">
<label className={cn('text-sm font-medium', theme === 'dark' ? 'text-slate-200' : 'text-slate-700')}>S3 端点</label>
<input
type="text"
value={s3Endpoint}
onChange={(e) => setS3Endpoint(e.target.value)}
placeholder="s3.example.com"
className={cn(styles.inputStyles.base, 'text-sm')}
/>
</div>
<div className="space-y-2">
<label className={cn('text-sm font-medium', theme === 'dark' ? 'text-slate-200' : 'text-slate-700')}>公网访问 URL</label>
<input
type="text"
value={s3PublicUrl}
onChange={(e) => setS3PublicUrl(e.target.value)}
placeholder="https://cdn.example.com"
className={cn(styles.inputStyles.base, 'text-sm')}
/>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<div className="space-y-2">
<label className={cn('text-sm font-medium', theme === 'dark' ? 'text-slate-200' : 'text-slate-700')}>Access Key</label>
<input
type="text"
value={s3AccessKey}
onChange={(e) => setS3AccessKey(e.target.value)}
placeholder="访问密钥"
className={cn(styles.inputStyles.base, 'text-sm font-mono')}
/>
</div>
<div className="space-y-2">
<label className={cn('text-sm font-medium', theme === 'dark' ? 'text-slate-200' : 'text-slate-700')}>Secret Key</label>
<input
type="password"
value={s3SecretKey}
onChange={(e) => setS3SecretKey(e.target.value)}
placeholder="安全密钥"
className={cn(styles.inputStyles.base, 'text-sm font-mono')}
/>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<div className="space-y-2">
<label className={cn('text-sm font-medium', theme === 'dark' ? 'text-slate-200' : 'text-slate-700')}>存储桶名称</label>
<input
type="text"
value={s3Bucket}
onChange={(e) => setS3Bucket(e.target.value)}
placeholder="uploads-bucket"
className={cn(styles.inputStyles.base, 'text-sm')}
/>
</div>
<div className="space-y-2">
<label className={cn('text-sm font-medium', theme === 'dark' ? 'text-slate-200' : 'text-slate-700')}>区域 (Region)</label>
<input
type="text"
value={s3Region}
onChange={(e) => setS3Region(e.target.value)}
placeholder="us-east-1"
className={cn(styles.inputStyles.base, 'text-sm')}
/>
</div>
</div>
<div className={cn(
'rounded-2xl border p-4',
theme === 'dark'
? 'border-indigo-900/60 bg-indigo-900/20'
: 'border-indigo-100 bg-indigo-50/80'
)}>
<p className={cn('text-sm font-semibold', theme === 'dark' ? 'text-indigo-300' : 'text-indigo-700')}>配置提示</p>
<ul className={cn('text-xs list-disc list-inside space-y-1 mt-2', theme === 'dark' ? 'text-indigo-200/90' : 'text-indigo-600/90')}>
<li>S3 端点通常为对象存储服务器地址</li>
<li>Access/Secret Key 需保证与存储桶权限一致</li>
<li>如使用 CDN,请将加速域名填入公网 URL</li>
<li>修改后建议重启后台服务确保配置加载</li>
</ul>
</div>
</div>
<div className={cn(styles.cardStyles.base, 'sticky bottom-4 backdrop-blur px-6 py-4 flex flex-wrap items-center gap-3 justify-end')}>
{toast && (
<span
className={`text-sm font-medium px-3 py-1 rounded-full ${
toast.type === 'success'
? 'bg-emerald-50 text-emerald-600 border border-emerald-200'
: 'bg-rose-50 text-rose-600 border border-rose-200'
}`}
>
{toast.message}
</span>
)}
<button
type="button"
onClick={fetchSettings}
disabled={saving}
className={cn(styles.buttonStyles.secondary, 'px-5 py-2.5 text-sm')}
>
重新拉取
</button>
<button
type="button"
onClick={handleSave}
disabled={saving}
className={cn(
'px-5 py-2.5 rounded-xl bg-gradient-to-r from-indigo-500 to-purple-500 text-white text-sm font-semibold shadow-lg shadow-indigo-500/40 hover:shadow-xl transition-all duration-200',
'disabled:opacity-60 disabled:cursor-not-allowed'
)}
>
{saving ? '保存中...' : '保存全部修改'}
</button>
</div>
</div>
</div>
</div>
)
}