flnxi-Wu/app/admin/_components/AppearanceSettingsPanel.tsx
WuYongJie 542c7bcc49 feat: 优化自定义上传模板系统
- 修复内置模板验证问题(builtin-video-material等)
- 添加模板验证工具 lib/template-validator.ts
- 创建模板系统优化需求文档(30个详细需求)
- 优化上传API的模板验证逻辑
- 改进错误提示信息的清晰度
- 添加字段池和分类管理相关需求
- 完善自定义字段的创建、验证、存储逻辑
- 清理过期的 .specstory 和 .trae 文件
2025-12-13 14:26:43 +08:00

942 lines
42 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, 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']
// 默认 LOGO 路径:与后端 /lib/config.ts 中的 defaultLogos 保持一致
const DEFAULT_HEADER_LOGO =
process.env.NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL || '/logo/logo1.png'
const DEFAULT_LOGIN_LOGO =
process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '/logo/logo2.png'
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(DEFAULT_HEADER_LOGO)
const [headerLogoPreview, setHeaderLogoPreview] = useState(DEFAULT_HEADER_LOGO)
const [loginLogoUrl, setLoginLogoUrl] = useState(DEFAULT_LOGIN_LOGO)
const [loginLogoPreview, setLoginLogoPreview] = useState(DEFAULT_LOGIN_LOGO)
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<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 navCardRef = useRef<HTMLDivElement | null>(null)
const [navCardHeight, setNavCardHeight] = useState(0)
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()
}, [])
// 动态计算导航卡片高度,用于设置状态卡片位置
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 || DEFAULT_HEADER_LOGO)
setHeaderLogoPreview(data.headerLogoUrl || DEFAULT_HEADER_LOGO)
setLoginLogoUrl(data.loginLogoUrl || DEFAULT_LOGIN_LOGO)
setLoginLogoPreview(data.loginLogoUrl || DEFAULT_LOGIN_LOGO)
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 = () => {
setHeaderLogoPreview(DEFAULT_HEADER_LOGO)
setHeaderLogoUrl(DEFAULT_HEADER_LOGO)
}
const handleLoginLogoReset = () => {
setLoginLogoPreview(DEFAULT_LOGIN_LOGO)
setLoginLogoUrl(DEFAULT_LOGIN_LOGO)
}
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<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
type="button"
onClick={fetchSettings}
disabled={saving}
className={cn(
'inline-flex items-center gap-2 rounded-xl border px-5 py-3 text-sm font-medium transition-all',
theme === 'dark'
? 'border-white/20 bg-white/5 text-white/80 hover:bg-white/10 hover:bg-white/20 disabled:opacity-60'
: 'border-slate-300/50 bg-slate-50/80 text-slate-700 hover:bg-slate-100/80 disabled:opacity-60'
)}
>
重新拉取
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} 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>
</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="flex flex-col gap-6">
<div
id="nav-card"
ref={navCardRef}
className={cn(styles.cardStyles.base, 'p-5 backdrop-blur-sm flex flex-col sticky top-24 max-h-[calc(100vh-8rem)]')}>
<p className={cn('text-xs font-semibold tracking-[0.3em] uppercase mb-4 flex-shrink-0', theme === 'dark' ? 'text-slate-200' : 'text-slate-500')}>
导航
</p>
<div className="space-y-3 flex-1 min-h-0 overflow-y-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
{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
id="status-card"
className={cn(
styles.cardStyles.base,
'p-5 space-y-4 backdrop-blur-sm flex flex-col',
'sticky',
theme === 'dark'
? 'bg-gradient-to-br from-slate-900 to-slate-800'
: 'bg-gradient-to-br from-white to-slate-50'
)}
style={{
top: navCardHeight > 0 ? `calc(6rem + ${navCardHeight}px + 1.5rem)` : 'calc(6rem + 1.5rem)'
}}
>
<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="FLNXI素材库"
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-slate-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 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: '首页删除按钮仅管理员可见',
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) => (
<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
className={cn(
'rounded-2xl border px-4 py-2.5 flex items-center justify-between gap-4',
theme === 'dark' ? 'border-slate-700/80 bg-slate-800/60' : 'border-slate-200/80 bg-slate-50/80'
)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2 mb-0.5">
<p className={cn('text-sm font-semibold', theme === 'dark' ? 'text-white' : 'text-slate-900')}>
精品作品比例
</p>
<div className="flex items-center gap-2 flex-shrink-0">
<input
type="number"
min={0}
max={100}
step={0.5}
value={premiumRatio}
onChange={(event) => {
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'
)}
/>
<span className={cn('text-sm font-medium', theme === 'dark' ? 'text-white' : 'text-slate-900')}>%</span>
</div>
</div>
<p className={cn('text-[11px] leading-tight', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>
系统会根据素材总量 × 该比例取整作为允许加精的数量,默认 10%。举例:总素材 320 条且比例 10%,则加精上限为 ⌊320 × 10%⌋ = 32 条
</p>
{!sessionUser?.isSuperAdmin && (
<p className="text-[10px] text-amber-500/90 mt-0.5">仅超级管理员可修改</p>
)}
</div>
</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>
</div>
</div>
)
}