flnxi-Wu/app/admin/_components/AppearanceSettingsPanel.tsx

894 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 { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react'
import Image from 'next/image'
import { useThemeStyles, useTheme, cn } from '@/lib/ui-styles'
import type { SessionUser } from './types'
import RestrictedToggle from '@/components/ui/RestrictedToggle'
import { useLicenseStatus } from '@/hooks/useLicenseStatus'
import StorageEditConfirmModal from '@/components/ui/StorageEditConfirmModal'
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: '标识管理', hint: '导航栏、登录页标识' },
{ 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 || ''
const DEFAULT_LOGIN_LOGO =
process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || ''
export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedded' }: AppearanceSettingsPanelProps) {
const styles = useThemeStyles()
const theme = useTheme()
const { isFree, isFeatureEnabled } = useLicenseStatus()
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)
const [enablePublicShare, setEnablePublicShare] = useState(true)
// 首页删除按钮: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 [activeNav, setActiveNav] = useState<SectionKey>('brand')
// 对象存储配置编辑状态
const [storageEditEnabled, setStorageEditEnabled] = useState(false)
const [showStorageConfirmModal, setShowStorageConfirmModal] = 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)
}, [])
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)
setEnablePublicShare(data.enablePublicShare ?? true)
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,
enablePublicShare,
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
ref.current.scrollIntoView({
behavior: 'smooth',
block: 'start',
})
setActiveNav(key)
}
const navButtonClass = (key: SectionKey) =>
cn(
'w-full text-left group',
activeNav === key ? styles.navItemStyles.active : styles.navItemStyles.hover
)
// 更精致的光晕效果 - 使用更相近的颜色,减少数量
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(
'rounded-2xl border p-5',
theme === 'dark'
? 'border-slate-700 bg-slate-900 text-white'
: 'border-slate-200 bg-white text-slate-900',
)}
>
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div className="space-y-3 max-w-2xl">
<p className={cn('text-xs font-medium text-slate-500', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>
外观
</p>
<h2 className={cn('text-xl font-semibold', theme === 'dark' ? 'text-white' : 'text-slate-900')}>品牌、标识与系统开关</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(
styles.buttonStyles.secondary,
styles.buttonModifiers.compact,
'inline-flex items-center gap-2 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(
styles.buttonStyles.primary,
styles.buttonModifiers.compact,
'inline-flex items-center gap-2 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 xl:sticky xl:top-24 xl:self-start xl:max-h-[calc(100vh-6rem)] xl:overflow-y-auto xl:[&::-webkit-scrollbar]:hidden xl:[-ms-overflow-style:none] xl:[scrollbar-width:none]">
<div
id="nav-card"
className={cn(styles.cardStyles.base, 'p-5 flex flex-col')}>
<p className={cn('text-xs font-medium text-slate-500 mb-4 flex-shrink-0', theme === 'dark' ? 'text-slate-400' : '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
id="status-card"
className={cn(
styles.cardStyles.base,
'p-5 space-y-4 flex flex-col'
)}
>
<div>
<p className="text-xs font-medium text-slate-500">状态概览</p>
<p className={cn('text-base font-semibold mt-1', theme === 'dark' ? 'text-white' : 'text-slate-900')}>当前主题色</p>
</div>
<div className={cn(
'w-full h-24 rounded-xl border shadow-inner',
theme === 'dark' ? 'border-slate-700' : 'border-slate-200'
)} 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 font-mono">{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',
)}
>
<header className="space-y-1">
<p className="text-xs font-medium text-slate-500">品牌</p>
<h3 className={cn('text-lg 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-xl 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-lg border ${headerBgColor === color ? 'border-white/80 ring-2 ring-slate-300 shadow-sm' : 'border-white/20'} transition-colors`}
style={{ backgroundColor: color }}
/>
))}
</div>
</div>
</div>
</div>
<div
ref={logoRef}
data-section="logos"
className={cn(
styles.cardStyles.large,
'space-y-6',
)}
>
<header className="space-y-1">
<p className="text-xs font-medium text-slate-500">标识</p>
<h3 className={cn('text-lg 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">
{[
{
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',
)}
>
<header className="space-y-1">
<p className="text-xs font-medium text-slate-500">开关</p>
<h3 className={cn('text-lg 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: '公开分享链接',
desc: '关闭后不能新建外部分享链接,已有分享链接也会停止公开访问。',
value: enablePublicShare,
onChange: setEnablePublicShare,
disabled: !sessionUser?.isSuperAdmin,
},
{
title: '图片编辑能力(预留)',
desc: '控制素材详情页的图片编辑入口(功能预留)。',
value: enableAiImageEdit,
onChange: setEnableAiImageEdit,
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',
item.disabled && 'opacity-60'
)}>
<div className={cn(item.disabled && 'opacity-80')}>
<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' : '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',
item.disabled
? 'peer-checked:bg-slate-400 dark:peer-checked:bg-slate-600 bg-slate-300 dark:bg-slate-700'
: 'peer-checked:bg-indigo-600',
theme === 'dark'
? !item.disabled && 'bg-slate-700 peer-focus:ring-indigo-800'
: !item.disabled && '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>
{/* 付费功能开关 - 使用 RestrictedToggle */}
<div className={cn(
'rounded-2xl border px-4',
theme === 'dark' ? 'border-slate-700' : 'border-slate-200'
)}>
<RestrictedToggle
label="付费内网穿透"
featureKey="intranetPenetration"
value={enableTunnelService}
onChange={setEnableTunnelService}
description="接入第三方服务以支持公网访问"
isRestricted={isFree || !isFeatureEnabled('intranetPenetration')}
/>
</div>
</div>
</div>
<div
ref={storageRef}
data-section="storage"
className={cn(
styles.cardStyles.large,
'space-y-6',
)}
>
<header className="flex items-start justify-between">
<div className="space-y-1">
<p className="text-xs font-medium text-slate-500">存储</p>
<h3 className={cn('text-lg 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>
</div>
{/* 修改配置按钮 */}
{sessionUser?.isSuperAdmin && !storageEditEnabled && (
<button
type="button"
onClick={() => setShowStorageConfirmModal(true)}
className={cn(
'px-4 py-2 rounded-xl text-sm font-medium transition-all',
'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400',
'hover:bg-amber-200 dark:hover:bg-amber-900/50',
'border border-amber-200 dark:border-amber-800'
)}
>
修改配置
</button>
)}
{storageEditEnabled && (
<button
type="button"
onClick={() => setStorageEditEnabled(false)}
className={cn(
'px-4 py-2 rounded-xl text-sm font-medium transition-all',
'bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300',
'hover:bg-slate-200 dark:hover:bg-slate-600'
)}
>
锁定配置
</button>
)}
</header>
{/* 锁定状态提示 */}
{!storageEditEnabled && (
<div className={cn(
'rounded-2xl border p-4',
theme === 'dark'
? 'border-amber-900/60 bg-amber-900/20'
: 'border-amber-100 bg-amber-50/80'
)}>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-amber-100 dark:bg-amber-900/50 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<div>
<p className={cn('text-sm font-semibold', theme === 'dark' ? 'text-amber-300' : 'text-amber-700')}>配置已锁定</p>
<p className={cn('text-xs mt-0.5', theme === 'dark' ? 'text-amber-200/80' : 'text-amber-600/80')}>
对象存储是系统核心配置,修改需要超级管理员密码验证
</p>
</div>
</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')}>S3 端点</label>
<input
type="text"
value={s3Endpoint}
onChange={(e) => setS3Endpoint(e.target.value)}
placeholder="s3.example.com"
disabled={!storageEditEnabled}
className={cn(styles.inputStyles.base, 'text-sm', !storageEditEnabled && 'opacity-60 cursor-not-allowed')}
/>
</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"
disabled={!storageEditEnabled}
className={cn(styles.inputStyles.base, 'text-sm', !storageEditEnabled && 'opacity-60 cursor-not-allowed')}
/>
</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="访问密钥"
disabled={!storageEditEnabled}
className={cn(styles.inputStyles.base, 'text-sm font-mono', !storageEditEnabled && 'opacity-60 cursor-not-allowed')}
/>
</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="安全密钥"
disabled={!storageEditEnabled}
className={cn(styles.inputStyles.base, 'text-sm font-mono', !storageEditEnabled && 'opacity-60 cursor-not-allowed')}
/>
</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"
disabled={!storageEditEnabled}
className={cn(styles.inputStyles.base, 'text-sm', !storageEditEnabled && 'opacity-60 cursor-not-allowed')}
/>
</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"
disabled={!storageEditEnabled}
className={cn(styles.inputStyles.base, 'text-sm', !storageEditEnabled && 'opacity-60 cursor-not-allowed')}
/>
</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>
{/* 对象存储修改确认弹窗 */}
<StorageEditConfirmModal
isOpen={showStorageConfirmModal}
onClose={() => setShowStorageConfirmModal(false)}
onConfirm={() => setStorageEditEnabled(true)}
/>
</div>
)
}