chenpei d17f6df2c7 feat: 部门管理和文件上传优化
新功能:
1. 部门管理功能
   - 管理员可在系统设置中添加/删除部门
   - 用户注册时从下拉框选择部门
   - 新增Department数据库表和API

2. 文件上传优化
   - 支持直接上传源文件(.max/.psd/.ai/.cad/.dwg/.c4d)
   - 自动识别文件类型并设置素材类型
   - 压缩包需手动选择类型
   - 优化拖拽上传体验

数据库变更:
- 新增departments表
- User表新增department字段
2025-10-26 14:06:19 +08:00

556 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

'use client'
import { useEffect, useState } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import Header from '@/components/Header'
export default function SystemSettings() {
const { data: session, status } = useSession()
const router = useRouter()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [uploading, setUploading] = useState(false)
// 设置状态
const [headerBgColor, setHeaderBgColor] = useState('#1f2937')
const [siteName, setSiteName] = useState('由壹点3D素材')
const [logoUrl, setLogoUrl] = useState('')
const [logoPreview, setLogoPreview] = useState('')
// 部门管理状态
const [departments, setDepartments] = useState<Array<{ id: string; name: string }>>([])
const [newDepartmentName, setNewDepartmentName] = useState('')
const [addingDepartment, setAddingDepartment] = useState(false)
// 权限检查
useEffect(() => {
if (status === 'unauthenticated') {
router.push('/login')
} else if (status === 'authenticated' && session?.user?.role !== 'ADMIN') {
router.push('/')
}
}, [status, session, router])
// 加载设置
useEffect(() => {
if (status === 'authenticated' && session?.user?.role === 'ADMIN') {
fetchSettings()
fetchDepartments()
}
}, [status, session])
const fetchSettings = async () => {
try {
const response = await fetch('/api/system-settings')
if (response.ok) {
const data = await response.json()
setHeaderBgColor(data.headerBgColor || '#1f2937')
setSiteName(data.siteName || '由壹点3D素材')
setLogoUrl(data.logoUrl || '')
setLogoPreview(data.logoUrl || '')
}
} catch (error) {
console.error('Error fetching settings:', error)
alert('加载设置失败')
} finally {
setLoading(false)
}
}
const fetchDepartments = async () => {
try {
const response = await fetch('/api/departments')
if (response.ok) {
const data = await response.json()
setDepartments(data)
}
} catch (error) {
console.error('Error fetching departments:', error)
}
}
const handleAddDepartment = async () => {
if (!newDepartmentName.trim()) {
alert('请输入部门名称')
return
}
setAddingDepartment(true)
try {
const response = await fetch('/api/departments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: newDepartmentName.trim() }),
})
if (response.ok) {
await fetchDepartments()
setNewDepartmentName('')
alert('部门添加成功')
} else {
const error = await response.json()
alert(error.error || '添加失败')
}
} catch (error) {
console.error('Add department error:', error)
alert('添加失败,请稍后重试')
} finally {
setAddingDepartment(false)
}
}
const handleDeleteDepartment = async (id: string, name: string) => {
if (!confirm(`确定要删除部门"${name}"吗?`)) {
return
}
try {
const response = await fetch(`/api/departments?id=${id}`, {
method: 'DELETE',
})
if (response.ok) {
await fetchDepartments()
alert('部门删除成功')
} else {
const error = await response.json()
alert(error.error || '删除失败')
}
} catch (error) {
console.error('Delete department error:', error)
alert('删除失败,请稍后重试')
}
}
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,
}),
})
if (response.ok) {
alert('保存成功!页面将自动刷新以应用新设置')
// 刷新页面以应用新设置
window.location.reload()
} else {
const error = await response.json()
alert(error.error || '保存失败')
}
} catch (error) {
console.error('Save settings error:', error)
alert('保存失败,请稍后重试')
} finally {
setSaving(false)
}
}
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
// 验证文件类型
if (!file.type.startsWith('image/')) {
alert('请选择图片文件')
return
}
// 验证文件大小(最大5MB)
if (file.size > 5 * 1024 * 1024) {
alert('LOGO文件大小不能超过5MB')
return
}
setUploading(true)
try {
const formData = new FormData()
formData.append('logo', file)
const response = await fetch('/api/system-settings/upload-logo', {
method: 'POST',
body: formData,
})
if (response.ok) {
const data = await response.json()
setLogoUrl(data.logoUrl)
setLogoPreview(data.logoUrl)
alert('LOGO上传成功!请点击"保存设置"以应用更改')
} else {
const error = await response.json()
alert(error.error || 'LOGO上传失败')
}
} catch (error) {
console.error('Upload logo error:', error)
alert('上传失败,请稍后重试')
} finally {
setUploading(false)
}
}
const handleColorReset = () => {
setHeaderBgColor('#1f2937')
}
const handleLogoReset = () => {
setLogoPreview('http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png')
}
if (status === 'loading' || loading) {
return (
<div className="min-h-screen flex items-center justify-center dark:bg-gray-900">
<div className="text-xl text-gray-600 dark:text-gray-400">加载中...</div>
</div>
)
}
if (!session || session.user.role !== 'ADMIN') {
return null
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header session={session} />
<main className="container mx-auto px-4 py-8 max-w-4xl">
<div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">系统设置</h1>
<p className="text-gray-600 dark:text-gray-400">自定义网站外观和品牌信息</p>
</div>
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 space-y-8">
{/* 网站名称设置 */}
<div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
<svg className="w-6 h-6 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
网站名称
</h2>
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
站点名称
</label>
<input
type="text"
value={siteName}
onChange={(e) => setSiteName(e.target.value)}
placeholder="由壹点3D素材"
className="w-full px-4 py-3 rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-600 outline-none transition-all"
/>
<p className="text-sm text-gray-500 dark:text-gray-400">
该名称将显示在顶部导航栏和登录页面
</p>
</div>
</div>
{/* LOGO设置 */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-8">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
<svg className="w-6 h-6 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
网站LOGO
</h2>
<div className="space-y-4">
{/* LOGO预览 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
当前LOGO预览
</label>
<div className="flex items-center gap-4">
<div className="w-32 h-32 rounded-lg border-2 border-gray-300 dark:border-gray-600 flex items-center justify-center overflow-hidden bg-gray-50 dark:bg-gray-700">
{logoPreview ? (
<img src={logoPreview} alt="LOGO" className="max-w-full max-h-full object-contain" />
) : (
<svg className="w-16 h-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
)}
</div>
<div className="flex-1">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
建议尺寸:200x200px,支持PNG、JPG、SVG格式
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
文件大小不超过5MB
</p>
</div>
</div>
</div>
{/* 上传按钮 */}
<div className="flex gap-3">
<label className="btn-primary cursor-pointer inline-flex items-center gap-2 disabled:opacity-50">
{uploading ? (
<>
<svg className="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
上传中...
</>
) : (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
上传LOGO
</>
)}
<input
type="file"
accept="image/*"
onChange={handleLogoUpload}
disabled={uploading}
className="hidden"
/>
</label>
<button
onClick={handleLogoReset}
className="btn-secondary"
disabled={uploading}
>
恢复默认
</button>
</div>
</div>
</div>
{/* 顶部导航栏颜色设置 */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-8">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
<svg className="w-6 h-6 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
顶部导航栏颜色
</h2>
<div className="space-y-4">
{/* 颜色预览 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
颜色预览
</label>
<div
className="w-full h-16 rounded-lg border-2 border-gray-300 dark:border-gray-600 shadow-inner"
style={{ backgroundColor: headerBgColor }}
>
<div className="flex items-center h-full px-4">
<span className="text-white font-medium" style={{ textShadow: '0 1px 2px rgba(0,0,0,0.3)' }}>
顶部导航栏预览
</span>
</div>
</div>
</div>
{/* 颜色选择器 */}
<div className="flex items-center gap-4">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
选择颜色
</label>
<div className="flex items-center gap-2">
<input
type="color"
value={headerBgColor}
onChange={(e) => setHeaderBgColor(e.target.value)}
className="w-16 h-12 rounded cursor-pointer border border-gray-300 dark:border-gray-600"
/>
<input
type="text"
value={headerBgColor}
onChange={(e) => setHeaderBgColor(e.target.value)}
placeholder="#1f2937"
className="flex-1 px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-600 outline-none transition-all font-mono"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 opacity-0">
操作
</label>
<button
onClick={handleColorReset}
className="btn-secondary whitespace-nowrap"
>
恢复默认
</button>
</div>
</div>
{/* 预设颜色 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
快速选择
</label>
<div className="flex flex-wrap gap-2">
{[
{ name: '默认灰', color: '#1f2937' },
{ name: '深蓝', color: '#1e3a8a' },
{ name: '靛蓝', color: '#4f46e5' },
{ name: '紫色', color: '#7c3aed' },
{ name: '粉色', color: '#db2777' },
{ name: '红色', color: '#dc2626' },
{ name: '橙色', color: '#ea580c' },
{ name: '绿色', color: '#059669' },
{ name: '青色', color: '#0891b2' },
{ name: '深灰', color: '#374151' },
].map((preset) => (
<button
key={preset.color}
onClick={() => setHeaderBgColor(preset.color)}
className={`px-4 py-2 rounded-lg border-2 transition-all ${
headerBgColor === preset.color
? 'border-blue-500 ring-2 ring-blue-200'
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400'
}`}
style={{ backgroundColor: preset.color }}
title={preset.color}
>
<span className="text-white text-sm font-medium" style={{ textShadow: '0 1px 2px rgba(0,0,0,0.5)' }}>
{preset.name}
</span>
</button>
))}
</div>
</div>
</div>
</div>
{/* 部门管理 */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-8">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
<svg className="w-6 h-6 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
部门管理
</h2>
<div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
管理系统中的部门列表,用户注册时可以选择所属部门
</p>
{/* 添加部门 */}
<div className="flex gap-2">
<input
type="text"
value={newDepartmentName}
onChange={(e) => setNewDepartmentName(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleAddDepartment()
}
}}
placeholder="输入部门名称(例如:设计部、技术部等)"
className="flex-1 px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-600 outline-none transition-all"
disabled={addingDepartment}
/>
<button
onClick={handleAddDepartment}
disabled={addingDepartment}
className="btn-primary whitespace-nowrap disabled:opacity-50"
>
{addingDepartment ? '添加中...' : '添加部门'}
</button>
</div>
{/* 部门列表 */}
<div className="mt-4">
{departments.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
暂无部门,请添加第一个部门
</div>
) : (
<div className="space-y-2">
{departments.map((dept) => (
<div
key={dept.id}
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600"
>
<span className="text-gray-900 dark:text-white font-medium">
{dept.name}
</span>
<button
onClick={() => handleDeleteDepartment(dept.id, dept.name)}
className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 transition-colors"
title="删除部门"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
))}
</div>
)}
</div>
</div>
</div>
{/* 保存按钮 */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-6 flex justify-between items-center">
<button
onClick={() => router.push('/admin')}
className="btn-secondary"
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
返回
</button>
<button
onClick={handleSave}
disabled={saving}
className="btn-primary text-lg px-8 py-3 disabled:opacity-50"
>
{saving ? (
<>
<svg className="w-5 h-5 mr-2 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
保存中...
</>
) : (
<>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
保存设置
</>
)}
</button>
</div>
{/* 提示信息 */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div className="text-sm text-blue-800 dark:text-blue-300">
<p className="font-medium mb-1">温馨提示:</p>
<ul className="list-disc list-inside space-y-1">
<li>修改设置后点击&ldquo;保存设置&rdquo;按钮,页面会自动刷新以应用新的设置</li>
<li>LOGO会自动上传到MinIO S3存储桶,确保全站可访问</li>
<li>建议使用高质量的LOGO图片以获得最佳显示效果</li>
<li>顶部导航栏颜色建议选择深色系,以确保白色文字清晰可见</li>
</ul>
</div>
</div>
</div>
</div>
</main>
</div>
)
}