'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, scrollToElementWithSpring } 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('FLNXI素材库') 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) // 首页删除按钮:true = 仅管理员可见,false = 所有人可见 const [homeDeleteButtonAdminOnly, setHomeDeleteButtonAdminOnly] = useState(true) const [premiumRatio, setPremiumRatio] = useState(10) const [toast, setToast] = useState(null) const [lastSavedAt, setLastSavedAt] = useState(null) const [heroGlowPos, setHeroGlowPos] = useState({ x: 50, y: 50 }) const [activeNav, setActiveNav] = useState('brand') const [visibleSections, setVisibleSections] = useState>({ brand: true, logos: true, toggles: true, storage: true, }) const [isDarkMode, setIsDarkMode] = useState(false) const brandRef = useRef(null) const logoRef = useRef(null) const toggleRef = useRef(null) const storageRef = useRef(null) const navCardRef = useRef(null) const [navCardHeight, setNavCardHeight] = useState(0) const sectionRefs: Record<(typeof sectionConfig)[number]['key'], RefObject> = useMemo( () => ({ brand: brandRef, logos: logoRef, toggles: toggleRef, storage: storageRef, }), [] ) const showToast = useCallback((next: ToastState) => { setToast(next) setTimeout(() => setToast(null), 3600) }, []) 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() }, []) // 动态计算导航卡片高度,用于设置状态卡片位置 useEffect(() => { const updateNavCardHeight = () => { if (navCardRef.current) { // 使用 getBoundingClientRect 获取更准确的高度 const rect = navCardRef.current.getBoundingClientRect() const height = rect.height setNavCardHeight(height) document.documentElement.style.setProperty('--nav-card-height', `${height}px`) } } // 初始计算 updateNavCardHeight() // 延迟再次计算,确保 DOM 完全渲染 const timeoutId = setTimeout(updateNavCardHeight, 100) const resizeObserver = new ResizeObserver(updateNavCardHeight) if (navCardRef.current) { resizeObserver.observe(navCardRef.current) } window.addEventListener('resize', updateNavCardHeight) return () => { clearTimeout(timeoutId) resizeObserver.disconnect() window.removeEventListener('resize', updateNavCardHeight) } }, []) const fetchSettings = useCallback(async () => { setLoading(true) try { const response = await fetch('/api/system-settings') if (response.ok) { const data = await response.json() setHeaderBgColor(data.headerBgColor || '#1f2937') setSiteName(data.siteName || 'FLNXI素材库') setHeaderLogoUrl(data.headerLogoUrl || '') 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) setHomeDeleteButtonAdminOnly((data.homeDeleteButtonMode ?? 'ADMIN_ONLY') !== 'ALL') setPremiumRatio(typeof data.premiumRatio === 'number' ? data.premiumRatio : 10) setLastSavedAt(data.updatedAt ? new Date(data.updatedAt) : null) } } catch (error) { console.error('Error fetching settings:', error) showToast({ type: 'error', message: '加载设置失败,请稍后再试' }) } finally { setLoading(false) } }, [showToast]) useEffect(() => { if (sessionUser?.role === 'ADMIN') { fetchSettings() } }, [sessionUser, fetchSettings]) const handleSave = async () => { setSaving(true) try { const numericRatio = Number(premiumRatio) if (!Number.isFinite(numericRatio)) { showToast({ type: 'error', message: '精品比例必须为数字' }) setSaving(false) return } const normalizedRatio = Math.min(100, Math.max(0, Number(numericRatio.toFixed(2)))) const response = await fetch('/api/system-settings', { method: 'PUT', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ headerBgColor, siteName, headerLogoUrl, loginLogoUrl, s3Endpoint, s3AccessKey, s3SecretKey, s3Bucket, s3Region, s3PublicUrl, enableUserActivation, enableRegistration, enableAiImageEdit, enableTunnelService, homeDeleteButtonMode: homeDeleteButtonAdminOnly ? 'ADMIN_ONLY' : 'ALL', premiumRatio: normalizedRatio, }), }) if (response.ok) { showToast({ type: 'success', message: '保存成功,配置已更新' }) setLastSavedAt(new Date()) } else { const error = await response.json() throw new Error(error?.error || '保存失败') } } catch (error) { console.error('Save settings error:', error) showToast({ type: 'error', message: '保存失败,请稍后重试' }) } finally { setSaving(false) } } const handleUpload = async (file: File, type: 'header' | 'login') => { if (!file.type.startsWith('image/')) { showToast({ type: 'error', message: '请选择图片文件' }) return } if (file.size > 5 * 1024 * 1024) { showToast({ type: 'error', message: 'LOGO 文件不能超过 5MB' }) return } type === 'header' ? setUploadingHeader(true) : setUploadingLogin(true) try { const formData = new FormData() formData.append('logo', file) formData.append('type', type) const response = await fetch('/api/system-settings/upload-logo', { method: 'POST', body: formData, }) if (!response.ok) { const error = await response.json() throw new Error(error?.error || '上传失败') } const data = await response.json() if (type === 'header') { setHeaderLogoUrl(data.logoUrl) setHeaderLogoPreview(data.logoUrl) } else { setLoginLogoUrl(data.logoUrl) setLoginLogoPreview(data.logoUrl) } showToast({ type: 'success', message: 'LOGO 上传成功' }) } catch (error) { console.error('Upload logo error:', error) showToast({ type: 'error', message: '上传失败,请稍后重试' }) } finally { type === 'header' ? setUploadingHeader(false) : setUploadingLogin(false) } } const handleColorReset = () => setHeaderBgColor('#1f2937') const handleHeaderLogoReset = () => { 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] if (!ref?.current) return // 使用苹果风格的缓入缓出滚动(页面级滚动,和分类管理保持一致) scrollToElementWithSpring(ref.current, null, { offset: 96, // 考虑 sticky top-24 (6rem) 的高度,和左侧小卡片上面对齐 duration: 600, easing: 'ease-in-out', }) setActiveNav(key) } const [isMouseOver, setIsMouseOver] = useState(false) const handleHeroMouseMove = (event: React.MouseEvent) => { 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 = ( <> {/* 精致光球 - 右上角 - 使用相近的蓝色系 */}
{/* 精致光球 - 左下角 - 使用相近的紫色系 */}
) const staggerDelays: Record = { brand: 'delay-[120ms]', logos: 'delay-[220ms]', toggles: 'delay-[320ms]', storage: 'delay-[420ms]', } if (!sessionUser || sessionUser.role !== 'ADMIN') { return (
当前账号无权查看外观设置。
) } if (loading) { return (

正在加载外观设置...

) } return (
{floatingOrbs}

Appearance Studio

外观设置工作台

沿用模板管理的分区式结构,这里集中处理品牌主题、LOGO、功能开关与对象存储。每次保存都会即时同步到后台界面与素材上传入口。

{lastSavedAt && (

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

)}
{variant === 'standalone' && ( )}
0 ? `calc(6rem + ${navCardHeight}px + 1.5rem)` : 'calc(6rem + 1.5rem)' }} >

状态

当前主题色

{siteName}

{headerBgColor}

导航示例

Section 01

品牌与主题

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

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

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

setHeaderBgColor(e.target.value)} className={cn(styles.inputStyles.base, 'flex-1 text-sm font-mono')} />
{['#1f2937', '#4f46e5', '#1e40af', '#7c3aed', '#0891b2', '#059669', '#dc2626', '#d97706'].map((color) => (

Section 02

LOGO 管理

为深色导航与浅色登录界面分别上传 LOGO。

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

{item.title}

{item.tip}

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

Section 03

功能开关

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

{[ { title: '用户激活功能', desc: '开启后新注册用户需管理员激活才能登录。', value: enableUserActivation, onChange: setEnableUserActivation, disabled: !sessionUser?.isSuperAdmin, }, { title: '用户手动注册', desc: '关闭后仅能通过邀请进入系统。', value: enableRegistration, onChange: setEnableRegistration, disabled: false, }, { title: '首页删除按钮仅管理员可见', desc: '开启后首页素材卡片的删除按钮仅管理员可见;关闭后所有用户都能看到(实际删除权限仍受角色控制)。', value: homeDeleteButtonAdminOnly, onChange: setHomeDeleteButtonAdminOnly, disabled: false, }, { title: 'AI 改图能力(预留)', desc: '控制素材详情页的 AI 改图入口(功能预留)。', value: enableAiImageEdit, onChange: setEnableAiImageEdit, disabled: !sessionUser?.isSuperAdmin, }, { title: '付费内网穿透', desc: '接入第三方服务以支持公网访问(功能预留)。', value: enableTunnelService, onChange: setEnableTunnelService, disabled: !sessionUser?.isSuperAdmin, }, ].map((item) => (

{item.title}

{item.desc}

{item.disabled && (

仅超级管理员可修改

)}
))}

精品作品比例

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

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

{!sessionUser?.isSuperAdmin && (

仅超级管理员可修改

)}

Section 04

对象存储配置

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

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

配置提示

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