feat: Admin页面重构 & UI三层架构系统
Some checks failed
CI Check / check (push) Has been cancelled

- 创建统一UI三层架构系统(基础层、颜色层、组合层)
- 拆分Admin页面为独立Tab组件(MonitorTab、UsersTab、DepartmentsTab等)
- 提取公共逻辑到Hooks(useToast、useDataFetch、useUserOperations等)
- 提取工具函数(validators、formatters、api等)
- 创建统一弹窗组件(Modal)和useModal Hook
- 添加错误边界和统一状态组件
- 完善类型定义和可访问性支持
- 更新文档(优化记录、优化计划、更新日志)
This commit is contained in:
WuYongJie 2025-11-22 15:17:10 +08:00
parent 66bd011c85
commit e4dcb6260a
68 changed files with 12139 additions and 6097 deletions

0
; Normal file
View File

View File

@ -1,93 +0,0 @@
## 🧾 2025-11-20 · 「拖拽体验优化」版本 🎨
本次更新主要优化了模板管理器的拖拽交互体验,让字段排序操作更加流畅和直观。
---
### 🎯 拖拽交互优化
- ✨ **移除高亮卡片效果**
- 移除了拖拽悬停时的高亮卡片显示,避免与虚位卡片(placeholder)视觉重复。
- 现在只保留虚位卡片来显示落点位置,界面更简洁清晰。
- ⚡ **立即更新布局**
- 松开鼠标后布局立即更新,不再等待动画完成。
- 直接根据拖拽后的顺序更新字段数据,响应更快,体验更流畅。
- 🎨 **动画效果优化**
- 拖拽结束动画时长从 400ms 增加到 500ms,使用弹性缓动函数 `cubic-bezier(0.34, 1.56, 0.64, 1)`。
- 拖拽覆盖层增加旋转和缩放效果,视觉反馈更明显。
- 占位符卡片使用渐变背景和脉冲动画,更清晰地指示落点位置。
---
## 🧾 2025-11-18 · 「权限进化 & 电商加成」版本 (๑•̀ㅂ•́)و✧
这次更新主要围绕 **权限体验、电商字段扩展、模板/分类管理、部署体验** 做了一轮集中升级,让「由壹点 3D 素材平台」更像一款真正能落地到团队里的内部产品,而不是一个玩具 Demo。下面是本次改动的「人话版」说明👇
---
### 🛡 权限与用户管理
- ✨ **部门管理员权限升级**
- 非最高权限账号,只要有「管理的部门」或「自己的部门」,就可以管理 **该部门 + 所有子部门** 的用户。
- 没有部门范围时,就老老实实只能改自己,避免误操作全公司 ( ̄▽ ̄)/
- 🧍‍♀️ **按钮展示更聪明**
- 对当前登录用户 **没有管理权限** 的成员:
- 不再显示一整排「编辑 / 禁用 / 下载权限 / 管理员」按钮。
- 只保留一行「无权限修改」的小提示,界面更干净,权限边界更清晰。
---
### 📦 模板与分类管理
- 🧩 **内置电商模板上线**
- 在模板管理中新增几套常用电商模板(如「通用电商」「简易基础」「渲染维度」等)。
- 字段命名与素材表最新标准对齐,开箱即用,不用每次从 0 造轮子。
- 📂 **分类下拉选项更好配**
- 使用店铺 / 场景类别 / 平台分类,全部支持配置「下拉选项」。
- 规则超简单:**一行一个选项**,不用再写 JSON 数组。
- 管理端统一提供输入框 + 示例,方便以后快速按业务维护分类选项。
---
### 🛍 素材模型与电商字段
- 🏷 **商品 / 品牌维度补齐**
- 为 `Material` 增加:`brandName`(品牌)、`seriesName`(系列)、`skuCode`(SKU 编码)、`materialTexture`(材质纹理)等字段。
- 方便日后按品牌、系列、SKU 做统计和检索,更贴合电商团队日常用语。
- 🎨 **渲染 / 制作维度补充**
- 新增:`renderTime`(渲染耗时)、`imageResolution`(输出分辨率)、`colorMode`(颜色模式)等字段。
- 从上传 → 编辑 → 展示 → 搜索,全链路打通,真正做到「不只是传个压缩包」。
- 🔍 **前台页面同步升级**
- 首页列表、分享页、素材编辑页均已接入以上新字段。
- 搜索支持按这些维度模糊匹配,找素材更像「搜商品」而不是「翻文件夹」。
---
### 🚀 部署与运维体验
- 📜 **`deploy-new-fixed.ps1` 阶段化部署脚本**
- 拆分为:检查阶段 → 构建阶段 → 镜像推送 → 远程部署,并在每一步给出清晰进度条和提示。
- 附带一点小彩蛋文案,部署的时候不那么无聊 (´・ω・`)
- 🔐 **SSH & Registry 流程更稳**
- 强制优先使用 OpenSSH,避免 plink 等工具弹交互对话框导致脚本卡死。
- 自动打 Tag 并推送镜像到私有 Registry,然后在远程服务器上拉取并通过 Docker Compose 滚动更新。
- ❤️ **健康检查 `/api/health`**
- 部署完成后,脚本会主动轮询 `/api/health`,确认服务真正「跑起来」再宣告成功。
- 避免刚部署完就打开页面,结果服务还在启动中的尴尬。
---
### 🎛 其他细节优化
- 🧭 系统管理页默认打开 **「监控仪表板」** 标签,而不是素材管理,让管理者一眼看到系统状态。
- 🎨 调整架构管理 & 用户管理的卡片、阴影、悬浮效果与按钮样式,使整个后台的观感更统一、更接近苹果风格。
> 小结:这一版可以理解为「**权限体验 + 电商字段 + 可迁移部署脚本**」的整合升级版,为后续更多客户侧部署和定制做好了「打基础」工作。✨

View File

@ -0,0 +1,858 @@
'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>
)
}

View File

@ -0,0 +1,885 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useThemeStyles, useTheme, cn } from '@/lib/ui-styles'
import type { Category, Scene, Platform, ToastFn } from './types'
interface CategoriesTabProps {
showToast: ToastFn
onDataChange?: () => void
}
export default function CategoriesTab({ showToast, onDataChange }: CategoriesTabProps) {
const styles = useThemeStyles()
const theme = useTheme()
const [categories, setCategories] = useState<Category[]>([])
const [scenes, setScenes] = useState<Scene[]>([])
const [platforms, setPlatforms] = useState<Platform[]>([])
const [editingCategory, setEditingCategory] = useState<Category | null>(null)
const [editingScene, setEditingScene] = useState<Scene | null>(null)
const [editingPlatform, setEditingPlatform] = useState<Platform | null>(null)
const [showAddCategoryModal, setShowAddCategoryModal] = useState(false)
const [showAddSceneModal, setShowAddSceneModal] = useState(false)
const [showAddPlatformModal, setShowAddPlatformModal] = useState(false)
const [newCategoryName, setNewCategoryName] = useState('')
const [newSceneName, setNewSceneName] = useState('')
const [newPlatformName, setNewPlatformName] = useState('')
// 根据主题选择颜色类名
const textLabel = theme === 'dark' ? 'text-slate-300' : 'text-slate-700'
const textHint = theme === 'dark' ? 'text-slate-400' : 'text-slate-500'
const textTitle = theme === 'dark' ? 'text-white' : 'text-slate-900'
const bgInfo = theme === 'dark' ? 'bg-blue-900/20' : 'bg-blue-50/50'
const borderInfo = theme === 'dark' ? 'border-blue-800' : 'border-blue-200'
const textInfoTitle = theme === 'dark' ? 'text-blue-300' : 'text-blue-700'
const textInfo = theme === 'dark' ? 'text-blue-400' : 'text-blue-600'
const bgCode = theme === 'dark' ? 'bg-slate-800' : 'bg-white'
const borderCode = theme === 'dark' ? 'border-blue-700' : 'border-blue-200'
const fetchData = useCallback(async () => {
try {
const [categoriesRes, scenesRes, platformsRes] = await Promise.all([
fetch('/api/categories'),
fetch('/api/scenes'),
fetch('/api/platforms')
])
if (categoriesRes.ok) {
const data = await categoriesRes.json()
setCategories(data)
}
if (scenesRes.ok) {
const data = await scenesRes.json()
setScenes(data)
}
if (platformsRes.ok) {
const data = await platformsRes.json()
setPlatforms(data)
}
} catch (error) {
console.error('Error fetching categories data:', error)
}
}, [])
useEffect(() => {
fetchData()
}, [fetchData])
const handleUpdateCategory = async (id: string, newName: string, options?: string | null) => {
if (!newName.trim()) {
showToast('分类名称不能为空', 'error')
return
}
try {
const response = await fetch('/api/categories', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ id, name: newName.trim(), options }),
})
if (response.ok) {
setEditingCategory(null)
showToast('修改成功')
await fetchData()
onDataChange?.()
} else {
const data = await response.json()
showToast(data.error || '修改失败', 'error')
}
} catch (error) {
console.error('Error updating category:', error)
showToast('修改失败', 'error')
}
}
const handleUpdateScene = async (id: string, newName: string, options?: string | null) => {
if (!newName.trim()) {
showToast('场景名称不能为空', 'error')
return
}
try {
const response = await fetch('/api/scenes', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ id, name: newName.trim(), options }),
})
if (response.ok) {
setEditingScene(null)
showToast('修改成功')
await fetchData()
onDataChange?.()
} else {
const data = await response.json()
showToast(data.error || '修改失败', 'error')
}
} catch (error) {
console.error('Error updating scene:', error)
showToast('修改失败', 'error')
}
}
const handleUpdatePlatform = async (id: string, newName: string, options?: string | null) => {
if (!newName.trim()) {
showToast('平台名称不能为空', 'error')
return
}
try {
const response = await fetch('/api/platforms', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ id, name: newName.trim(), options }),
})
if (response.ok) {
setEditingPlatform(null)
showToast('修改成功')
await fetchData()
onDataChange?.()
} else {
const data = await response.json()
showToast(data.error || '修改失败', 'error')
}
} catch (error) {
console.error('Error updating platform:', error)
showToast('修改失败', 'error')
}
}
const handleDeleteCategory = async (id: string) => {
if (!confirm('确定要删除这个使用店铺分类吗?')) return
try {
const response = await fetch(`/api/categories?id=${id}`, {
method: 'DELETE',
})
if (response.ok) {
showToast('删除成功')
await fetchData()
onDataChange?.()
} else {
const data = await response.json()
showToast(data.error || '删除失败', 'error')
}
} catch (error) {
console.error('Error deleting category:', error)
showToast('删除失败', 'error')
}
}
const handleDeleteScene = async (id: string) => {
if (!confirm('确定要删除这个场景类别吗?')) return
try {
const response = await fetch(`/api/scenes?id=${id}`, {
method: 'DELETE',
})
if (response.ok) {
showToast('删除成功')
await fetchData()
onDataChange?.()
} else {
const data = await response.json()
showToast(data.error || '删除失败', 'error')
}
} catch (error) {
console.error('Error deleting scene:', error)
showToast('删除失败', 'error')
}
}
const handleDeletePlatform = async (id: string) => {
if (!confirm('确定要删除这个平台分类吗?')) return
try {
const response = await fetch(`/api/platforms?id=${id}`, {
method: 'DELETE',
})
if (response.ok) {
showToast('删除成功')
await fetchData()
onDataChange?.()
} else {
const data = await response.json()
showToast(data.error || '删除失败', 'error')
}
} catch (error) {
console.error('Error deleting platform:', error)
showToast('删除失败', 'error')
}
}
const handleAddCategory = async () => {
if (!newCategoryName.trim()) {
showToast('分类名称不能为空', 'error')
return
}
try {
const response = await fetch('/api/categories', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: newCategoryName.trim() }),
})
if (response.ok) {
setShowAddCategoryModal(false)
setNewCategoryName('')
showToast('添加成功')
await fetchData()
onDataChange?.()
} else {
const data = await response.json()
showToast(data.error || '添加失败', 'error')
}
} catch (error) {
console.error('Error adding category:', error)
showToast('添加失败', 'error')
}
}
const handleAddScene = async () => {
if (!newSceneName.trim()) {
showToast('场景名称不能为空', 'error')
return
}
try {
const response = await fetch('/api/scenes', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: newSceneName.trim() }),
})
if (response.ok) {
setShowAddSceneModal(false)
setNewSceneName('')
showToast('添加成功')
await fetchData()
onDataChange?.()
} else {
const data = await response.json()
showToast(data.error || '添加失败', 'error')
}
} catch (error) {
console.error('Error adding scene:', error)
showToast('添加失败', 'error')
}
}
const handleAddPlatform = async () => {
if (!newPlatformName.trim()) {
showToast('平台名称不能为空', 'error')
return
}
try {
const response = await fetch('/api/platforms', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: newPlatformName.trim() }),
})
if (response.ok) {
setShowAddPlatformModal(false)
setNewPlatformName('')
showToast('添加成功')
await fetchData()
onDataChange?.()
} else {
const data = await response.json()
showToast(data.error || '添加失败', 'error')
}
} catch (error) {
console.error('Error adding platform:', error)
showToast('添加失败', 'error')
}
}
const renderCategoryCard = (category: Category) => (
<div
key={category.id}
className={cn(styles.cardStyles.flat, 'p-4 hover:shadow-md transition-all')}
>
{editingCategory?.id === category.id ? (
<div className="space-y-3" data-category-editor>
<div>
<label className={cn('block text-xs font-medium mb-1.5', textLabel)}>
分类名称 <span className="text-red-500">*</span>
</label>
<input
type="text"
defaultValue={category.name}
name="category-name"
className={cn(styles.inputStyles.base, 'text-sm')}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const editor = e.currentTarget.closest('[data-category-editor]') as HTMLElement | null
const optionsInput = editor?.querySelector<HTMLTextAreaElement>('textarea[name="category-options"]')
handleUpdateCategory(category.id, e.currentTarget.value, optionsInput?.value || null)
} else if (e.key === 'Escape') {
setEditingCategory(null)
}
}}
autoFocus
/>
</div>
<div>
<label className={cn('block text-xs font-medium mb-1.5', textLabel)}>
下拉选项 <span className={cn('font-normal', textHint)}>(可选)</span>
</label>
<textarea
rows={4}
defaultValue={category.options ? (() => {
try {
const parsed = JSON.parse(category.options)
return Array.isArray(parsed) ? parsed.join('\n') : category.options
} catch {
return category.options
}
})() : ''}
name="category-options"
className={cn(styles.inputStyles.base, 'text-sm resize-none font-mono')}
placeholder='直接输入选项,每行一个,例如:&#10;选项1&#10;选项2&#10;选项3'
/>
<div className={cn('mt-2 p-2.5 rounded-lg', bgInfo, borderInfo)}>
<p className={cn('text-[10px] font-semibold mb-1.5', textInfoTitle)}>填写说明:</p>
<div className={cn('text-[10px]', textInfo)}>
<p className="mb-1.5">每行输入一个选项,例如:</p>
<div className={cn('ml-3 font-mono px-2 py-1 rounded', bgCode, borderCode)}>
选项1<br/>
选项2<br/>
选项3
</div>
</div>
</div>
</div>
<div className="flex gap-2">
<button
onClick={(e) => {
const editor = e.currentTarget.closest('[data-category-editor]') as HTMLElement | null
const nameInput = editor?.querySelector<HTMLInputElement>('input[name="category-name"]')
const optionsInput = editor?.querySelector<HTMLTextAreaElement>('textarea[name="category-options"]')
handleUpdateCategory(category.id, nameInput?.value || '', optionsInput?.value || null)
}}
className={cn(styles.buttonStyles.primary, styles.buttonModifiers.compact, 'flex-1')}
>
保存
</button>
<button
onClick={() => setEditingCategory(null)}
className={cn(styles.buttonStyles.secondary, styles.buttonModifiers.compact, 'flex-1')}
>
取消
</button>
</div>
</div>
) : (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className={cn('font-semibold', textTitle)}>
{category.name}
</h3>
<span className={cn(styles.badgeStyles.success, 'text-xs')}>
{category.count} 个素材
</span>
</div>
<div className="flex gap-2">
<button
onClick={() => setEditingCategory(category)}
className={cn(styles.buttonStyles.secondary, styles.buttonModifiers.compact, 'flex-1')}
>
编辑
</button>
<button
onClick={() => handleDeleteCategory(category.id)}
className={cn(styles.buttonStyles.danger, styles.buttonModifiers.compact, 'flex-1')}
>
删除
</button>
</div>
</div>
)}
</div>
)
const renderSceneCard = (scene: Scene) => (
<div
key={scene.id}
className={cn(styles.cardStyles.flat, 'p-4 hover:shadow-md transition-all')}
>
{editingScene?.id === scene.id ? (
<div className="space-y-3" data-scene-editor>
<div>
<label className={cn('block text-xs font-medium mb-1.5', textLabel)}>
分类名称 <span className="text-red-500">*</span>
</label>
<input
type="text"
defaultValue={scene.name}
name="scene-name"
className={cn(styles.inputStyles.base, 'text-sm')}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const editor = e.currentTarget.closest('[data-scene-editor]') as HTMLElement | null
const optionsInput = editor?.querySelector<HTMLTextAreaElement>('textarea[name="scene-options"]')
handleUpdateScene(scene.id, e.currentTarget.value, optionsInput?.value || null)
} else if (e.key === 'Escape') {
setEditingScene(null)
}
}}
autoFocus
/>
</div>
<div>
<label className={cn('block text-xs font-medium mb-1.5', textLabel)}>
下拉选项 <span className={cn('font-normal', textHint)}>(可选)</span>
</label>
<textarea
rows={4}
defaultValue={scene.options ? (() => {
try {
const parsed = JSON.parse(scene.options)
return Array.isArray(parsed) ? parsed.join('\n') : scene.options
} catch {
return scene.options
}
})() : ''}
name="scene-options"
className={cn(styles.inputStyles.base, 'text-sm resize-none font-mono')}
placeholder='直接输入选项,每行一个,例如:&#10;选项1&#10;选项2&#10;选项3'
/>
<div className={cn('mt-2 p-2.5 rounded-lg', bgInfo, borderInfo)}>
<p className={cn('text-[10px] font-semibold mb-1.5', textInfoTitle)}>填写说明:</p>
<div className={cn('text-[10px]', textInfo)}>
<p className="mb-1.5">每行输入一个选项,例如:</p>
<div className={cn('ml-3 font-mono px-2 py-1 rounded', bgCode, borderCode)}>
选项1<br/>
选项2<br/>
选项3
</div>
</div>
</div>
</div>
<div className="flex gap-2">
<button
onClick={(e) => {
const editor = e.currentTarget.closest('[data-scene-editor]') as HTMLElement | null
const nameInput = editor?.querySelector<HTMLInputElement>('input[name="scene-name"]')
const optionsInput = editor?.querySelector<HTMLTextAreaElement>('textarea[name="scene-options"]')
handleUpdateScene(scene.id, nameInput?.value || '', optionsInput?.value || null)
}}
className={cn(styles.buttonStyles.primary, styles.buttonModifiers.compact, 'flex-1')}
>
保存
</button>
<button
onClick={() => setEditingScene(null)}
className={cn(styles.buttonStyles.secondary, styles.buttonModifiers.compact, 'flex-1')}
>
取消
</button>
</div>
</div>
) : (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className={cn('font-semibold', textTitle)}>
{scene.name}
</h3>
<span className={cn(styles.badgeStyles.success, 'text-xs')}>
{scene.count} 个素材
</span>
</div>
<div className="flex gap-2">
<button
onClick={() => setEditingScene(scene)}
className={cn(styles.buttonStyles.secondary, styles.buttonModifiers.compact, 'flex-1')}
>
编辑
</button>
<button
onClick={() => handleDeleteScene(scene.id)}
className={cn(styles.buttonStyles.danger, styles.buttonModifiers.compact, 'flex-1')}
>
删除
</button>
</div>
</div>
)}
</div>
)
const renderPlatformCard = (platform: Platform) => (
<div
key={platform.id}
className={cn(styles.cardStyles.flat, 'p-4 hover:shadow-md transition-all')}
>
{editingPlatform?.id === platform.id ? (
<div className="space-y-3" data-platform-editor>
<div>
<label className={cn('block text-xs font-medium mb-1.5', textLabel)}>
分类名称 <span className="text-red-500">*</span>
</label>
<input
type="text"
defaultValue={platform.name}
name="platform-name"
className={cn(styles.inputStyles.base, 'text-sm rounded-lg')}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const editor = e.currentTarget.closest('[data-platform-editor]') as HTMLElement | null
const optionsInput = editor?.querySelector<HTMLTextAreaElement>('textarea[name="platform-options"]')
handleUpdatePlatform(platform.id, e.currentTarget.value, optionsInput?.value || null)
} else if (e.key === 'Escape') {
setEditingPlatform(null)
}
}}
autoFocus
/>
</div>
<div>
<label className={cn('block text-xs font-medium mb-1.5', textLabel)}>
下拉选项 <span className={cn('font-normal', textHint)}>(可选)</span>
</label>
<textarea
rows={4}
defaultValue={platform.options ? (() => {
try {
const parsed = JSON.parse(platform.options)
return Array.isArray(parsed) ? parsed.join('\n') : platform.options
} catch {
return platform.options
}
})() : ''}
name="platform-options"
className={cn(
styles.inputStyles.base,
'text-sm rounded-lg resize-none font-mono',
theme === 'dark' ? 'bg-slate-900' : ''
)}
placeholder='直接输入选项,每行一个,例如:&#10;选项1&#10;选项2&#10;选项3'
/>
<div className={cn('mt-2 p-2.5 rounded-lg', bgInfo, borderInfo)}>
<p className={cn('text-[10px] font-semibold mb-1.5', textInfoTitle)}>填写说明:</p>
<div className={cn('text-[10px]', textInfo)}>
<p className="mb-1.5">每行输入一个选项,例如:</p>
<div className={cn('ml-3 font-mono px-2 py-1 rounded', bgCode, borderCode)}>
选项1<br/>
选项2<br/>
选项3
</div>
</div>
</div>
</div>
<div className="flex gap-2">
<button
onClick={(e) => {
const editor = e.currentTarget.closest('[data-platform-editor]') as HTMLElement | null
const nameInput = editor?.querySelector<HTMLInputElement>('input[name="platform-name"]')
const optionsInput = editor?.querySelector<HTMLTextAreaElement>('textarea[name="platform-options"]')
handleUpdatePlatform(platform.id, nameInput?.value || '', optionsInput?.value || null)
}}
className={cn(styles.buttonStyles.primary, styles.buttonModifiers.compact, 'flex-1')}
>
保存
</button>
<button
onClick={() => setEditingPlatform(null)}
className={cn(styles.buttonStyles.secondary, styles.buttonModifiers.compact, 'flex-1')}
>
取消
</button>
</div>
</div>
) : (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className={cn('font-semibold', textTitle)}>
{platform.name}
</h3>
<span className={cn(styles.badgeStyles.success, 'text-xs')}>
{platform.count} 个素材
</span>
</div>
<div className="flex gap-2">
<button
onClick={() => setEditingPlatform(platform)}
className={cn(styles.buttonStyles.secondary, styles.buttonModifiers.compact, 'flex-1')}
>
编辑
</button>
<button
onClick={() => handleDeletePlatform(platform.id)}
className={cn(styles.buttonStyles.danger, styles.buttonModifiers.compact, 'flex-1')}
>
删除
</button>
</div>
</div>
)}
</div>
)
return (
<div className="space-y-8">
{/* 使用店铺分类 */}
<div className={cn(styles.cardStyles.base)}>
<div className="px-6 py-4 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between">
<div>
<h2 className={cn('text-lg font-semibold', textTitle)}>使用店铺分类</h2>
<p className={cn('text-sm mt-1', textHint)}>管理所有使用店铺分类名称</p>
</div>
<button
onClick={() => setShowAddCategoryModal(true)}
className={cn(styles.buttonStyles.primary, styles.buttonModifiers.compact, 'flex items-center gap-2')}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
新增店铺
</button>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{categories.map(renderCategoryCard)}
</div>
{categories.length === 0 && (
<div className={cn('text-center py-8', textHint)}>
暂无使用店铺分类
</div>
)}
</div>
</div>
{/* 场景类别 */}
<div className={cn(styles.cardStyles.base)}>
<div className={cn('px-6 py-4 border-b flex items-center justify-between', theme === 'dark' ? 'border-slate-800' : 'border-slate-200')}>
<div>
<h2 className={cn('text-lg font-semibold', textTitle)}>场景类别</h2>
<p className={cn('text-sm mt-1', textHint)}>管理所有场景类别名称</p>
</div>
<button
onClick={() => setShowAddSceneModal(true)}
className={cn(styles.buttonStyles.primary, styles.buttonModifiers.compact, 'flex items-center gap-2')}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
新增场景
</button>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{scenes.map(renderSceneCard)}
</div>
{scenes.length === 0 && (
<div className={cn('text-center py-8', textHint)}>
暂无场景类别
</div>
)}
</div>
</div>
{/* 平台分类 */}
<div className={cn(styles.cardStyles.base)}>
<div className={cn('px-6 py-4 border-b flex items-center justify-between', theme === 'dark' ? 'border-slate-800' : 'border-slate-200')}>
<div>
<h2 className={cn('text-lg font-semibold', textTitle)}>平台分类(可选)</h2>
<p className={cn('text-sm mt-1', textHint)}>管理所有平台分类名称,用于素材分类</p>
</div>
<button
onClick={() => setShowAddPlatformModal(true)}
className={cn(styles.buttonStyles.primary, styles.buttonModifiers.compact, 'flex items-center gap-2')}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
新增平台
</button>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{platforms.map(renderPlatformCard)}
</div>
{platforms.length === 0 && (
<div className={cn('text-center py-8', textHint)}>
暂无平台分类
</div>
)}
</div>
</div>
{/* Add Category Modal */}
{showAddCategoryModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className={cn(styles.cardStyles.base, 'w-full max-w-md')}>
<h3 className={cn('text-lg font-semibold mb-4', textTitle)}>
新增使用店铺分类
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
分类名称
</label>
<input
type="text"
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleAddCategory()
} else if (e.key === 'Escape') {
setShowAddCategoryModal(false)
setNewCategoryName('')
}
}}
placeholder="请输入店铺名称"
className={cn(styles.inputStyles.base)}
autoFocus
/>
</div>
<div className="flex gap-3 justify-end">
<button
onClick={() => {
setShowAddCategoryModal(false)
setNewCategoryName('')
}}
className={cn(styles.buttonStyles.secondary)}
>
取消
</button>
<button
onClick={handleAddCategory}
className={cn(styles.buttonStyles.primary)}
>
确定添加
</button>
</div>
</div>
</div>
</div>
)}
{/* Add Scene Modal */}
{showAddSceneModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className={cn(styles.cardStyles.base, 'w-full max-w-md')}>
<h3 className={cn('text-lg font-semibold mb-4', textTitle)}>
新增场景类别
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
类别名称
</label>
<input
type="text"
value={newSceneName}
onChange={(e) => setNewSceneName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleAddScene()
} else if (e.key === 'Escape') {
setShowAddSceneModal(false)
setNewSceneName('')
}
}}
placeholder="请输入场景类别名称"
className={cn(styles.inputStyles.base)}
autoFocus
/>
</div>
<div className="flex gap-3 justify-end">
<button
onClick={() => {
setShowAddSceneModal(false)
setNewSceneName('')
}}
className={cn(styles.buttonStyles.secondary)}
>
取消
</button>
<button
onClick={handleAddScene}
className={cn(styles.buttonStyles.primary)}
>
确定添加
</button>
</div>
</div>
</div>
</div>
)}
{/* Add Platform Modal */}
{showAddPlatformModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className={cn(styles.cardStyles.base, 'w-full max-w-md')}>
<h3 className={cn('text-lg font-semibold mb-4', textTitle)}>
新增平台分类
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
平台名称
</label>
<input
type="text"
value={newPlatformName}
onChange={(e) => setNewPlatformName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleAddPlatform()
} else if (e.key === 'Escape') {
setShowAddPlatformModal(false)
setNewPlatformName('')
}
}}
placeholder="请输入平台名称"
className={cn(styles.inputStyles.base)}
autoFocus
/>
</div>
<div className="flex gap-3 justify-end">
<button
onClick={() => {
setShowAddPlatformModal(false)
setNewPlatformName('')
}}
className={cn(styles.buttonStyles.secondary)}
>
取消
</button>
<button
onClick={handleAddPlatform}
className={cn(styles.buttonStyles.primary)}
>
确定添加
</button>
</div>
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,382 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { useThemeStyles, useTheme, cn } from '@/lib/ui-styles'
import type { Department, DepartmentNode, ToastFn } from './types'
import { buildDepartmentTree, filterDepartmentTree } from '../_utils/departmentTree'
interface DepartmentsTabProps {
departments: Department[]
onCreateDepartment: (name: string) => Promise<void> | void
onRenameDepartment: (id: string, newName: string) => Promise<void> | void
onDeleteDepartment: (id: string, fullName: string) => Promise<void> | void
showToast: ToastFn
}
export default function DepartmentsTab({
departments,
onCreateDepartment,
onRenameDepartment,
onDeleteDepartment,
showToast,
}: DepartmentsTabProps) {
const styles = useThemeStyles()
const theme = useTheme()
const [departmentSearchTerm, setDepartmentSearchTerm] = useState('')
const [collapsedDepartments, setCollapsedDepartments] = useState<Set<string>>(new Set())
const [renamingDepartmentId, setRenamingDepartmentId] = useState<string | null>(null)
const [renamingDepartmentName, setRenamingDepartmentName] = useState('')
const [addingDepartment, setAddingDepartment] = useState(false)
// 根据主题选择颜色类名
const bgIndigo50 = theme === 'dark' ? 'bg-indigo-900/50' : 'bg-indigo-50/90'
const bgIndigo30 = theme === 'dark' ? 'bg-indigo-900/30' : 'bg-indigo-50/70'
const bgIndigo20 = theme === 'dark' ? 'bg-indigo-900/20' : 'bg-indigo-50/50'
const borderIndigo400 = theme === 'dark' ? 'border-indigo-400' : 'border-indigo-400'
const borderIndigo300 = theme === 'dark' ? 'border-indigo-500/80' : 'border-indigo-300'
const borderIndigo200 = theme === 'dark' ? 'border-indigo-700/80' : 'border-indigo-200'
const shadowDark = theme === 'dark' ? 'shadow-[0_1px_3px_rgba(15,23,42,0.55)]' : 'shadow-[0_1px_3px_rgba(15,23,42,0.06)]'
const hoverBgIndigo = theme === 'dark' ? 'hover:bg-indigo-700/80' : 'hover:bg-indigo-200/80'
const textIndigo = theme === 'dark' ? 'text-indigo-300' : 'text-indigo-600'
const borderInputIndigo = theme === 'dark' ? 'border-indigo-600' : 'border-indigo-300'
const bgInput = theme === 'dark' ? 'bg-slate-900' : 'bg-white'
const textTitle = theme === 'dark' ? 'text-white' : 'text-slate-900'
const textDesc = theme === 'dark' ? 'text-slate-300' : 'text-slate-600'
const textTitle90 = theme === 'dark' ? 'text-white/90' : 'text-slate-900'
const bgGradient = theme === 'dark' ? 'from-slate-900 via-slate-800 to-indigo-900' : 'from-slate-50 via-blue-50 to-indigo-50'
const departmentTree = useMemo(
() => buildDepartmentTree(departments),
[departments],
)
const filteredDepartmentTree = useMemo(
() => filterDepartmentTree(departmentTree, departmentSearchTerm),
[departmentTree, departmentSearchTerm],
)
const toggleDepartmentCollapse = useCallback((deptName: string) => {
setCollapsedDepartments((prev) => {
const next = new Set(prev)
if (next.has(deptName)) {
next.delete(deptName)
} else {
next.add(deptName)
}
return next
})
}, [])
const handleRootAdd = async () => {
const input = window.prompt('为「主公司」添加一级分支,例如:一部、设计部、技术部等', '')
if (!input) return
const trimmed = input.trim()
if (!trimmed) {
showToast('分支名称不能为空', 'error')
return
}
setAddingDepartment(true)
try {
await onCreateDepartment(trimmed)
} finally {
setAddingDepartment(false)
}
}
const handleAddSubDepartment = async (parentName: string) => {
const input = window.prompt(
`为「${parentName}」添加子分支\n\n建议直接输入子分支名,例如:短视频、直播组等`,
`${parentName}-`,
)
if (!input) return
const trimmed = input.trim()
if (!trimmed) {
showToast('子分支名称不能为空', 'error')
return
}
const finalName = trimmed.startsWith(parentName) ? trimmed : `${parentName}-${trimmed}`
setAddingDepartment(true)
try {
await onCreateDepartment(finalName)
} finally {
setAddingDepartment(false)
}
}
const handleRenameSubmit = async () => {
if (!renamingDepartmentId) return
const trimmed = renamingDepartmentName.trim()
if (!trimmed) {
showToast('部门名称不能为空', 'error')
return
}
await onRenameDepartment(renamingDepartmentId, trimmed)
setRenamingDepartmentId(null)
setRenamingDepartmentName('')
}
const handleStartRename = (deptId: string, currentName: string) => {
setRenamingDepartmentId(deptId)
setRenamingDepartmentName(currentName)
}
const handleDelete = async (deptId: string, fullName: string) => {
if (!confirm(`确定要删除部门「${fullName}」吗?`)) return
await onDeleteDepartment(deptId, fullName)
}
const renderDeptNode = (node: DepartmentNode, depth = 0): JSX.Element => {
const isCollapsed = collapsedDepartments.has(node.fullName)
const hasChildren = node.children.length > 0
const level = Math.min(depth, 2)
const depthContainerClasses =
level === 0
? 'bg-indigo-50/90 dark:bg-indigo-900/50 border-indigo-400 dark:border-indigo-400'
: level === 1
? 'bg-indigo-50/70 dark:bg-indigo-900/30 border-indigo-300 dark:border-indigo-500/80'
: 'bg-indigo-50/50 dark:bg-indigo-900/20 border-indigo-200 dark:border-indigo-700/80'
return (
<div key={node.fullName} className="space-y-1.5">
<div
className={cn(
`group flex items-center justify-between px-5 py-3 border-l-[3px] rounded-xl ${shadowDark} transition-all duration-300 ease-out hover:shadow-md hover:scale-[1.01]`,
depthContainerClasses,
)}
style={{ marginLeft: depth * 12 }}
>
<div className="flex items-center gap-3 min-w-0 flex-1">
{hasChildren ? (
<button
type="button"
className="flex-shrink-0 w-6 h-6 flex items-center justify-center rounded hover:bg-indigo-200/80 dark:hover:bg-indigo-700/80 transition-all duration-200 hover:scale-110"
title={isCollapsed ? '展开' : '折叠'}
onClick={(e) => {
e.stopPropagation()
toggleDepartmentCollapse(node.fullName)
}}
>
<svg
className={cn(
'w-4 h-4 transform transition-transform duration-300 ease-out text-indigo-600 dark:text-indigo-300',
!isCollapsed && 'rotate-90',
)}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
) : (
<span className="flex-shrink-0 w-6 h-6" />
)}
{renamingDepartmentId === node.id ? (
<input
value={renamingDepartmentName}
onChange={(e) => setRenamingDepartmentName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
void handleRenameSubmit()
} else if (e.key === 'Escape') {
setRenamingDepartmentId(null)
setRenamingDepartmentName('')
}
}}
className={cn(
styles.inputStyles.base,
styles.inputModifiers.compact,
'flex-1',
borderInputIndigo,
bgInput,
)}
autoFocus
/>
) : (
<span className={cn('text-sm font-semibold truncate', textTitle)}>{node.fullName}</span>
)}
</div>
<div className="flex items-center gap-2">
{renamingDepartmentId === node.id ? (
<>
<button
type="button"
onClick={() => void handleRenameSubmit()}
className={cn(styles.buttonStyles.primary, styles.buttonModifiers.compact)}
>
保存
</button>
<button
type="button"
onClick={() => {
setRenamingDepartmentId(null)
setRenamingDepartmentName('')
}}
className={cn(styles.buttonStyles.secondary, styles.buttonModifiers.compact)}
>
取消
</button>
</>
) : (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
void handleAddSubDepartment(node.fullName)
}}
className={cn(styles.buttonStyles.primary, styles.buttonModifiers.compact)}
>
添加子分支
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
handleStartRename(node.id, node.fullName)
}}
className={cn(styles.buttonStyles.secondary, styles.buttonModifiers.compact)}
>
重命名
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
void handleDelete(node.id, node.fullName)
}}
className={cn(styles.buttonStyles.danger, styles.buttonModifiers.compact)}
>
删除
</button>
</>
)}
</div>
</div>
{!isCollapsed && node.children.length > 0 && (
<div className="ml-6 space-y-2 animate-in fade-in slide-in-from-top-2 duration-300">
{node.children.map((child) => renderDeptNode(child, depth + 1))}
</div>
)}
</div>
)
}
return (
<div className="space-y-6">
<section
className={cn(
styles.cardStyles.large,
`bg-gradient-to-br ${bgGradient}`,
)}
>
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-1.5">
<p className={cn('text-sm font-semibold tracking-wide', textTitle90)}>组织脉络</p>
<p className={cn('text-sm', textDesc)}>
树形结构展示上下级,配合搜索框即可迅速过滤任意分支或定位负责人。
</p>
</div>
<div className="relative w-full md:w-[320px] lg:w-[360px]">
<input
type="text"
value={departmentSearchTerm}
onChange={(e) => setDepartmentSearchTerm(e.target.value)}
placeholder="搜索分支名称,例如:一部 / 技术部 / 主公司"
className={cn(styles.inputStyles.base, 'pl-11 pr-10')}
/>
<svg className={cn('absolute left-4 top-3 w-5 h-5', theme === 'dark' ? 'text-slate-500' : 'text-slate-400')} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
{departmentSearchTerm && (
<button
type="button"
onClick={() => setDepartmentSearchTerm('')}
className={cn(
styles.buttonStyles.ghost,
styles.buttonModifiers.icon,
'absolute right-2 top-2',
theme === 'dark' ? 'text-slate-400 hover:text-slate-200' : 'text-slate-500 hover:text-slate-700',
)}
aria-label="清空组织搜索"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
</section>
<div className={cn(styles.cardStyles.base, 'overflow-hidden')}>
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">组织结构视图</h3>
<span className="text-xs text-slate-500 dark:text-slate-400">
以「主公司」为根的树形结构,仅展示分支(不含人员),支持折叠与搜索
</span>
</div>
<div className="mt-4 space-y-2">
<div className="space-y-2">
<div className="group flex items-center justify-between px-4 py-2.5 bg-gradient-to-r from-indigo-50/90 to-purple-50/90 dark:from-indigo-900/50 dark:to-purple-900/50 border-l-4 border-indigo-500 dark:border-indigo-400 rounded-xl shadow-sm hover:shadow-md transition-all duration-300 ease-out hover:scale-[1.01]">
<div className="flex items-center gap-3">
<button
type="button"
className="flex-shrink-0 w-6 h-6 flex items-center justify-center rounded hover:bg-indigo-200/80 dark:hover:bg-indigo-700/80 transition-all duration-200 hover:scale-110"
title={collapsedDepartments.has('ROOT') ? '展开' : '折叠'}
onClick={() => toggleDepartmentCollapse('ROOT')}
>
<svg
className={cn(
'w-4 h-4 transform transition-transform duration-300 ease-out text-indigo-600 dark:text-indigo-300',
!collapsedDepartments.has('ROOT') && 'rotate-90',
)}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
<div className="flex flex-col">
<span className="text-sm font-semibold text-slate-900 dark:text-white">主公司</span>
<span className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
一级分支:{filteredDepartmentTree.length}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => void handleRootAdd()}
disabled={addingDepartment}
className={cn(styles.buttonStyles.primary, styles.buttonModifiers.compact)}
>
{addingDepartment ? '添加中...' : '添加子分支'}
</button>
</div>
</div>
{!collapsedDepartments.has('ROOT') && (
<div className="mt-2 space-y-2 ml-6 animate-in fade-in slide-in-from-top-2 duration-300">
{departments.length === 0 ? (
<div className="text-xs text-slate-500 dark:text-slate-400 px-4 py-2">
当前还没有任何分支,请使用上方按钮为「主公司」添加第一个一级分支
</div>
) : filteredDepartmentTree.length === 0 ? (
<div className="text-xs text-slate-500 dark:text-slate-400 px-4 py-2">
未找到匹配的分支,请调整搜索关键词或清空搜索
</div>
) : (
filteredDepartmentTree.map((node) => renderDeptNode(node))
)}
</div>
)}
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,322 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useThemeStyles, useTheme, cn } from '@/lib/ui-styles'
import { formatDateTimeLocale } from '../_utils/formatters'
import type { Invitation, ToastFn } from './types'
interface InvitationsTabProps {
showToast: ToastFn
}
export default function InvitationsTab({ showToast }: InvitationsTabProps) {
const styles = useThemeStyles()
const theme = useTheme()
const [invitations, setInvitations] = useState<Invitation[]>([])
const [showCreateInvitationModal, setShowCreateInvitationModal] = useState(false)
const [newInvitationMaxUses, setNewInvitationMaxUses] = useState(1)
const [newInvitationExpiresInDays, setNewInvitationExpiresInDays] = useState<number | null>(null)
// 根据主题选择颜色类名
const borderColor = theme === 'dark' ? 'border-slate-800' : 'border-slate-200'
const textTitle = theme === 'dark' ? 'text-white' : 'text-slate-900'
const textDesc = theme === 'dark' ? 'text-slate-400' : 'text-slate-500'
const textLabel = theme === 'dark' ? 'text-slate-300' : 'text-slate-700'
const textMono = theme === 'dark' ? 'text-white' : 'text-slate-900'
const textLink = theme === 'dark' ? 'text-slate-400' : 'text-slate-600'
const textEmpty = theme === 'dark' ? 'text-slate-500' : 'text-slate-400'
const hoverBg = theme === 'dark' ? 'hover:bg-slate-800/50' : 'hover:bg-slate-50'
const divideColor = theme === 'dark' ? 'divide-slate-800' : 'divide-slate-200'
const badgeSuccess = theme === 'dark' ? 'bg-green-900/30 text-green-300' : 'bg-green-100 text-green-800'
const badgeDanger = theme === 'dark' ? 'bg-red-900/30 text-red-300' : 'bg-red-100 text-red-800'
const fetchInvitations = useCallback(async () => {
try {
const response = await fetch('/api/invitations')
if (response.ok) {
const data = await response.json()
setInvitations(data.invitations || [])
} else {
const errorData = await response.json().catch(() => ({}))
console.error('Error fetching invitations:', errorData)
showToast(errorData.error || '获取邀请链接失败', 'error')
}
} catch (error) {
console.error('Error fetching invitations:', error)
showToast('获取邀请链接失败,请稍后重试', 'error')
}
}, [showToast])
useEffect(() => {
fetchInvitations()
}, [fetchInvitations])
const handleCreateInvitation = async () => {
try {
const response = await fetch('/api/invitations', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
maxUses: newInvitationMaxUses,
expiresInDays: newInvitationExpiresInDays,
}),
})
if (response.ok) {
await fetchInvitations()
setShowCreateInvitationModal(false)
setNewInvitationMaxUses(1)
setNewInvitationExpiresInDays(null)
showToast('邀请链接创建成功')
} else {
const error = await response.json()
showToast(error.error || '创建失败', 'error')
}
} catch (error) {
console.error('Create invitation error:', error)
showToast('创建失败,请稍后重试', 'error')
}
}
const handleToggleInvitationActive = async (id: string, isActive: boolean) => {
try {
const response = await fetch(`/api/invitations/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ isActive: !isActive }),
})
if (response.ok) {
await fetchInvitations()
showToast(isActive ? '邀请链接已禁用' : '邀请链接已激活')
} else {
const error = await response.json()
showToast(error.error || '操作失败', 'error')
}
} catch (error) {
console.error('Toggle invitation active error:', error)
showToast('操作失败,请稍后重试', 'error')
}
}
const handleDeleteInvitation = async (id: string) => {
if (!confirm('确定要删除这个邀请链接吗?')) return
try {
const response = await fetch(`/api/invitations/${id}`, {
method: 'DELETE',
})
if (response.ok) {
await fetchInvitations()
showToast('邀请链接已删除')
} else {
const error = await response.json()
showToast(error.error || '删除失败', 'error')
}
} catch (error) {
console.error('Delete invitation error:', error)
showToast('删除失败,请稍后重试', 'error')
}
}
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text).then(() => {
showToast('已复制到剪贴板')
}).catch(() => {
showToast('复制失败', 'error')
})
}
return (
<>
<div className={cn(styles.cardStyles.base, 'overflow-hidden')}>
<div className={cn('px-6 py-4 border-b', borderColor)}>
<div className="flex items-center justify-between">
<div>
<h2 className={cn('text-xl font-semibold', textTitle)}>邀请管理</h2>
<p className={cn('text-sm mt-1', textDesc)}>
创建和管理邀请链接,用户可以通过邀请链接注册账号
</p>
</div>
<button
onClick={() => setShowCreateInvitationModal(true)}
className={cn(styles.buttonStyles.primary, styles.buttonModifiers.compact, 'flex items-center gap-2')}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
创建邀请链接
</button>
</div>
</div>
<div className="p-6">
{invitations.length === 0 ? (
<div className={cn('text-center py-8', textDesc)}>
暂无邀请链接,点击上方按钮创建
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className={cn('border-b', borderColor)}>
<th className={cn('px-4 py-3 text-left text-xs font-medium uppercase', textDesc)}>邀请码</th>
<th className={cn('px-4 py-3 text-left text-xs font-medium uppercase', textDesc)}>邀请链接</th>
<th className={cn('px-4 py-3 text-left text-xs font-medium uppercase', textDesc)}>使用次数</th>
<th className={cn('px-4 py-3 text-left text-xs font-medium uppercase', textDesc)}>过期时间</th>
<th className={cn('px-4 py-3 text-left text-xs font-medium uppercase', textDesc)}>状态</th>
<th className={cn('px-4 py-3 text-left text-xs font-medium uppercase', textDesc)}>创建时间</th>
<th className={cn('px-4 py-3 text-left text-xs font-medium uppercase', textDesc)}>操作</th>
</tr>
</thead>
<tbody className={cn('divide-y', divideColor)}>
{invitations.map((invitation) => {
const isExpired = invitation.expiresAt && new Date(invitation.expiresAt) < new Date()
const isUsedUp = (invitation.currentUses || 0) >= (invitation.maxUses || 1)
const isValid = (invitation.isActive !== false) && !isExpired && !isUsedUp
return (
<tr key={invitation.id} className={hoverBg}>
<td className={cn('px-4 py-3 text-sm font-mono', textMono)}>
{invitation.code}
</td>
<td className="px-4 py-3 text-sm">
{invitation.inviteUrl ? (
<div className="flex items-center gap-2">
<span className={cn('font-mono text-xs truncate max-w-xs', textLink)}>
{invitation.inviteUrl}
</span>
<button
onClick={() => copyToClipboard(invitation.inviteUrl!)}
className={cn(styles.buttonStyles.secondary, styles.buttonModifiers.compactXs, 'flex-shrink-0')}
>
复制
</button>
</div>
) : (
<span className={textEmpty}>-</span>
)}
</td>
<td className={cn('px-4 py-3 text-sm', textMono)}>
{invitation.currentUses} / {invitation.maxUses}
</td>
<td className={cn('px-4 py-3 text-sm', textMono)}>
{invitation.expiresAt
? formatDateTimeLocale(invitation.expiresAt)
: '永不过期'}
</td>
<td className="px-4 py-3 text-sm">
<span
className={cn('px-2 py-1 rounded-full text-xs font-medium', isValid ? badgeSuccess : badgeDanger)}
>
{!invitation.isActive
? '已禁用'
: isExpired
? '已过期'
: isUsedUp
? '已用完'
: '有效'}
</span>
</td>
<td className={cn('px-4 py-3 text-sm', textDesc)}>
{formatDateTimeLocale(invitation.createdAt)}
</td>
<td className="px-4 py-3 text-sm">
<div className="flex items-center gap-2">
<button
onClick={() => handleToggleInvitationActive(invitation.id, invitation.isActive)}
className={cn(
invitation.isActive ? styles.buttonStyles.danger : styles.buttonStyles.success,
styles.buttonModifiers.compactXs,
)}
>
{invitation.isActive ? '禁用' : '启用'}
</button>
<button
onClick={() => handleDeleteInvitation(invitation.id)}
className={cn(styles.buttonStyles.danger, styles.buttonModifiers.compactXs)}
>
删除
</button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
</div>
{/* Create Invitation Modal */}
{showCreateInvitationModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className={cn(styles.cardStyles.base, 'w-full max-w-md')}>
<h3 className={cn('text-lg font-semibold mb-4', textTitle)}>
创建邀请链接
</h3>
<div className="space-y-4">
<div>
<label className={cn('block text-sm font-medium mb-2', textLabel)}>
最大使用次数
</label>
<input
type="number"
min="1"
value={newInvitationMaxUses}
onChange={(e) => setNewInvitationMaxUses(parseInt(e.target.value) || 1)}
className={cn(styles.inputStyles.base)}
placeholder="请输入最大使用次数"
/>
<p className={cn('text-xs mt-1', textDesc)}>
邀请链接最多可以被使用多少次
</p>
</div>
<div>
<label className={cn('block text-sm font-medium mb-2', textLabel)}>
过期天数(可选)
</label>
<input
type="number"
min="1"
value={newInvitationExpiresInDays || ''}
onChange={(e) => setNewInvitationExpiresInDays(e.target.value ? parseInt(e.target.value) : null)}
className={cn(styles.inputStyles.base)}
placeholder="留空表示永不过期"
/>
<p className={cn('text-xs mt-1', textDesc)}>
邀请链接在多少天后过期,留空表示永不过期
</p>
</div>
<div className="flex gap-3 justify-end">
<button
onClick={() => {
setShowCreateInvitationModal(false)
setNewInvitationMaxUses(1)
setNewInvitationExpiresInDays(null)
}}
className={cn(styles.buttonStyles.secondary)}
>
取消
</button>
<button
onClick={handleCreateInvitation}
className={cn(styles.buttonStyles.primary)}
>
创建
</button>
</div>
</div>
</div>
</div>
)}
</>
)
}

View File

@ -0,0 +1,214 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import Image from 'next/image'
import EditMaterialModal from '@/components/EditMaterialModal'
import { useThemeStyles, useTheme, cn } from '@/lib/ui-styles'
import type { Material, ToastFn } from './types'
import LoadingState from './ui/LoadingState'
interface MaterialsTabProps {
showToast: ToastFn
}
export default function MaterialsTab({ showToast }: MaterialsTabProps) {
const styles = useThemeStyles()
const theme = useTheme()
const [materials, setMaterials] = useState<Material[]>([])
const [loading, setLoading] = useState(true)
const [editingMaterial, setEditingMaterial] = useState<Material | null>(null)
const [materialSearchTerm, setMaterialSearchTerm] = useState('')
const fetchMaterials = useCallback(async () => {
try {
setLoading(true)
const response = await fetch('/api/materials?pageSize=1000')
if (response.ok) {
const data = await response.json()
setMaterials(data.materials || data)
} else {
showToast('获取素材列表失败', 'error')
}
} catch (error) {
console.error('Error fetching materials:', error)
showToast('获取素材列表失败', 'error')
} finally {
setLoading(false)
}
}, [showToast])
useEffect(() => {
fetchMaterials()
}, [fetchMaterials])
const handleDeleteMaterial = async (id: string) => {
if (!confirm('确定要删除这个素材吗?')) return
try {
const response = await fetch(`/api/materials?id=${id}`, {
method: 'DELETE',
})
if (response.ok) {
setMaterials(prevMaterials => prevMaterials.filter(m => m.id !== id))
showToast('删除成功')
} else {
showToast('删除失败', 'error')
}
} catch (error) {
console.error('Error deleting material:', error)
showToast('删除失败', 'error')
}
}
const filteredMaterials = useMemo(() => {
if (!materialSearchTerm.trim()) return materials
const term = materialSearchTerm.toLowerCase()
return materials.filter((material) => {
return (
material.productModel?.toLowerCase().includes(term) ||
material.brandName?.toLowerCase().includes(term) ||
material.categoryName?.toLowerCase().includes(term) ||
material.sceneName?.toLowerCase().includes(term) ||
material.materialType?.toLowerCase().includes(term) ||
material.user.username?.toLowerCase().includes(term)
)
})
}, [materials, materialSearchTerm])
if (loading) {
return <LoadingState message="加载素材列表..." />
}
// 根据主题选择颜色类名
const borderColor = theme === 'dark' ? 'border-slate-800' : 'border-slate-200'
const textTitle = theme === 'dark' ? 'text-white' : 'text-slate-900'
const textDesc = theme === 'dark' ? 'text-slate-400' : 'text-slate-500'
const textIcon = theme === 'dark' ? 'text-slate-500' : 'text-slate-400'
const textIconHover = theme === 'dark' ? 'hover:text-slate-200' : 'hover:text-slate-700'
const bgImage = theme === 'dark' ? 'bg-slate-800' : 'bg-slate-100'
const textPlaceholder = theme === 'dark' ? 'text-slate-500' : 'text-slate-400'
const textItem = theme === 'dark' ? 'text-white' : 'text-slate-900'
const textItemDesc = theme === 'dark' ? 'text-slate-400' : 'text-slate-500'
return (
<>
<div className={cn(styles.cardStyles.base)}>
<div className={cn('px-6 py-4 border-b', borderColor, 'flex items-center justify-between')}>
<div>
<h2 className={cn('text-xl font-semibold', textTitle)}>素材管理</h2>
<p className={cn('text-sm mt-1', textDesc)}>
管理所有上传的素材,支持搜索、编辑和删除
</p>
</div>
</div>
<div className="p-6">
{/* 搜索框 */}
<div className="mb-6">
<div className="relative">
<input
type="text"
placeholder="搜索素材(型号、品牌、分类、场景、类型、上传者)..."
value={materialSearchTerm}
onChange={(e) => setMaterialSearchTerm(e.target.value)}
className={cn(styles.inputStyles.base, 'pl-11 pr-10')}
/>
<svg className={cn('absolute left-4 top-3 w-5 h-5', textIcon)} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
{materialSearchTerm && (
<button
onClick={() => setMaterialSearchTerm('')}
className={cn(
styles.buttonStyles.ghost,
styles.buttonModifiers.icon,
'absolute right-2 top-2',
textIcon,
textIconHover,
)}
aria-label="清空搜索"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
{/* 素材列表 */}
{filteredMaterials.length === 0 ? (
<div className={cn('text-center py-12', textDesc)}>
{materialSearchTerm ? '暂无匹配的素材' : '暂无素材'}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filteredMaterials.map((material) => (
<div
key={material.id}
className={cn(styles.cardStyles.hover, 'overflow-hidden')}
>
<div className={cn('aspect-square relative overflow-hidden', bgImage)}>
{material.previewUrl ? (
<Image
src={material.previewUrl}
alt={material.productModel || '素材预览图'}
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, (max-width: 1280px) 33vw, 25vw"
/>
) : (
<div className={cn('w-full h-full flex items-center justify-center', textPlaceholder)}>
<svg className="w-12 h-12" 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>
<div className="p-4">
<h3 className={cn('font-semibold truncate mb-1', textItem)}>
{material.productModel}
</h3>
<div className={cn('text-xs space-y-1 mb-3', textItemDesc)}>
<div>类型: {material.materialType}</div>
<div>分类: {material.categoryName}</div>
<div>上传者: {material.user.username}</div>
</div>
<div className="flex gap-2">
<button
onClick={() => setEditingMaterial(material)}
className={cn(styles.buttonStyles.secondary, styles.buttonModifiers.compactXs, 'flex-1')}
>
编辑
</button>
<button
onClick={() => handleDeleteMaterial(material.id)}
className={cn(styles.buttonStyles.danger, styles.buttonModifiers.compactXs, 'flex-1')}
>
删除
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Edit Material Modal */}
{editingMaterial && (
<EditMaterialModal
material={editingMaterial}
onClose={() => setEditingMaterial(null)}
onSuccess={async () => {
setEditingMaterial(null)
await fetchMaterials()
showToast('修改成功')
}}
/>
)}
</>
)
}

View File

@ -0,0 +1,355 @@
'use client'
import { useEffect, useMemo } from 'react'
import { useSession } from 'next-auth/react'
import { useThemeStyles, useTheme, cn } from '@/lib/ui-styles'
import { formatDateTimeLocale } from '../_utils/formatters'
import type { ToastFn, DashboardData } from './types'
import { useDashboardData } from '../_hooks/useDashboardData'
import LoadingState from './ui/LoadingState'
import ErrorState from './ui/ErrorState'
import EmptyState from './ui/EmptyState'
interface MonitorTabProps {
showToast: ToastFn
}
const AUTO_REFRESH_INTERVAL = 60_000
export default function MonitorTab({ showToast }: MonitorTabProps) {
const styles = useThemeStyles()
const theme = useTheme()
// 根据主题选择颜色类名
const borderColor = theme === 'dark' ? 'border-slate-800/70' : 'border-slate-200/70'
const textTitle = theme === 'dark' ? 'text-white' : 'text-slate-900'
const textDesc = theme === 'dark' ? 'text-slate-400' : 'text-slate-500'
const textLabel = theme === 'dark' ? 'text-slate-400' : 'text-slate-500'
const textValue = theme === 'dark' ? 'text-white' : 'text-slate-900'
const borderRow = theme === 'dark' ? 'border-slate-700' : 'border-slate-200'
const textRow = theme === 'dark' ? 'text-slate-400' : 'text-slate-600'
const { data: session } = useSession()
const currentIsSuperAdmin = Boolean((session?.user as any)?.isSuperAdmin)
const { data, loading, error, refresh, updateSystemSetting, lastUpdated, refreshing } = useDashboardData({ showToast })
useEffect(() => {
refresh()
}, [refresh])
useEffect(() => {
const timer = setInterval(() => {
void refresh({ background: true })
}, AUTO_REFRESH_INTERVAL)
return () => clearInterval(timer)
}, [refresh])
const materialUploadMax = useMemo(() => {
if (!data?.trends.materialUploads?.length) return 1
return Math.max(...data.trends.materialUploads.map((item) => item.count), 1)
}, [data?.trends.materialUploads])
const formattedLastUpdated = useMemo(() => {
if (!lastUpdated) return '尚未获取'
return formatDateTimeLocale(lastUpdated, 'zh-CN', { hour12: false })
}, [lastUpdated])
const handleManualRefresh = () => {
void refresh({ background: Boolean(data) })
}
if (loading) {
return <LoadingState message="加载系统监控数据..." />
}
if (error) {
return <ErrorState message={error} onRetry={() => refresh()} />
}
if (!data) {
return <EmptyState message="暂无数据" />
}
const handleToggle = (key: SystemSettingKey, value: boolean) => {
void updateSystemSetting(key, value).catch(() => undefined)
}
return (
<div className="space-y-8">
<div
className={cn(
styles.cardStyles.base,
cn('flex flex-wrap items-center justify-between gap-4 border', borderColor),
)}
>
<div>
<p className={cn('text-sm font-semibold', textTitle)}>系统监控概览</p>
<p className={cn('text-xs', textDesc)}>最近更新:{formattedLastUpdated}</p>
</div>
<div className="flex items-center gap-3">
{refreshing && (
<span className={cn('text-xs flex items-center gap-1', textDesc)}>
<span className="inline-block h-3 w-3 rounded-full border-2 border-slate-300 border-t-indigo-500 animate-spin" />
后台同步中...
</span>
)}
<button
type="button"
className={cn(styles.buttonStyles.secondary, 'flex items-center gap-2')}
onClick={handleManualRefresh}
>
<svg className={cn('w-4 h-4', refreshing && 'animate-spin')} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v6h6M20 20v-6h-6M5 19a9 9 0 0014-7 9 9 0 00-9-9" />
</svg>
刷新数据
</button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6">
{OVERVIEW_CARDS.map((card) => (
<div key={card.label} className={cn(styles.cardStyles.base, 'overflow-hidden')}>
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className={cn('w-8 h-8 rounded-full flex items-center justify-center shadow-sm', card.className)}>
<span className="text-white font-medium">{card.icon}</span>
</div>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className={cn('text-sm font-medium truncate', textLabel)}>{card.label}</dt>
<dd className={cn('text-lg font-semibold', textValue)}>{card.getValue(data)}</dd>
</dl>
</div>
</div>
</div>
</div>
))}
</div>
<div className={cn(styles.cardStyles.base)}>
<h3 className={cn('text-lg font-semibold mb-4', textTitle)}>📊 素材上传趋势(最近12个月)</h3>
<div className="h-64 overflow-y-auto">
{data.trends.materialUploads.length ? (
<div className="space-y-2">
{data.trends.materialUploads.map((item) => (
<div
key={item.date}
className={cn('flex justify-between items-center py-2 border-b', borderRow)}
>
<span className={cn('text-sm', textRow)}>{item.date}</span>
<div className="flex items-center">
<div className="w-32 bg-slate-200 dark:bg-slate-700 rounded-full h-2 mr-3">
<div
className="bg-purple-600 dark:bg-purple-500 h-2 rounded-full transition-all"
style={{ width: `${Math.min(100, (item.count / materialUploadMax) * 100)}%` }}
/>
</div>
<span className={cn('text-sm font-semibold', textValue)}>{item.count}</span>
</div>
</div>
))}
</div>
) : (
<div className={cn('h-full flex items-center justify-center text-sm', textDesc)}>
暂无趋势数据
</div>
)}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className={cn(styles.cardStyles.base)}>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-4">🏢 部门分布</h3>
<div className="space-y-3">
{data.distribution.departments.length ? (
data.distribution.departments.map((item) => (
<div key={`${item.department || 'unknown'}`} className="flex justify-between items-center">
<span className={cn('text-sm', textRow)}>{item.department || '未设置'}</span>
<span className={cn('text-sm font-semibold', textValue)}>{item.count}</span>
</div>
))
) : (
<p className={cn('text-sm', textDesc)}>暂无部门分布数据</p>
)}
</div>
</div>
<div className={cn(styles.cardStyles.base)}>
<h3 className={cn('text-lg font-semibold mb-4', textTitle)}>🛡️ 权限分布</h3>
<div className="space-y-3">
{data.distribution.roles.length ? (
data.distribution.roles.map((item, index) => (
<div key={`${item.role}-${index}`} className="flex justify-between items-center">
<span className={cn('text-sm', textRow)}>
{item.role === 'ADMIN' && item.isSuperAdmin
? '超级管理员'
: item.role === 'ADMIN'
? '管理员'
: '普通用户'}
</span>
<span className={cn('text-sm font-semibold', textValue)}>{item.count}</span>
</div>
))
) : (
<p className={cn('text-sm', textDesc)}>暂无权限分布数据</p>
)}
</div>
</div>
<div className={cn(styles.cardStyles.base)}>
<h3 className={cn('text-lg font-semibold mb-4', textTitle)}>⚙️ 系统信息</h3>
<div className="space-y-3">
<SystemToggleRow
label="注册功能"
editable={currentIsSuperAdmin}
enabled={data.systemInfo.registrationEnabled}
onToggle={(value) => handleToggle('registrationEnabled', value)}
activeText="开启"
inactiveText="关闭"
/>
<SystemToggleRow
label="用户激活"
editable={currentIsSuperAdmin}
enabled={data.systemInfo.userActivationRequired}
onToggle={(value) => handleToggle('userActivationRequired', value)}
activeText="需要"
inactiveText="关闭"
/>
<div className="flex justify-between items-center">
<span className="text-sm text-slate-600 dark:text-slate-400">数据库状态</span>
<span
className={cn(
'text-sm font-medium',
data.status.database === 'healthy' ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400',
)}
>
{data.status.database === 'healthy' ? '正常' : '异常'}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-slate-600 dark:text-slate-400">运行时间</span>
<span className="text-sm font-semibold text-slate-900 dark:text-white">
{Math.floor(data.status.uptime / 3600)} 小时
</span>
</div>
</div>
</div>
</div>
<div className={cn(styles.cardStyles.base)}>
<h3 className={cn('text-lg font-semibold mb-4', textTitle)}>🕒 最近活动</h3>
<div className="overflow-x-auto">
<table className={cn('min-w-full divide-y', theme === 'dark' ? 'divide-slate-700' : 'divide-slate-200')}>
<thead className={theme === 'dark' ? 'bg-slate-700' : 'bg-slate-50'}>
<tr>
<th className={cn('px-6 py-3 text-left text-xs font-medium uppercase tracking-wider', theme === 'dark' ? 'text-slate-300' : 'text-slate-500')}>
素材标题
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider">
上传者
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider">
上传时间
</th>
</tr>
</thead>
<tbody className={cn('divide-y', theme === 'dark' ? 'bg-slate-800 divide-slate-700' : 'bg-white divide-slate-200')}>
{data.recentActivity.map((activity) => (
<tr key={activity.id} className={theme === 'dark' ? 'hover:bg-slate-700' : 'hover:bg-slate-50'}>
<td className={cn('px-6 py-4 whitespace-nowrap text-sm', textValue)}>{activity.title}</td>
<td className={cn('px-6 py-4 whitespace-nowrap text-sm', textDesc)}>
{activity.uploadedBy.username}
</td>
<td className={cn('px-6 py-4 whitespace-nowrap text-sm', textDesc)}>
{new Date(activity.createdAt).toLocaleString('zh-CN')}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}
type SystemSettingKey = 'registrationEnabled' | 'userActivationRequired'
const OVERVIEW_CARDS: Array<{
label: string
icon: string
className: string
getValue: (data: DashboardData) => number
}> = [
{
label: '总用户数',
icon: '👥',
className: 'bg-blue-500 shadow-blue-500/40',
getValue: (data) => data.overview.totalUsers,
},
{
label: '活跃用户',
icon: '⚡',
className: 'bg-green-500 shadow-green-500/40',
getValue: (data) => data.overview.activeUsers,
},
{
label: '总素材数',
icon: '📦',
className: 'bg-purple-500 shadow-purple-500/40',
getValue: (data) => data.overview.totalMaterials,
},
{
label: '本月新用户',
icon: '🆕',
className: 'bg-yellow-500 shadow-yellow-500/40',
getValue: (data) => data.overview.newUsersThisMonth,
},
{
label: '本月新素材',
icon: '📈',
className: 'bg-indigo-500 shadow-indigo-500/40',
getValue: (data) => data.overview.newMaterialsThisMonth,
},
]
interface SystemToggleRowProps {
label: string
enabled: boolean
editable: boolean
onToggle: (value: boolean) => void
activeText: string
inactiveText: string
}
function SystemToggleRow({ label, enabled, editable, onToggle, activeText, inactiveText }: SystemToggleRowProps) {
// 需要使用主题 Hook,但这个组件在函数外部定义,暂时保留 dark: 前缀
// 可以考虑将这个组件移到函数内部或传入 theme 参数
if (!editable) {
return (
<div className="flex justify-between items-center">
<span className="text-sm text-slate-600 dark:text-slate-400">{label}</span>
<span className={cn('text-sm font-medium', enabled ? 'text-green-600 dark:text-green-400' : 'text-slate-500 dark:text-slate-400')}>
{enabled ? activeText : inactiveText}
</span>
</div>
)
}
return (
<div className="flex justify-between items-center">
<span className="text-sm text-slate-600 dark:text-slate-400">{label}</span>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={enabled}
onChange={(event) => onToggle(event.target.checked)}
/>
<div className="w-11 h-6 bg-slate-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-slate-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-slate-600 peer-checked:bg-blue-600" />
</label>
</div>
)
}

View File

@ -0,0 +1,72 @@
'use client'
import { Component, type ErrorInfo, type ReactNode } from 'react'
import { cardStyles, buttonStyles, cn } from '@/lib/ui-styles'
interface TabErrorBoundaryProps {
tabKey: string
children: ReactNode
onRetry?: () => Promise<void> | void
fallbackTitle?: string
fallbackDescription?: string
}
interface TabErrorBoundaryState {
hasError: boolean
error?: Error
}
export class TabErrorBoundary extends Component<TabErrorBoundaryProps, TabErrorBoundaryState> {
state: TabErrorBoundaryState = {
hasError: false,
error: undefined,
}
static getDerivedStateFromError(error: Error): TabErrorBoundaryState {
return { hasError: true, error }
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error(`Tab "${this.props.tabKey}" render error:`, error, info)
}
componentDidUpdate(prevProps: TabErrorBoundaryProps) {
if (prevProps.tabKey !== this.props.tabKey && this.state.hasError) {
this.reset()
}
}
reset = () => {
this.setState({ hasError: false, error: undefined })
}
handleRetry = async () => {
this.reset()
await this.props.onRetry?.()
}
render() {
if (this.state.hasError) {
return (
<div className={cn(cardStyles.base, 'text-center py-10')}>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">
{this.props.fallbackTitle || '模块加载失败'}
</h3>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-2">
{this.props.fallbackDescription || '请重试或联系管理员'}
</p>
<div className="mt-6">
<button onClick={this.handleRetry} className={cn(buttonStyles.primary)}>
重试
</button>
</div>
</div>
)
}
return this.props.children
}
}
export default TabErrorBoundary

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,537 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { useThemeStyles, useTheme, cn } from '@/lib/ui-styles'
import type { Department, DepartmentNode, DepartmentUserGrouping, User, UserTreeNode } from './types'
import { buildDepartmentTree } from '../_utils/departmentTree'
import DepartmentHierarchy from './users/DepartmentHierarchy'
import UserTree from './users/UserTree'
import { useModalState } from '../_hooks/useModalState'
import UserEditModal from './users/UserEditModal'
import TransferSuperAdminModal from './users/TransferSuperAdminModal'
interface UsersTabProps {
users: User[]
departments: Department[]
currentUserId?: string
currentUserEmail?: string
currentIsSuperAdmin?: boolean
canManageUser: (user: User) => boolean
onToggleActive: (userId: string, current: boolean) => Promise<void> | void
onToggleDownload: (userId: string, current: boolean) => Promise<void> | void
onSetRole: (userId: string, currentRole: User['role']) => Promise<void> | void
onUpdateUser: (user: User, payload: { username: string; department: string }) => Promise<boolean> | boolean
onDeleteUser: (userId: string) => Promise<boolean> | boolean
onTransferSuperAdmin: (targetEmail: string, password: string) => Promise<boolean> | boolean
}
export default function UsersTab({
users,
departments,
currentUserId,
currentUserEmail,
currentIsSuperAdmin,
canManageUser,
onToggleActive,
onToggleDownload,
onSetRole,
onUpdateUser,
onDeleteUser,
onTransferSuperAdmin,
}: UsersTabProps) {
const styles = useThemeStyles()
const theme = useTheme()
const [searchTerm, setSearchTerm] = useState('')
const [collapsedUserNodeIds, setCollapsedUserNodeIds] = useState<Set<string>>(new Set())
const [collapsedDepartments, setCollapsedDepartments] = useState<Set<string>>(new Set())
const [editingUsername, setEditingUsername] = useState('')
const [selectedDepartment, setSelectedDepartment] = useState('')
const [transferPassword, setTransferPassword] = useState('')
const editingUserModal = useModalState<User>()
const passwordModal = useModalState()
const editingUser = editingUserModal.data
// 根据主题选择颜色类名
const badgeActive = theme === 'dark' ? 'bg-green-900/30 text-green-300' : 'bg-green-100 text-green-700'
const badgeInactive = theme === 'dark' ? 'bg-rose-900/30 text-rose-300' : 'bg-rose-100 text-rose-700'
const badgeRole = theme === 'dark' ? 'bg-blue-900/30 text-blue-300' : 'bg-blue-100 text-blue-700'
const badgeNoRole = theme === 'dark' ? 'bg-slate-800 text-slate-200' : 'bg-slate-200 text-slate-700'
const textDesc = theme === 'dark' ? 'text-slate-400' : 'text-slate-500'
const textSuperAdmin = theme === 'dark' ? 'text-indigo-100 group-hover:text-indigo-200' : 'text-indigo-900 group-hover:text-indigo-700'
const textNormal = theme === 'dark' ? 'text-white group-hover:text-slate-200' : 'text-slate-900 group-hover:text-slate-700'
const userLookup = useMemo(() => {
const map = new Map<string, User>()
users.forEach((user) => map.set(user.id, user))
return map
}, [users])
const { treeRoots } = useMemo(() => {
if (!users.length) {
return { treeRoots: [] as UserTreeNode[] }
}
const childMap = new Map<string, string[]>()
users.forEach((user) => {
if (user?.id) {
childMap.set(user.id, [])
}
})
const rootIds: string[] = []
users.forEach((user) => {
if (!user?.id) return
const parentId = user.parentId
if (parentId && childMap.has(parentId)) {
childMap.get(parentId)!.push(user.id)
} else {
rootIds.push(user.id)
}
})
const compareUsers = (a: User, b: User) => {
if (a.isSuperAdmin && !b.isSuperAdmin) return -1
if (!a.isSuperAdmin && b.isSuperAdmin) return 1
if (a.role === 'ADMIN' && b.role !== 'ADMIN') return -1
if (a.role !== 'ADMIN' && b.role === 'ADMIN') return 1
const labelA = (a.username || a.email || '').toLowerCase()
const labelB = (b.username || b.email || '').toLowerCase()
return labelA.localeCompare(labelB, 'zh-CN')
}
const buildNodes = (ids: string[]): UserTreeNode[] =>
ids
.map((id) => {
const user = userLookup.get(id)
if (!user) return null
return {
user,
children: buildNodes(childMap.get(id) || []),
}
})
.filter((node): node is UserTreeNode => Boolean(node))
.sort((a, b) => compareUsers(a.user, b.user))
return { treeRoots: buildNodes(rootIds) }
}, [userLookup, users])
const toggleUserNodeCollapse = useCallback((nodeId: string) => {
setCollapsedUserNodeIds((prev) => {
const next = new Set(prev)
if (next.has(nodeId)) {
next.delete(nodeId)
} else {
next.add(nodeId)
}
return next
})
}, [])
const toggleDepartmentCollapse = useCallback((deptName: string) => {
setCollapsedDepartments((prev) => {
const next = new Set(prev)
if (next.has(deptName)) {
next.delete(deptName)
} else {
next.add(deptName)
}
return next
})
}, [])
const handleEditUser = useCallback(
(user: User) => {
editingUserModal.open(user)
setSelectedDepartment(user.department || '')
setEditingUsername(user.username || user.email || '')
},
[editingUserModal],
)
const handleCloseTransferModal = useCallback(() => {
passwordModal.close()
setTransferPassword('')
}, [passwordModal])
const handleCloseEditModal = useCallback(() => {
editingUserModal.close()
setSelectedDepartment('')
setEditingUsername('')
handleCloseTransferModal()
}, [editingUserModal, handleCloseTransferModal])
const handleSaveUser = useCallback(async () => {
if (!editingUser) return
const success = await onUpdateUser(editingUser, {
username: editingUsername,
department: selectedDepartment,
})
if (success) {
handleCloseEditModal()
}
}, [editingUser, editingUsername, onUpdateUser, selectedDepartment, handleCloseEditModal])
const handleModalRole = useCallback(async () => {
if (!editingUser) return
await onSetRole(editingUser.id, editingUser.role)
handleCloseEditModal()
}, [editingUser, onSetRole, handleCloseEditModal])
const handleModalActive = useCallback(async () => {
if (!editingUser) return
await onToggleActive(editingUser.id, editingUser.isActive)
handleCloseEditModal()
}, [editingUser, onToggleActive, handleCloseEditModal])
const handleModalDownload = useCallback(async () => {
if (!editingUser) return
await onToggleDownload(editingUser.id, editingUser.canDownload)
handleCloseEditModal()
}, [editingUser, onToggleDownload, handleCloseEditModal])
const handleDeleteUserInternal = useCallback(async () => {
if (!editingUser) return
if (!confirm('确定要删除这个用户吗?该用户的所有素材也会被删除。')) return
const success = await onDeleteUser(editingUser.id)
if (success) {
handleCloseEditModal()
}
}, [editingUser, onDeleteUser, handleCloseEditModal])
const showTransferButton = useMemo(
() => Boolean(currentIsSuperAdmin && editingUser && editingUser.email !== currentUserEmail),
[currentIsSuperAdmin, editingUser, currentUserEmail]
)
const handleOpenTransferModal = useCallback(() => {
if (!editingUser || !showTransferButton) return
setTransferPassword('')
passwordModal.open()
}, [editingUser, passwordModal, showTransferButton])
const handleConfirmTransfer = useCallback(async () => {
if (!editingUser) return
const success = await onTransferSuperAdmin(editingUser.email, transferPassword)
if (success) {
handleCloseTransferModal()
handleCloseEditModal()
}
}, [editingUser, onTransferSuperAdmin, transferPassword, handleCloseTransferModal, handleCloseEditModal])
const treeToRender = useMemo(() => {
const term = searchTerm.trim().toLowerCase()
if (!term) return treeRoots
const matchUser = (user: User) => {
const username = user.username?.toLowerCase() || ''
const email = user.email?.toLowerCase() || ''
const department = user.department?.toLowerCase() || ''
return username.includes(term) || email.includes(term) || department.includes(term)
}
const filterNodes = (nodes: UserTreeNode[]): UserTreeNode[] =>
nodes
.map((node) => {
const filteredChildren = filterNodes(node.children)
return matchUser(node.user) || filteredChildren.length
? { user: node.user, children: filteredChildren }
: null
})
.filter((node): node is UserTreeNode => Boolean(node))
return filterNodes(treeRoots)
}, [treeRoots, searchTerm])
const departmentTree = useMemo(
() => buildDepartmentTree(departments),
[departments],
)
const departmentUsersMap = useMemo(() => {
const map = new Map<string, DepartmentUserGrouping>()
const ensureEntry = (name: string) => {
if (!map.has(name)) {
map.set(name, { managers: [], regulars: [] })
}
return map.get(name)!
}
const sortUsers = (a: User, b: User) => {
if (a.isSuperAdmin && !b.isSuperAdmin) return -1
if (!a.isSuperAdmin && b.isSuperAdmin) return 1
const labelA = (a.username || a.email || '').toLowerCase()
const labelB = (b.username || b.email || '').toLowerCase()
return labelA.localeCompare(labelB, 'zh-CN')
}
users.forEach((user) => {
const deptName = (user.department && user.department.trim()) || '未分配部门'
const entry = ensureEntry(deptName)
if (user.isSuperAdmin || user.managedDepartment || user.role === 'ADMIN') {
entry.managers.push(user)
} else {
entry.regulars.push(user)
}
})
departments.forEach((dept) => ensureEntry(dept.name))
ensureEntry('未分配部门')
map.forEach((entry) => {
entry.managers.sort(sortUsers)
entry.regulars.sort(sortUsers)
})
return map
}, [users, departments])
const departmentMaterialCounts = useMemo(() => {
const counts = new Map<string, number>()
users.forEach((user) => {
const deptName = (user.department && user.department.trim()) || '未分配部门'
const current = counts.get(deptName) || 0
counts.set(deptName, current + (user._count?.materials || 0))
})
return counts
}, [users])
const hasDepartments = departments.length > 0
const editingUserDepartment = editingUser?.department ?? ''
const managerNames = useMemo(() => {
if (!editingUserDepartment) return []
return users
.filter((candidate) => candidate.managedDepartment === editingUserDepartment)
.map((manager) => manager.username)
.filter(Boolean) as string[]
}, [editingUserDepartment, users])
const renderStatusBadges = (user: User) => (
<>
<span
className={cn(
'px-2 py-0.5 text-xs rounded-full',
user.isActive
? badgeActive
: badgeInactive,
)}
>
{user.isActive ? '已激活' : '未激活'}
</span>
<span
className={cn(
'px-2 py-0.5 text-xs rounded-full',
user.canDownload
? badgeRole
: badgeNoRole,
)}
>
{user.canDownload ? '可下载' : '禁止下载'}
</span>
</>
)
const renderActionButtons = useCallback((user: User, options?: { showRoleToggle?: boolean }) => {
const allowed = canManageUser(user)
if (!allowed && user.id !== currentUserId) {
return <span className={cn('text-[11px]', textDesc)}>无权限修改</span>
}
const actionButton = (
label: string,
className: string,
onClick: () => void,
visible: boolean = true,
) => {
if (!visible) return null
return (
<button
type="button"
disabled={!allowed}
className={cn(
className,
styles.buttonModifiers.compactXs,
!allowed && 'opacity-40 cursor-not-allowed pointer-events-none',
)}
onClick={() => {
if (!allowed) return
onClick()
}}
>
{label}
</button>
)
}
return (
<div className="flex flex-wrap items-center gap-2">
{actionButton(
'编辑',
styles.buttonStyles.secondary,
() => handleEditUser(user),
)}
{actionButton(
user.isActive ? '禁用' : '激活',
user.isActive ? styles.buttonStyles.danger : styles.buttonStyles.success,
() => onToggleActive(user.id, user.isActive),
)}
{actionButton(
user.canDownload ? '禁止下载' : '允许下载',
styles.buttonStyles.secondary,
() => onToggleDownload(user.id, user.canDownload),
)}
{actionButton(
user.role === 'ADMIN' ? '取消管理员' : '设为管理员',
styles.buttonStyles.rainbow,
() => onSetRole(user.id, user.role),
options?.showRoleToggle !== false && !user.isSuperAdmin,
)}
</div>
)
}, [canManageUser, currentUserId, handleEditUser, onToggleActive, onToggleDownload, onSetRole])
const renderUserRow = useCallback(
(user: User, isManager = false) => (
<div key={user.id} className={cn(styles.cardStyles.hover, 'group flex items-start gap-3 cursor-pointer')}>
<div className="flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span
className={cn(
'transition-all duration-200 text-sm',
isManager || user.isSuperAdmin
? cn('font-semibold', textSuperAdmin)
: cn('font-medium', textNormal),
)}
>
{user.username}
</span>
{user.isSuperAdmin && (
<span className="px-2 py-0.5 bg-gradient-to-r from-purple-600 to-pink-600 text-white text-xs font-semibold rounded-full shadow-sm hover:shadow-md transition-all duration-200 hover:scale-105">
最高权限
</span>
)}
{isManager && !user.isSuperAdmin && (
<span className="px-2 py-0.5 bg-gradient-to-r from-purple-500 to-indigo-600 text-white text-xs font-semibold rounded-full shadow-sm hover:shadow-md transition-all duration-200 hover:scale-105">
管理员
</span>
)}
{renderStatusBadges(user)}
</div>
<div className={cn('text-xs', textDesc)}>素材:{user._count.materials}</div>
</div>
{renderActionButtons(user)}
</div>
),
[renderActionButtons],
)
const showSearchResults = Boolean(searchTerm.trim())
return (
<>
<div className={cn(styles.spacingStyles.section)}>
<section
className={cn(
styles.cardStyles.large,
`bg-gradient-to-br ${theme === 'dark' ? 'from-slate-900 via-slate-800 to-indigo-900' : 'from-slate-50 via-blue-50 to-indigo-50'}`,
)}
>
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="space-y-1.5">
<p className="text-sm font-semibold tracking-wide text-slate-900 dark:text-white/90">成员目录</p>
<p className="text-sm text-slate-600 dark:text-slate-300">
结合组织树与搜索框,可按姓名、邮箱或部门快速定位管理员与成员。
</p>
</div>
<div className="relative w-full md:w-[320px] lg:w-[360px]">
<input
type="text"
placeholder="搜索用户名、邮箱或部门..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className={cn(styles.inputStyles.base, 'pl-11 pr-10')}
/>
<svg className={cn('absolute left-4 top-3 w-5 h-5', theme === 'dark' ? 'text-slate-500' : 'text-slate-400')} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
{searchTerm && (
<button
type="button"
onClick={() => setSearchTerm('')}
className={cn(
styles.buttonStyles.ghost,
styles.buttonModifiers.icon,
'absolute right-2 top-2',
theme === 'dark' ? 'text-slate-400 hover:text-slate-200' : 'text-slate-500 hover:text-slate-700',
)}
aria-label="清空成员搜索"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
</section>
<section className={cn(styles.cardStyles.base, 'overflow-hidden')}>
<div className="overflow-hidden p-4">
{showSearchResults ? (
treeToRender.length > 0 ? (
<UserTree
nodes={treeToRender}
collapsedNodeIds={collapsedUserNodeIds}
onToggleUserNode={toggleUserNodeCollapse}
searchTerm={searchTerm}
renderStatusBadges={renderStatusBadges}
renderActionButtons={renderActionButtons}
/>
) : (
<div className={cn('text-center py-12', textDesc)}>暂无匹配的用户</div>
)
) : (
<DepartmentHierarchy
departmentTree={departmentTree}
departmentUsersMap={departmentUsersMap}
departmentMaterialCounts={departmentMaterialCounts}
collapsedDepartments={collapsedDepartments}
onToggleDepartmentCollapse={toggleDepartmentCollapse}
renderUserRow={renderUserRow}
totalUsers={users.length}
/>
)}
</div>
</section>
</div>
<UserEditModal
isOpen={editingUserModal.isOpen}
user={editingUser}
departments={departments}
editingUsername={editingUsername}
selectedDepartment={selectedDepartment}
managerNames={managerNames}
showTransferButton={showTransferButton}
onChangeUsername={setEditingUsername}
onChangeDepartment={setSelectedDepartment}
onClose={handleCloseEditModal}
onSave={handleSaveUser}
onToggleRole={handleModalRole}
onToggleActive={handleModalActive}
onToggleDownload={handleModalDownload}
onDelete={handleDeleteUserInternal}
onOpenTransferModal={handleOpenTransferModal}
hasDepartments={hasDepartments}
/>
<TransferSuperAdminModal
isOpen={passwordModal.isOpen}
targetUser={editingUser}
password={transferPassword}
onClose={handleCloseTransferModal}
onChangePassword={setTransferPassword}
onConfirm={handleConfirmTransfer}
/>
</>
)
}

View File

@ -0,0 +1,76 @@
'use client'
import { cardStyles, buttonStyles, buttonModifiers, cn } from '@/lib/ui-styles'
import type { BuiltinTemplateConfig } from './types'
interface BuiltInTemplateGridProps {
presets: BuiltinTemplateConfig[]
onPreview: (presetKey: string) => void
onQuickCreate: (presetKey: string) => void
}
export default function BuiltInTemplateGrid({
presets,
onPreview,
onQuickCreate,
}: BuiltInTemplateGridProps) {
return (
<div className="px-6 pt-5 pb-4 border-b border-slate-200/70 dark:border-slate-700/70">
<div className="flex items-center justify-between gap-4 mb-3">
<div>
<h3 className="text-sm font-semibold text-slate-900 dark:text-white">预制模板(推荐)</h3>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">常用场景一键启用,也可作为自定义模板的起点</p>
<p className="text-[11px] text-slate-400 dark:text-slate-500">
点击卡片仅预览示例,内容不可直接编辑,需复制为自定义模板后才可修改。
</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{presets.map((preset) => {
const fieldCount = preset.getFields().length
return (
<div
key={preset.key}
className={cn(cardStyles.hover, 'group relative overflow-hidden cursor-pointer')}
onClick={() => onPreview(preset.key)}
>
<div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-blue-500/70 via-purple-500/60 to-blue-500/70 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<p className="text-sm font-semibold text-slate-900 dark:text-white tracking-tight">{preset.name}</p>
<p className="text-xs text-slate-500 dark:text-slate-400 leading-snug">{preset.description}</p>
</div>
<span className="px-2.5 py-0.5 rounded-full text-[11px] font-medium text-blue-600 dark:text-blue-300 bg-blue-50/70 dark:bg-blue-900/30 border border-blue-100/70 dark:border-blue-800/60">
推荐
</span>
</div>
<div className="mt-4 flex items-center justify-between gap-3">
<div className="flex items-center gap-1.5 text-[11px] text-slate-400 dark:text-slate-500">
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M4 7h16M4 12h10m-10 5h7" />
</svg>
<span>{fieldCount} 个字段</span>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onQuickCreate(preset.key)
}}
className={cn(buttonStyles.success, buttonModifiers.compactXs, buttonModifiers.pill, 'inline-flex items-center gap-1.5 shadow-none')}
>
<svg className="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor">
<path d="M10 3.5a.75.75 0 01.75.75v5h5a.75.75 0 010 1.5h-5v5a.75.75 0 01-1.5 0v-5h-5a.75.75 0 010-1.5h5v-5A.75.75 0 0110 3.5z" />
</svg>
一键创建并启用
</button>
</div>
</div>
)
})}
</div>
</div>
)
}

View File

@ -0,0 +1,163 @@
'use client'
import { cardStyles, buttonStyles, buttonModifiers, cn } from '@/lib/ui-styles'
import type { DisplayTemplate } from './types'
interface CustomTemplateSectionProps {
loading: boolean
error: string | null
templates: DisplayTemplate[]
filteredTemplates: DisplayTemplate[]
deletingId: string | null
onCreate: () => void
onPreview: (template: DisplayTemplate) => void
onActivate: (id: string) => void
onEdit: (template: DisplayTemplate) => void
onDuplicate: (template: DisplayTemplate) => void
onDelete: (id: string) => void
}
export default function CustomTemplateSection({
loading,
error,
templates,
filteredTemplates,
deletingId,
onCreate,
onPreview,
onActivate,
onEdit,
onDuplicate,
onDelete,
}: CustomTemplateSectionProps) {
return (
<div className="px-6 pt-5 pb-4">
<div className="flex items-center justify-between gap-4 mb-3">
<div>
<h3 className="text-sm font-semibold text-slate-900 dark:text-white">自定义模板</h3>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">这里列出你自己创建的模板,可继续编辑、复制或删除</p>
<p className="text-[11px] text-slate-400 dark:text-slate-500">这些模板才会被真实上传面板使用,确保状态为“启用”后用户才能看到。</p>
</div>
</div>
{loading ? (
<div className="p-8 text-center text-slate-500 dark:text-slate-400">加载中...</div>
) : error ? (
<div className="p-8 text-center text-red-500 dark:text-red-300">{error}</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<button
type="button"
onClick={onCreate}
className={cn(cardStyles.flat, 'border-dashed border-2 border-slate-300 dark:border-slate-600 flex items-center justify-center gap-3')}
>
<div className="w-14 h-14 rounded-full bg-slate-100 dark:bg-slate-800 flex items-center justify-center flex-shrink-0 group-hover:bg-slate-200 dark:group-hover:bg-slate-700 transition-colors">
<svg className="w-8 h-8 text-slate-400 group-hover:text-slate-600 dark:group-hover:text-slate-200 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</div>
<div className="text-left">
<p className="text-sm font-semibold text-slate-800 dark:text-slate-200">新建自定义模板</p>
<p className="text-[11px] text-slate-500 dark:text-slate-400 mt-1 leading-snug">从空白模板起步,完全按照业务需求自由配置字段</p>
</div>
</button>
{templates.length === 0 ? (
<div className="col-span-2 p-8 text-center text-slate-500 dark:text-slate-400">暂无自定义模板,点击上方卡片开始创建。</div>
) : filteredTemplates.length === 0 ? (
<div className="col-span-2 p-8 text-center text-slate-500 dark:text-slate-400">未找到匹配的模板名称,请调整搜索关键词。</div>
) : (
filteredTemplates.map((tpl) => (
<div
key={tpl.id}
onClick={() => onPreview(tpl)}
className={cn(cardStyles.hover, 'group relative overflow-hidden cursor-pointer')}
>
<div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-purple-500/70 via-blue-500/60 to-purple-500/70 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<p className="text-sm font-semibold text-slate-900 dark:text-white tracking-tight">{tpl.name}</p>
{tpl.description && (
<p className="text-xs text-slate-500 dark:text-slate-400 leading-snug line-clamp-2">{tpl.description}</p>
)}
</div>
<div className="flex flex-col items-end gap-1 text-right">
<span
className={`px-2 py-0.5 text-xs font-medium rounded-full transition-all duration-200 ${
tpl.status === 'active'
? 'bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300'
: tpl.status === 'draft'
? 'bg-yellow-100 dark:bg-yellow-900/40 text-yellow-700 dark:text-yellow-300'
: 'bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300'
}`}
>
{tpl.statusLabel}
</span>
<span className="text-[10px] font-semibold uppercase tracking-[0.18em] text-blue-500/80 dark:text-blue-300/80 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
预览
</span>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-2 border-t border-slate-200/70 dark:border-slate-700/70 pt-3">
{tpl.status !== 'active' && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onActivate(tpl.id)
}}
className={cn(buttonStyles.success, buttonModifiers.compactXs, buttonModifiers.pill)}
>
启用
</button>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onEdit(tpl)
}}
className={cn(buttonStyles.secondary, buttonModifiers.compactXs, buttonModifiers.pill)}
>
编辑
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onDuplicate(tpl)
}}
className={cn(buttonStyles.ghost, buttonModifiers.compactXs, buttonModifiers.pill)}
>
复制
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onDelete(tpl.id)
}}
disabled={deletingId === tpl.id}
className={cn(
buttonStyles.danger,
buttonModifiers.compactXs,
buttonModifiers.pill,
deletingId === tpl.id && 'opacity-70 cursor-wait',
)}
>
{deletingId === tpl.id ? '删除中...' : '删除'}
</button>
<span className="ml-auto text-[11px] text-slate-400 dark:text-slate-500">
{tpl.updatedLabel} · v{tpl.version}
</span>
</div>
</div>
))
)}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,58 @@
'use client'
import { buttonStyles, buttonModifiers, inputStyles, cn } from '@/lib/ui-styles'
interface TemplateManagerHeaderProps {
searchTerm: string
onSearchChange: (value: string) => void
}
export default function TemplateManagerHeader({
searchTerm,
onSearchChange,
}: TemplateManagerHeaderProps) {
return (
<section className="rounded-2xl border border-slate-200/70 dark:border-slate-600/60 bg-gradient-to-br from-[#e9efff] via-[#dfe6ff] to-[#d3ddff] dark:from-[#0b1424] dark:via-[#111c32] dark:to-[#1b2740] text-slate-900 dark:text-white shadow-[0_18px_38px_rgba(15,23,42,0.16)] p-6">
<div className="flex flex-col gap-5 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-3 w-full">
<div className="space-y-1.5">
<p className="text-sm font-semibold tracking-wide text-slate-900 dark:text-white/90">模板工作台</p>
<p className="text-sm text-slate-600 dark:text-slate-300">
预制模板仅供参考和复制,自定义模板启用后才会对上传面板生效。
</p>
</div>
</div>
<div className="relative w-full md:w-[320px] lg:w-[360px]">
<input
type="text"
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="搜索模板名称..."
className={cn(inputStyles.base, 'pl-11 pr-10')}
/>
<svg className="absolute left-4 top-3 w-5 h-5 text-slate-400 dark:text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
{searchTerm && (
<button
type="button"
onClick={() => onSearchChange('')}
className={cn(
buttonStyles.ghost,
buttonModifiers.icon,
'absolute right-2 top-2 text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200',
)}
aria-label="清空模板搜索"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
</section>
)
}

View File

@ -0,0 +1,91 @@
'use client'
import { cardStyles, inputStyles, inputModifiers, cn } from '@/lib/ui-styles'
import type { TemplateForm } from '../types'
interface TemplateAdvancedConfigPanelProps {
form: TemplateForm
advancedConfigOpen: boolean
onToggle: () => void
fieldGroupsError: string | null
displayConfigError: string | null
onFieldGroupsChange: (value: string) => void
onDisplayConfigChange: (value: string) => void
onFieldGroupsErrorChange: (value: string | null) => void
onDisplayConfigErrorChange: (value: string | null) => void
}
export default function TemplateAdvancedConfigPanel({
form,
advancedConfigOpen,
onToggle,
fieldGroupsError,
displayConfigError,
onFieldGroupsChange,
onDisplayConfigChange,
onFieldGroupsErrorChange,
onDisplayConfigErrorChange,
}: TemplateAdvancedConfigPanelProps) {
return (
<div className={cn(cardStyles.base, 'p-4')}>
<button type="button" onClick={onToggle} className="w-full flex items-center justify-between text-left">
<div>
<p className="text-sm font-semibold text-slate-900 dark:text-white">高级显示 / 分组配置(选填)</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">仅在需要自定义上传布局或字段分组时填写,支持 JSON</p>
</div>
<svg
className={`w-5 h-5 text-slate-500 dark:text-slate-400 transition-transform ${advancedConfigOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{advancedConfigOpen && (
<div className="mt-4 space-y-4">
<div>
<label className="block text-xs font-medium text-slate-700 dark:text-slate-300 mb-1.5">字段分组(fieldGroups)</label>
<textarea
rows={3}
value={form.fieldGroups}
onChange={(e) => {
onFieldGroupsErrorChange(null)
onFieldGroupsChange(e.target.value)
}}
className={cn(inputStyles.base, inputModifiers.compact, 'font-mono')}
placeholder='例如:{"groups":[{"title":"核心信息","fieldKeys":["materialType","productModel"]}]}'
/>
{fieldGroupsError ? (
<p className="text-xs text-red-500 mt-1">{fieldGroupsError}</p>
) : (
<p className="text-[11px] text-slate-400 dark:text-slate-500 mt-1">留空即可使用默认展示顺序。</p>
)}
</div>
<div>
<label className="block text-xs font-medium text-slate-700 dark:text-slate-300 mb-1.5">展示配置(displayConfig)</label>
<textarea
rows={3}
value={form.displayConfig}
onChange={(e) => {
onDisplayConfigErrorChange(null)
onDisplayConfigChange(e.target.value)
}}
className={cn(inputStyles.base, inputModifiers.compact, 'font-mono')}
placeholder='例如:{"layout":"two-column","showPreview":true}'
/>
{displayConfigError ? (
<p className="text-xs text-red-500 mt-1">{displayConfigError}</p>
) : (
<p className="text-[11px] text-slate-400 dark:text-slate-500 mt-1">用于自定义上传界面元素,非必要可不填。</p>
)}
</div>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,102 @@
'use client'
import { buttonStyles, buttonModifiers, inputStyles, cn } from '@/lib/ui-styles'
import type { TemplateForm, BuiltinTemplateConfig } from '../types'
interface TemplateBasicsFormProps {
form: TemplateForm
statusOptions: Array<{ value: string; label: string }>
showPresetSelector: boolean
selectedPreset: string | null
presets: BuiltinTemplateConfig[]
onChange: (patch: Partial<TemplateForm>) => void
onSelectPreset: (presetKey: string) => void
}
export default function TemplateBasicsForm({
form,
statusOptions,
showPresetSelector,
selectedPreset,
presets,
onChange,
onSelectPreset,
}: TemplateBasicsFormProps) {
return (
<div className="space-y-5">
<div>
<label className="block text-sm font-semibold text-slate-900 dark:text-white mb-2">
模板名称 <span className="text-red-500">*</span>
</label>
<input
type="text"
value={form.name}
onChange={(e) => onChange({ name: e.target.value })}
className={cn(inputStyles.base)}
placeholder="例如:产品素材模板"
/>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1.5">给模板起一个容易识别的名称</p>
</div>
<div>
<label className="block text-sm font-semibold text-slate-900 dark:text-white mb-2">模板描述</label>
<textarea
rows={2}
value={form.description}
onChange={(e) => onChange({ description: e.target.value })}
className={cn(inputStyles.base, 'resize-none')}
placeholder="简单描述这个模板的用途"
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-900 dark:text-white mb-2">模板状态</label>
<select
value={form.status}
onChange={(e) => onChange({ status: e.target.value })}
className={cn(inputStyles.base)}
>
{statusOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1.5">草稿:仅保存,不生效;启用:用户可以使用</p>
</div>
{showPresetSelector && (
<div>
<label className="block text-sm font-semibold text-slate-900 dark:text-white mb-2">选择预设模板</label>
<div className="flex flex-wrap gap-2">
{presets.map((preset) => {
const active = selectedPreset === preset.key
return (
<button
key={preset.key}
type="button"
onClick={() => onSelectPreset(preset.key)}
className={cn(
active ? buttonStyles.primary : buttonStyles.secondary,
buttonModifiers.compactXs,
buttonModifiers.pill,
'flex items-center gap-1.5 shadow-none',
!active && 'border-dashed',
)}
>
{active && <span className="inline-block w-1.5 h-1.5 rounded-full bg-white" />}
<span>{preset.name}</span>
</button>
)
})}
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1.5">
选择一个预设模板后,下方字段会自动填充,可在此基础上继续增删或调整。
</p>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,109 @@
'use client'
import { buttonStyles, buttonModifiers, cn } from '@/lib/ui-styles'
import type { UploadTemplateDto } from '../../template-manager/types'
interface SaveMessage {
type: 'success' | 'error'
text: string
}
interface TemplateEditorFooterProps {
isPreviewMode: boolean
saving: boolean
saveMessage: SaveMessage | null
previewTemplate: UploadTemplateDto | null
onClose: () => void
onSave: () => void
onExitPreview: () => void
onCreateFromPreview: () => void
}
export default function TemplateEditorFooter({
isPreviewMode,
saving,
saveMessage,
previewTemplate,
onClose,
onSave,
onExitPreview,
onCreateFromPreview,
}: TemplateEditorFooterProps) {
return (
<div className="px-6 py-5 border-t border-slate-200/60 dark:border-slate-800/60 bg-slate-50/50 dark:bg-slate-800/30">
{saveMessage && (
<div
className={cn(
'mb-4 px-4 py-3 rounded-xl flex items-center gap-2 border',
saveMessage.type === 'success'
? 'bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300 border-green-200 dark:border-green-800'
: 'bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 border-red-200 dark:border-red-800',
)}
>
{saveMessage.type === 'success' ? (
<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>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
)}
<span className="text-sm font-medium">{saveMessage.text}</span>
</div>
)}
<div className="flex justify-end gap-3">
{isPreviewMode ? (
<>
<button
onClick={onClose}
className={cn(buttonStyles.secondary, buttonModifiers.compact)}
>
关闭
</button>
<button
onClick={previewTemplate ? onExitPreview : onCreateFromPreview}
className={cn(buttonStyles.primary, buttonModifiers.compact)}
>
{previewTemplate ? '编辑此模板' : '从此模板新建'}
</button>
</>
) : (
<>
<button
onClick={onClose}
disabled={saving}
className={cn(
buttonStyles.secondary,
buttonModifiers.compact,
saving && 'opacity-60 cursor-not-allowed',
)}
>
取消
</button>
<button
onClick={onSave}
disabled={saving}
className={cn(buttonStyles.primary, buttonModifiers.compact, 'min-w-[120px]')}
>
{saving ? (
<span className="flex items-center justify-center gap-2">
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
保存中...
</span>
) : (
'保存模板'
)}
</button>
</>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,388 @@
'use client'
import { Fragment, DragEvent } from 'react'
import { cardStyles, inputStyles, inputModifiers, cn } from '@/lib/ui-styles'
import type { FieldForm, DragHoverState } from '../../template-manager/types'
interface StandardFieldOption {
key: string
label: string
}
interface LayoutOption {
value: string
label: string
}
interface TemplateFieldsEditorProps {
fields: FieldForm[]
dragFieldIndex: number | null
dragHover: DragHoverState | null
editingFieldIndex: number | null
standardFields: StandardFieldOption[]
fieldTypes: string[]
layoutOptions: LayoutOption[]
onEditingFieldChange: (index: number | null) => void
onFieldDragStart: (event: DragEvent<HTMLElement>, index: number) => void
onFieldDragOver: (event: DragEvent<HTMLDivElement>, index: number, surface: 'editor') => void
onFieldDrop: (event: DragEvent<HTMLDivElement>) => void
onFieldDragEnd: () => void
onContainerDragOver: (event: DragEvent<HTMLDivElement>) => void
onContainerDrop: (event: DragEvent<HTMLDivElement>) => void
onContainerDragLeave: (event: DragEvent<HTMLDivElement>) => void
onMoveField: (index: number, direction: 'up' | 'down') => void
onRemoveField: (index: number) => void
onAddField: () => void
renderDropPlaceholder: (key: string) => React.ReactNode
onUpdateField: (index: number, patch: Partial<FieldForm>) => void
}
export default function TemplateFieldsEditor({
fields,
dragFieldIndex,
dragHover,
editingFieldIndex,
standardFields,
fieldTypes,
layoutOptions,
onEditingFieldChange,
onFieldDragStart,
onFieldDragOver,
onFieldDrop,
onFieldDragEnd,
onContainerDragOver,
onContainerDrop,
onContainerDragLeave,
onMoveField,
onRemoveField,
onAddField,
renderDropPlaceholder,
onUpdateField,
}: TemplateFieldsEditorProps) {
return (
<div className={cn(cardStyles.base, 'p-4 space-y-5')}>
<div>
<h4 className="text-lg font-semibold text-slate-900 dark:text-white">字段配置</h4>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">添加用户上传时需要填写的字段</p>
<p className="text-[11px] text-slate-400 dark:text-slate-500">
先选预设再按需调整,拖拽左侧卡片即可重排顺序。
</p>
</div>
<div
className="space-y-3"
onDragOver={onContainerDragOver}
onDrop={onContainerDrop}
onDragLeave={onContainerDragLeave}
>
{fields.map((field, index) => {
const isDragging = dragFieldIndex === index
const placeholderBefore =
dragFieldIndex !== null &&
dragHover &&
dragHover.index === index &&
dragHover.position === 'before'
const placeholderAfter =
dragFieldIndex !== null &&
dragHover &&
dragHover.index === index &&
dragHover.position === 'after'
const standardOption = standardFields.find((opt) => opt.key === field.key)
const selectValue = standardOption ? standardOption.key : '__custom'
return (
<Fragment key={`editor-field-${index}`}>
{placeholderBefore && renderDropPlaceholder(`editor-before-${index}`)}
<div
id={`editor-field-${index}`}
className={`group border rounded-xl p-4 transition-all duration-200 ease-out ${
editingFieldIndex === index
? 'border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 shadow-md'
: 'border-slate-200/60 dark:border-slate-700/60 bg-slate-50/50 dark:bg-slate-800/40 hover:border-slate-300 dark:hover:border-slate-600 hover:bg-white dark:hover:bg-slate-800 hover:shadow-sm'
} ${
isDragging
? 'opacity-40 scale-[0.96] cursor-grabbing border-blue-500 dark:border-blue-400 shadow-lg shadow-blue-200/50 dark:shadow-blue-900/50 transition-all duration-200'
: 'cursor-grab'
}`}
draggable
onMouseEnter={() => onEditingFieldChange(index)}
onMouseLeave={() => onEditingFieldChange(null)}
onDragStart={(event) => onFieldDragStart(event, index)}
onDragOver={(event) => onFieldDragOver(event, index, 'editor')}
onDrop={onFieldDrop}
onDragEnd={onFieldDragEnd}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 text-xs font-semibold bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300 rounded">
字段 {index + 1}
</span>
<span className="text-sm font-medium text-slate-900 dark:text-white">
{field.label || field.key || '未命名字段'}
</span>
</div>
<div className="flex items-center gap-1.5">
<button
onClick={(e) => {
e.stopPropagation()
onMoveField(index, 'up')
}}
disabled={index === 0}
className="p-1.5 text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-200 dark:hover:bg-slate-700 rounded-lg transition-all duration-200 disabled:opacity-40 disabled:cursor-not-allowed"
title="上移"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation()
onMoveField(index, 'down')
}}
disabled={index === fields.length - 1}
className="p-1.5 text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-200 dark:hover:bg-slate-700 rounded-lg transition-all duration-200 disabled:opacity-40 disabled:cursor-not-allowed"
title="下移"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation()
onRemoveField(index)
}}
className="p-1.5 text-red-500 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/30 rounded-lg transition-all duration-200"
title="删除"
>
<svg className="w-4 h-4" 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 className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-700 dark:text-slate-300 mb-1.5">
字段键名 <span className="text-red-500">*</span>
</label>
<div className="space-y-1.5">
<select
value={selectValue}
onChange={(e) => {
e.stopPropagation()
const value = e.target.value
if (value === '__custom') {
onUpdateField(index, { key: '', label: '', autoLabel: field.autoLabel })
return
}
const option = standardFields.find((item) => item.key === value)
if (!option) return
const shouldAutoLabel = field.autoLabel !== false
const patch: Partial<FieldForm> = {
key: option.key,
autoLabel: shouldAutoLabel ? true : field.autoLabel,
}
if (shouldAutoLabel) {
patch.label = option.label
}
onUpdateField(index, patch)
}}
onClick={(e) => e.stopPropagation()}
className={cn(inputStyles.base, inputModifiers.compact)}
>
<option value="__custom">自定义字段(手动输入键名)</option>
<optgroup label="标准字段">
{standardFields.map((option) => (
<option key={option.key} value={option.key}>
{option.label}({option.key})
</option>
))}
</optgroup>
</select>
{selectValue === '__custom' ? (
<input
key={`field-key-input-${index}`}
type="text"
value={field.key || ''}
onChange={(e) => {
e.stopPropagation()
onUpdateField(index, { key: e.target.value })
}}
onClick={(e) => e.stopPropagation()}
className="w-full px-3 py-2 text-sm border border-slate-300 dark:border-slate-600 dark:bg-slate-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200"
placeholder="例如:productModel"
autoComplete="off"
/>
) : (
<p className="text-[11px] text-slate-500 dark:text-slate-400">
键名:<span className="font-mono">{field.key}</span>
</p>
)}
</div>
</div>
<div>
<label className="block text-xs font-medium text-slate-700 dark:text-slate-300 mb-1.5">显示名称</label>
<input
key={`field-label-input-${index}`}
type="text"
value={field.label || ''}
onChange={(e) => {
e.stopPropagation()
onUpdateField(index, { label: e.target.value, autoLabel: false })
}}
onClick={(e) => e.stopPropagation()}
className="w-full px-3 py-2 text-sm border border-slate-300 dark:border-slate-600 dark:bg-slate-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200"
placeholder="例如:商品型号"
autoComplete="off"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-700 dark:text-slate-300 mb-1.5">字段类型</label>
<select
value={field.type}
onChange={(e) => onUpdateField(index, { type: e.target.value })}
onClick={(e) => e.stopPropagation()}
className="w-full px-3 py-2 text-sm border border-slate-300 dark:border-slate-600 dark:bg-slate-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200"
>
{fieldTypes
.filter((type) => type !== 'image' && type !== 'file')
.map((type) => (
<option key={type} value={type}>
{type === 'text'
? '文本'
: type === 'textarea'
? '多行文本'
: type === 'number'
? '数字'
: type === 'select'
? '下拉选择'
: type === 'checkbox'
? '复选框'
: type === 'radio'
? '单选'
: type === 'date'
? '日期'
: type === 'uppercase'
? '仅大写+字母'
: type}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-slate-700 dark:text-slate-300 mb-1.5">布局宽度</label>
<select
value={field.layoutWidth}
onChange={(e) => onUpdateField(index, { layoutWidth: e.target.value })}
onClick={(e) => e.stopPropagation()}
className="w-full px-3 py-2 text-sm border border-slate-300 dark:border-slate-600 dark:bg-slate-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200"
>
{layoutOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</div>
{field.type === 'select' && (
<div className="mt-3">
<label className="block text-xs font-medium text-slate-700 dark:text-slate-300 mb-1.5">
下拉选项 <span className="text-slate-500 font-normal">(必填)</span>
</label>
<textarea
rows={6}
value={field.optionsStatic || ''}
onChange={(e) => onUpdateField(index, { optionsStatic: e.target.value })}
onClick={(e) => e.stopPropagation()}
className="w-full px-3 py-2 text-sm border border-slate-300 dark:border-slate-600 dark:bg-slate-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200 resize-none font-mono"
placeholder="直接输入选项,每行一个,例如:&#10;3DMAX&#10;PSD&#10;AI&#10;CAD&#10;C4D"
/>
<div className="mt-2 p-2.5 bg-blue-50/50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<p className="text-[10px] font-semibold text-blue-700 dark:text-blue-300 mb-1.5">填写说明:</p>
<div className="text-[10px] text-blue-600 dark:text-blue-400">
<p className="mb-1.5">每行输入一个选项,例如:</p>
<div className="ml-3 font-mono bg-white dark:bg-slate-800 px-2 py-1 rounded border border-blue-200 dark:border-blue-700">
3DMAX
<br />
PSD
<br />
AI
<br />
CAD
</div>
</div>
</div>
</div>
)}
<div className="flex flex-wrap gap-3 mt-3 pt-3 border-t border-slate-200/60 dark:border-slate-700/60">
<label className="flex items-center gap-1.5 text-xs text-slate-700 dark:text-slate-300 cursor-pointer">
<input
type="checkbox"
checked={field.required}
onChange={(e) => onUpdateField(index, { required: e.target.checked })}
onClick={(e) => e.stopPropagation()}
className="w-3.5 h-3.5 rounded border-slate-300 dark:border-slate-600 text-blue-600 focus:ring-2 focus:ring-blue-500"
/>
<span>必填</span>
</label>
<label className="flex items-center gap-1.5 text-xs text-slate-700 dark:text-slate-300 cursor-pointer">
<input
type="checkbox"
checked={field.visibleOnHomeFilter}
onChange={(e) => onUpdateField(index, { visibleOnHomeFilter: e.target.checked })}
onClick={(e) => e.stopPropagation()}
className="w-3.5 h-3.5 rounded border-slate-300 dark:border-slate-600 text-blue-600 focus:ring-2 focus:ring-blue-500"
/>
<span>首页筛选</span>
</label>
<label className="flex items-center gap-1.5 text-xs text-slate-700 dark:text-slate-300 cursor-pointer">
<input
type="checkbox"
checked={field.isSearchable}
onChange={(e) => onUpdateField(index, { isSearchable: e.target.checked })}
onClick={(e) => e.stopPropagation()}
className="w-3.5 h-3.5 rounded border-slate-300 dark:border-slate-600 text-blue-600 focus:ring-2 focus:ring-blue-500"
/>
<span>可搜索</span>
</label>
</div>
</div>
{placeholderAfter && renderDropPlaceholder(`editor-after-${index}`)}
</Fragment>
)
})}
<button
onClick={onAddField}
className="w-full border-2 border-dashed border-slate-300 dark:border-slate-600 rounded-lg p-4 transition-all duration-200 hover:border-slate-400 dark:hover:border-slate-500 hover:bg-slate-50 dark:hover:bg-slate-800/50 group"
>
<div className="flex items-center justify-center gap-3">
<div className="w-14 h-14 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center flex-shrink-0 group-hover:bg-slate-200 dark:group-hover:bg-slate-600 transition-colors">
<svg className="w-7 h-7 text-slate-400 group-hover:text-slate-600 dark:group-hover:text-slate-300 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</div>
<div className="text-left">
<p className="text-xs font-medium text-slate-600 dark:text-slate-400 group-hover:text-slate-900 dark:group-hover:text-slate-200 transition-colors">
添加元素
</p>
<p className="text-[10px] text-slate-500 dark:text-slate-500 mt-0.5">点击添加新的字段配置</p>
</div>
</div>
</button>
</div>
</div>
)
}

View File

@ -0,0 +1,685 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import {
closestCenter,
DndContext,
DragEndEvent,
DragOverEvent,
DragOverlay,
DragStartEvent,
PointerSensor,
defaultDropAnimationSideEffects,
useSensor,
useSensors,
} from '@dnd-kit/core'
import { SortableContext, rectSortingStrategy, useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import type { FieldForm } from '../../template-manager/types'
type PreviewSortableItem = {
field: FieldForm
originalIndex: number
sortId: string
}
type PreviewRenderableItem =
| (PreviewSortableItem & { placeholder?: false })
| {
placeholder: true
sortId: string
layoutWidth: string
}
const getPreviewWidthStyle = (_layoutWidth?: string | null): React.CSSProperties => ({
width: '100%',
minWidth: 0,
})
const getPreviewWidthStyleValue = (layoutWidth?: string) => {
if (layoutWidth === 'full') {
return '520px'
}
return '320px'
}
interface TemplatePreviewPanelProps {
fields: FieldForm[]
isPreviewMode: boolean
editingFieldIndex: number | null
onEditingFieldChange: (index: number | null) => void
onScrollToField: (index: number) => void
onRemoveField: (index: number) => void
onFieldsReordered: (nextFields: FieldForm[], movedFieldLabel?: string) => void
}
export default function TemplatePreviewPanel({
fields,
isPreviewMode,
editingFieldIndex,
onEditingFieldChange,
onScrollToField,
onRemoveField,
onFieldsReordered,
}: TemplatePreviewPanelProps) {
const previewSensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 6,
},
}),
)
const disabledSensors = useSensors()
const previewSortableFields = useMemo<PreviewSortableItem[]>(() => {
return fields
.map((field, index) => ({
field,
originalIndex: index,
sortId: `preview-${index}-${field.id ?? field.key ?? `field-${index}`}`,
}))
.filter((item) => item.field.type !== 'image' && item.field.type !== 'file')
}, [fields])
const previewIndexLookup = useMemo(() => {
const map = new Map<string, number>()
previewSortableFields.forEach((item) => {
map.set(item.sortId, item.originalIndex)
})
return map
}, [previewSortableFields])
const previewSortableMap = useMemo(() => {
const map = new Map<string, PreviewSortableItem>()
previewSortableFields.forEach((item) => {
map.set(item.sortId, item)
})
return map
}, [previewSortableFields])
const previewDefaultOrder = useMemo(
() => previewSortableFields.map((item) => item.sortId),
[previewSortableFields],
)
const [previewOrder, setPreviewOrder] = useState<string[]>(previewDefaultOrder)
const [previewActiveId, setPreviewActiveId] = useState<string | null>(null)
const [previewOverId, setPreviewOverId] = useState<string | null>(null)
const [previewActiveRect, setPreviewActiveRect] = useState<{ width: number; height: number } | null>(null)
const [previewPlaceholderVisible, setPreviewPlaceholderVisible] = useState(false)
const [previewPlaceholderIndex, setPreviewPlaceholderIndex] = useState<number | null>(null)
const pendingPlaceholderIndexRef = useRef<number | null>(null)
useEffect(() => {
if (!previewActiveId) {
setPreviewOrder(previewDefaultOrder)
setPreviewPlaceholderIndex(null)
} else {
setPreviewOrder((currentOrder) => {
if (currentOrder.length !== previewDefaultOrder.length) {
const activeId = previewActiveId
const newOrder = previewDefaultOrder.filter((id) => id !== activeId)
const currentIndex = currentOrder.indexOf(activeId)
if (currentIndex >= 0 && currentIndex < newOrder.length) {
newOrder.splice(currentIndex, 0, activeId)
} else {
newOrder.push(activeId)
}
return newOrder
}
return currentOrder
})
}
}, [previewDefaultOrder, previewActiveId])
useEffect(() => {
if (pendingPlaceholderIndexRef.current !== null) {
const index = pendingPlaceholderIndexRef.current
pendingPlaceholderIndexRef.current = null
requestAnimationFrame(() => {
setPreviewPlaceholderIndex(index)
})
}
}, [previewOrder])
const orderedPreviewItems = useMemo(() => {
return previewOrder.map((id) => previewSortableMap.get(id)).filter((item): item is PreviewSortableItem => !!item)
}, [previewOrder, previewSortableMap])
const previewActiveItem = previewActiveId ? previewSortableMap.get(previewActiveId) : null
const previewRenderableItems = useMemo<PreviewRenderableItem[]>(() => {
let items: PreviewRenderableItem[] = orderedPreviewItems
if (previewActiveId) {
items = items.filter((item) => item.sortId !== previewActiveId)
}
if (previewPlaceholderVisible && previewPlaceholderIndex !== null && previewActiveItem) {
const placeholder: PreviewRenderableItem = {
placeholder: true,
sortId: `${previewActiveItem.sortId}-placeholder`,
layoutWidth: previewActiveItem.field.layoutWidth,
}
const insertIndex = Math.max(0, Math.min(previewPlaceholderIndex, items.length))
const cloned = [...items]
cloned.splice(insertIndex, 0, placeholder)
return cloned
}
return items
}, [orderedPreviewItems, previewActiveId, previewActiveItem, previewPlaceholderIndex, previewPlaceholderVisible])
const resetPreviewDragState = useCallback(() => {
setPreviewActiveId(null)
setPreviewOverId(null)
setPreviewActiveRect(null)
setPreviewPlaceholderVisible(false)
setPreviewPlaceholderIndex(null)
pendingPlaceholderIndexRef.current = null
}, [])
const handlePreviewDragStart = useCallback(
(event: DragStartEvent) => {
const activeId = String(event.active.id)
const initialRect = event.active.rect.current?.initial
setPreviewActiveId(activeId)
setPreviewOverId(activeId)
setPreviewPlaceholderVisible(true)
setPreviewPlaceholderIndex(previewOrder.indexOf(activeId))
setPreviewOrder((order) => {
if (order.length === previewDefaultOrder.length) return order
return previewDefaultOrder
})
if (initialRect) {
setPreviewActiveRect({
width: initialRect.width,
height: initialRect.height,
})
} else {
setPreviewActiveRect(null)
}
},
[previewDefaultOrder, previewOrder],
)
const handlePreviewDragCancel = useCallback(() => {
setPreviewOrder(previewDefaultOrder)
resetPreviewDragState()
}, [previewDefaultOrder, resetPreviewDragState])
const handlePreviewDragOver = useCallback(
(event: DragOverEvent) => {
const { over, active } = event
if (!over) {
setPreviewOverId(null)
setPreviewPlaceholderVisible(false)
return
}
const overId = String(over.id)
const activeId = String(active.id)
if (overId.includes('-placeholder')) return
if (!previewSortableMap.has(overId) || !previewSortableMap.has(activeId)) {
setPreviewOverId(null)
setPreviewPlaceholderVisible(false)
return
}
setPreviewPlaceholderVisible((prev) => (prev ? prev : true))
setPreviewOverId((prev) => (prev !== overId ? overId : prev))
setPreviewOrder((items) => {
if (!items.includes(activeId)) {
return items
}
const currentIndex = items.indexOf(activeId)
const overIndex = items.indexOf(overId)
if (currentIndex === -1 || overIndex === -1) {
return items
}
const overRect = over.rect
const overItem = previewSortableMap.get(overId)
const delta = event.delta
let mouseX = 0
let mouseY = 0
const activeRect = event.active.rect.current.translated ?? event.active.rect.current.initial
if (activeRect && delta) {
mouseX = activeRect.left + activeRect.width / 2 + delta.x
mouseY = activeRect.top + activeRect.height / 2 + delta.y
} else if (overRect) {
mouseX = overRect.left + overRect.width / 2
mouseY = overRect.top + overRect.height / 2
}
let insertionIndex = overIndex
if (overRect && mouseX > 0 && mouseY > 0) {
const overCenterX = overRect.left + overRect.width / 2
const overCenterY = overRect.top + overRect.height / 2
const relativeX = mouseX - overCenterX
const relativeY = mouseY - overCenterY
const sameRow = Math.abs(relativeY) < overRect.height * 0.65
const edgeThresholdX = overRect.width * 0.3
const edgeThresholdY = overRect.height * 0.3
if (overItem?.field.layoutWidth === 'full' || !sameRow) {
if (Math.abs(relativeY) > edgeThresholdY) {
insertionIndex = mouseY > overCenterY ? overIndex + 1 : overIndex
} else {
insertionIndex = overIndex
}
} else {
if (Math.abs(relativeX) > edgeThresholdX) {
insertionIndex = mouseX > overCenterX ? overIndex + 1 : overIndex
} else {
insertionIndex = overIndex
}
}
}
const withoutActive = items.filter((id) => id !== activeId)
if (insertionIndex > currentIndex) {
insertionIndex = Math.max(0, insertionIndex - 1)
}
insertionIndex = Math.max(0, Math.min(insertionIndex, withoutActive.length))
const nextOrder = [...withoutActive]
nextOrder.splice(insertionIndex, 0, activeId)
if (nextOrder.length === items.length && nextOrder.every((id, idx) => id === items[idx])) {
return items
}
pendingPlaceholderIndexRef.current = insertionIndex
return nextOrder
})
},
[previewSortableMap],
)
const handlePreviewDragEnd = useCallback(
({ active }: DragEndEvent) => {
const activeId = String(active.id)
const activeIndex = previewIndexLookup.get(activeId)
const targetOrderIndex = previewOrder.indexOf(activeId)
if (activeIndex === undefined || targetOrderIndex === -1 || activeIndex === targetOrderIndex) {
resetPreviewDragState()
return
}
const reorderedFields = previewOrder
.map((sortId) => {
const item = previewSortableMap.get(sortId)
return item ? fields[item.originalIndex] : null
})
.filter((field): field is FieldForm => field !== null)
onFieldsReordered(reorderedFields, fields[activeIndex]?.label || fields[activeIndex]?.key)
resetPreviewDragState()
},
[fields, onFieldsReordered, previewIndexLookup, previewOrder, previewSortableMap, resetPreviewDragState],
)
const canEdit = !isPreviewMode
return (
<div
className={`overflow-y-auto bg-slate-50/30 dark:bg-slate-900/50 transition-all duration-500 ease-in-out ${
isPreviewMode ? 'w-[520px] mx-auto animate-in zoom-in-95' : 'w-1/2 animate-in slide-in-from-right'
}`}
>
<div className={isPreviewMode ? 'p-4' : 'p-6'}>
{!isPreviewMode && (
<div className="mb-5 pb-5 border-b border-slate-200/40 dark:border-slate-800/40">
<h4 className="text-xl font-semibold text-slate-900 dark:text-white mb-1.5 tracking-tight">实时预览</h4>
<p className="text-sm text-slate-500 dark:text-slate-400 font-light">预览上传面板的实际效果</p>
<p className="text-[11px] text-slate-400 dark:text-slate-500">右侧仅供预览,不可编辑,点击字段可定位到左侧配置。</p>
</div>
)}
{isPreviewMode && (
<div className="mb-4 text-center">
<h4 className="text-lg font-semibold text-slate-900 dark:text-white mb-1">模板预览</h4>
<p className="text-xs text-slate-500 dark:text-slate-400">完整的上传面板效果</p>
</div>
)}
<div
className={
isPreviewMode
? 'border-2 border-dashed border-slate-300 dark:border-slate-600 rounded-3xl p-4 bg-slate-50/30 dark:bg-slate-800/30'
: ''
}
>
<div
className={`bg-white/95 dark:bg-slate-800/95 backdrop-blur-xl rounded-2xl shadow-lg shadow-slate-200/30 dark:shadow-slate-900/30 border border-slate-200/40 dark:border-slate-700/40 space-y-5 ${
isPreviewMode ? 'p-6 border-2 border-slate-300 dark:border-slate-600 ring-2 ring-slate-200/50 dark:ring-slate-700/50' : 'p-5'
}`}
>
<div className="relative">
<label className="block text-xs font-medium text-slate-700 dark:text-slate-300 mb-1.5">
预览图片 <span className="text-red-500">*</span>
</label>
<div className="border-2 border-dashed border-slate-300/60 dark:border-slate-600/60 rounded-2xl p-4 transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] hover:border-slate-400/80 dark:hover:border-slate-500/80 hover:bg-slate-50/50 dark:hover:bg-slate-700/30 hover:shadow-md">
<div className="flex items-center justify-center space-x-3">
<div className="w-16 h-16 rounded-xl bg-gradient-to-br from-slate-100 to-slate-50 dark:from-slate-700 dark:to-slate-800 flex items-center justify-center flex-shrink-0 shadow-inner">
<svg className="w-8 h-8 text-slate-400 dark:text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} 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="text-left flex-1 min-w-0">
<p className="text-xs font-medium text-slate-700 dark:text-slate-300">点击或拖拽上传预览图</p>
<p className="text-[10px] text-slate-500 dark:text-slate-500 mt-0.5">支持 JPG、PNG、WebP 等格式</p>
</div>
</div>
</div>
</div>
<div className="relative">
<label className="block text-xs font-medium text-slate-700 dark:text-slate-300 mb-1.5">
素材文件 <span className="text-red-500">*</span>
</label>
<div className="border-2 border-dashed border-slate-300/60 dark:border-slate-600/60 rounded-2xl p-4 transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] hover:border-slate-400/80 dark:hover:border-slate-500/80 hover:bg-slate-50/50 dark:hover:bg-slate-700/30 hover:shadow-md">
<div className="flex items-center justify-center space-x-3">
<svg className="w-8 h-8 text-slate-400 flex-shrink-0" 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>
<div className="text-left flex-1 min-w-0">
<p className="text-xs text-slate-600 dark:text-slate-400">点击或拖拽上传素材文件</p>
<p className="text-[10px] text-slate-500 dark:text-slate-500 mt-0.5">支持 ZIP、RAR 等压缩格式</p>
</div>
</div>
</div>
</div>
<DndContext
sensors={isPreviewMode ? disabledSensors : previewSensors}
collisionDetection={closestCenter}
onDragStart={isPreviewMode ? undefined : handlePreviewDragStart}
onDragOver={isPreviewMode ? undefined : handlePreviewDragOver}
onDragCancel={isPreviewMode ? undefined : handlePreviewDragCancel}
onDragEnd={isPreviewMode ? undefined : handlePreviewDragEnd}
>
<SortableContext items={previewOrder} strategy={rectSortingStrategy}>
{orderedPreviewItems.length === 0 ? (
<div className="w-full rounded-2xl border-2 border-dashed border-slate-200/60 dark:border-slate-700/60 bg-gradient-to-br from-slate-50/50 to-transparent dark:from-slate-800/30 dark:to-transparent backdrop-blur-sm py-8 text-center">
<div className="flex flex-col items-center gap-2">
<svg className="w-8 h-8 text-slate-400 dark:text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 4v16m8-8H4" />
</svg>
<p className="text-sm text-slate-500 dark:text-slate-400 font-medium">暂无自定义字段</p>
<p className="text-xs text-slate-400 dark:text-slate-500">点击左侧「添加元素」开始配置</p>
</div>
</div>
) : (
<div className="grid grid-cols-12 gap-4">
{previewRenderableItems.map((item) => {
const layoutWidth = item.placeholder ? item.layoutWidth : item.field.layoutWidth || 'full'
const widthClass = layoutWidth === 'full' ? 'col-span-12' : 'col-span-12 min-[480px]:col-span-6'
if (item.placeholder) {
return <PreviewPlaceholderCard key={item.sortId} gridClassName={widthClass} />
}
return (
<PreviewSortableCard
key={item.sortId}
item={item}
isEditing={canEdit && editingFieldIndex === item.originalIndex}
isDragging={canEdit && previewActiveId === item.sortId}
onHover={canEdit ? () => onEditingFieldChange(item.originalIndex) : () => {}}
onHoverEnd={canEdit ? () => onEditingFieldChange(null) : () => {}}
onRemove={canEdit ? () => onRemoveField(item.originalIndex) : () => {}}
onClick={canEdit ? () => onScrollToField(item.originalIndex) : () => {}}
gridClassName={widthClass}
isPreviewMode={!canEdit}
/>
)
})}
</div>
)}
</SortableContext>
{typeof window !== 'undefined' &&
createPortal(
<DragOverlay
dropAnimation={{
duration: 500,
easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
sideEffects: defaultDropAnimationSideEffects({
styles: {
active: {
opacity: '0',
transform: 'scale(0.95)',
},
},
}),
}}
style={{ cursor: 'grabbing', zIndex: 1000 }}
>
{previewActiveItem ? <PreviewDragOverlayCard item={previewActiveItem} rect={previewActiveRect} /> : null}
</DragOverlay>,
document.body,
)}
</DndContext>
</div>
</div>
</div>
</div>
)
}
type PreviewSortableCardProps = {
item: PreviewSortableItem
isEditing: boolean
isDragging: boolean
gridClassName?: string
onHover: () => void
onHoverEnd: () => void
onRemove: () => void
onClick: () => void
isPreviewMode?: boolean
}
function PreviewSortableCard({
item,
isEditing,
isDragging,
gridClassName,
onHover,
onHoverEnd,
onRemove,
onClick,
isPreviewMode = false,
}: PreviewSortableCardProps) {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
id: item.sortId,
disabled: isPreviewMode,
})
const style = {
transform: isDragging ? undefined : CSS.Transform.toString(transform),
transition: isDragging
? undefined
: transition ?? 'transform 0.5s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
zIndex: isDragging ? 30 : undefined,
opacity: isDragging ? 0.4 : 1,
}
const field = item.field
const widthStyle = getPreviewWidthStyle(item.field.layoutWidth)
const containerPointerEvents = isDragging ? 'none' : 'auto'
const cardStateClass = isEditing
? 'transform scale-[1.02] shadow-xl shadow-slate-200/50 dark:shadow-slate-900/50 border-2 border-slate-200/80 dark:border-slate-700/80 bg-white dark:bg-slate-800 rounded-2xl backdrop-blur-sm'
: 'hover:scale-[1.01] hover:shadow-lg hover:shadow-slate-200/50 dark:hover:shadow-slate-900/50 border border-slate-200/40 dark:border-slate-700/40 bg-white/95 dark:bg-slate-800/95 rounded-2xl backdrop-blur-sm'
return (
<div
ref={setNodeRef}
style={{
...style,
...widthStyle,
position: 'relative',
pointerEvents: containerPointerEvents,
}}
className={`group relative transition-all duration-500 ease-[cubic-bezier(0.34,1.56,0.64,1)] ${gridClassName ?? 'col-span-12'}`}
onMouseEnter={onHover}
onMouseLeave={onHoverEnd}
onClick={onClick}
>
<div className={`relative transition-all duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)] ${cardStateClass}`}>
{!isPreviewMode && (
<div className="absolute top-2 right-2 z-20 flex flex-col items-end gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<button
type="button"
className="w-8 h-8 rounded-full bg-white/95 dark:bg-slate-900/95 backdrop-blur-md border border-slate-200/70 dark:border-slate-700/70 shadow-lg shadow-slate-200/50 dark:shadow-slate-900/50 flex items-center justify-center cursor-grab active:cursor-grabbing hover:border-blue-400/80 dark:hover:border-blue-300/80 hover:bg-white dark:hover:bg-slate-900 hover:shadow-xl hover:scale-110 active:scale-95 transition-all duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)]"
title="拖拽调整顺序"
aria-label="拖拽调整顺序"
{...attributes}
{...listeners}
>
<svg className="w-3.5 h-3.5 text-slate-600 dark:text-slate-300" viewBox="0 0 20 20" fill="currentColor">
<path d="M7 5a1 1 0 11-2 0 1 1 0 012 0zm4 0a1 1 0 11-2 0 1 1 0 012 0zm4 0a1 1 0 11-2 0 1 1 0 012 0zM7 10a1 1 0 11-2 0 1 1 0 012 0zm4 0a1 1 0 11-2 0 1 1 0 012 0zm4 0a1 1 0 11-2 0 1 1 0 012 0zM7 15a1 1 0 11-2 0 1 1 0 012 0zm4 0a1 1 0 11-2 0 1 1 0 012 0zm4 0a1 1 0 11-2 0 1 1 0 012 0z" />
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation()
onRemove()
}}
className="w-7 h-7 bg-gradient-to-br from-red-500 to-red-600 text-white rounded-full flex items-center justify-center hover:from-red-600 hover:to-red-700 transition-all duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)] hover:scale-110 active:scale-95 shadow-md shadow-red-500/30 hover:shadow-lg hover:shadow-red-500/40"
title="删除此字段"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
)}
<PreviewFieldContent field={field} />
</div>
</div>
)
}
function PreviewFieldContent({ field }: { field: FieldForm }) {
const baseInputClass =
'w-full px-4 py-2.5 text-sm rounded-2xl outline-none transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] border bg-white/90 dark:bg-slate-800/80 dark:text-white border-slate-200/70 dark:border-slate-700/60 shadow-[inset_0_1px_0_rgba(255,255,255,0.75)] dark:shadow-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:border-blue-400/60'
const requiredInputClass = field.required
? ' bg-gradient-to-br from-white via-blue-50/90 to-blue-100/60 dark:from-slate-800/80 dark:via-blue-900/30 dark:to-blue-900/20 border-blue-200/80 dark:border-blue-500/50 shadow-[0_18px_35px_-25px_rgba(37,99,235,1)]'
: ' bg-white/90 dark:bg-slate-800/80'
const optionList = (() => {
if (!field.optionsStatic) return []
const raw = field.optionsStatic.trim()
if (!raw) return []
if (raw.startsWith('[') || raw.startsWith('{')) {
try {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) return parsed.map((item) => String(item))
} catch {
// ignore
}
}
return raw
.split('\n')
.map((opt) => opt.trim())
.filter(Boolean)
})()
return (
<div className="pointer-events-none space-y-2.5">
<div className="flex items-center justify-between gap-3">
<span className="text-xs font-semibold text-slate-800 dark:text-slate-200 tracking-tight">{field.label || field.key || '未命名字段'}</span>
{field.required && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-[0.18em] text-white bg-gradient-to-r from-purple-500 to-indigo-600 shadow-[0_8px_16px_rgba(79,70,229,0.35)]">
必填
</span>
)}
</div>
{field.type === 'text' && <input type="text" placeholder={`请输入${field.label || field.key}`} className={`${baseInputClass}${requiredInputClass}`} disabled />}
{field.type === 'textarea' && (
<textarea rows={2} placeholder={`请输入${field.label || field.key}`} className={`${baseInputClass}${requiredInputClass} resize-none`} disabled />
)}
{field.type === 'number' && <input type="number" placeholder={`请输入${field.label || field.key}`} className={`${baseInputClass}${requiredInputClass}`} disabled />}
{field.type === 'select' && (
<div className="space-y-1.5">
<select className={`${baseInputClass}${requiredInputClass}`} disabled>
<option value="">请选择{field.label || field.key}</option>
{optionList.map((opt, idx) => (
<option key={`${field.key}-opt-${idx}`} value={opt}>
{opt}
</option>
))}
</select>
{optionList.length === 0 && <p className="text-[10px] text-slate-400 dark:text-slate-500">暂无下拉选项</p>}
</div>
)}
{field.type === 'date' && <input type="date" className={`${baseInputClass}${requiredInputClass}`} disabled />}
{field.type === 'uppercase' && (
<input type="text" placeholder={`请输入${field.label || field.key}(仅大写字母)`} className={`${baseInputClass}${requiredInputClass} uppercase`} disabled />
)}
{field.type === 'checkbox' && (
<label className="flex items-center gap-2 cursor-default select-none">
<input type="checkbox" className="w-4 h-4 rounded border-slate-300 dark:border-slate-600 text-blue-600 focus:ring-2 focus:ring-blue-500" disabled />
<span className="text-xs text-slate-700 dark:text-slate-300">选择{field.label || field.key}</span>
</label>
)}
{field.type === 'radio' && (
<div className="space-y-1.5">
{optionList.map((opt, idx) => (
<label key={`${field.key}-radio-${idx}`} className="flex items-center gap-2 cursor-default select-none">
<input type="radio" name={`preview-${field.key}`} value={opt} className="w-3.5 h-3.5 text-blue-600 focus:ring-2 focus:ring-blue-500" disabled />
<span className="text-xs text-slate-700 dark:text-slate-300">{opt}</span>
</label>
))}
{optionList.length === 0 && <p className="text-[10px] text-slate-400 dark:text-slate-500">暂无单选项</p>}
</div>
)}
</div>
)
}
function PreviewDragOverlayCard({
item,
rect,
}: {
item: PreviewSortableItem
rect: { width: number; height: number } | null
}) {
const widthValue = getPreviewWidthStyleValue(item.field.layoutWidth)
return (
<div
className="pointer-events-none"
style={{
width: rect?.width ?? widthValue,
height: rect?.height,
maxWidth: '90vw',
transform: 'none',
}}
>
<div className="rounded-2xl border-2 border-blue-500/60 dark:border-blue-400/60 bg-white/98 dark:bg-slate-800/98 backdrop-blur-xl shadow-2xl shadow-blue-500/30 dark:shadow-blue-900/50 px-4 py-3 transform rotate-[1.5deg] scale-[1.08] transition-all duration-200 ease-out">
<PreviewFieldContent field={item.field} />
</div>
</div>
)
}
function PreviewPlaceholderCard({ gridClassName }: { gridClassName: string }) {
return (
<div className={gridClassName}>
<div className="h-full min-h-[88px] rounded-2xl border-2 border-dashed border-blue-500 dark:border-blue-400 bg-gradient-to-br from-blue-50/90 to-blue-100/70 dark:from-blue-900/40 dark:to-blue-800/30 flex items-center justify-center text-blue-600 dark:text-blue-300 text-xs font-semibold tracking-wide pointer-events-none shadow-xl shadow-blue-200/60 dark:shadow-blue-900/60 animate-pulse">
<div className="flex flex-col items-center gap-2 animate-bounce">
<svg className="w-6 h-6 text-blue-500 dark:text-blue-400 animate-pulse" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M12 5l7 7-7 7" />
</svg>
<span className="opacity-80 animate-pulse">松开即可放置</span>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,78 @@
export interface UploadFieldDto {
id?: string
key: string
label: string
type: string
required: boolean
orderIndex: number
layoutWidth?: string | null
validations?: any
optionsStatic?: any
allowedExtensions?: any
visibleOnHomeFilter: boolean
isSearchable: boolean
isReadOnly: boolean
conditions?: any
}
export interface UploadTemplateDto {
id: string
name: string
description: string | null
status: string
version: number
fieldGroups: any
displayConfig: any
createdAt: string
updatedAt: string
fields: UploadFieldDto[]
}
export interface FieldForm {
id?: string
key: string
label: string
type: string
required: boolean
layoutWidth: string
visibleOnHomeFilter: boolean
isSearchable: boolean
isReadOnly: boolean
validations: string
optionsStatic: string
allowedExtensions: string
conditions: string
autoLabel?: boolean
}
export interface TemplateForm {
id?: string
version?: number
name: string
description: string
status: string
fieldGroups: string
displayConfig: string
fields: FieldForm[]
}
export interface BuiltinTemplateConfig {
key: string
name: string
description: string
getFields: () => FieldForm[]
}
export interface DisplayTemplate extends UploadTemplateDto {
updatedLabel: string
fieldCount: number
statusLabel: string
}
export type DragHoverState = {
index: number
position: 'before' | 'after'
surface: 'editor'
}

View File

@ -0,0 +1,165 @@
/**
* 共享的类型定义
* 统一管理所有 admin 相关的类型,避免重复定义
*/
export interface User {
id: string
email: string
username: string
department: string
parentId: string | null
role: string
isActive: boolean
canDownload: boolean
managedDepartment?: string
isSuperAdmin?: boolean
createdAt: string
_count: {
materials: number
}
}
export interface Material {
id: string
previewUrl: string
productModel: string
brandName?: string | null
seriesName?: string | null
skuCode?: string | null
materialTexture?: string | null
categoryName: string
sceneName: string
materialType: string
renderer: string
renderTime?: string | null
imageResolution?: string | null
colorMode?: string | null
authorName: string
createdAt: string
user: {
username: string
}
}
export interface Category {
id: string
name: string
count: number
options?: string | null
}
export interface Scene {
id: string
name: string
count: number
options?: string | null
}
export interface Platform {
id: string
name: string
count: number
options?: string | null
}
export interface Invitation {
id: string
code: string
createdBy: string
usedBy: string | null
usedAt: string | null
expiresAt: string | null
maxUses: number
currentUses: number
isActive: boolean
createdAt: string
inviteUrl?: string
}
export interface Department {
id: string
name: string
}
// 树形结构类型
export type UserTreeNode = {
user: User
children: UserTreeNode[]
}
export type DepartmentNode = {
id: string
name: string
fullName: string
children: DepartmentNode[]
}
export type DepartmentUserGrouping = {
managers: User[]
regulars: User[]
}
// 工具类型
export type ToastFn = (message: string, type?: 'success' | 'error') => void
export type AdminTab = 'materials' | 'users' | 'categories' | 'templates' | 'departments' | 'invitations' | 'monitor' | 'settings'
// Session 用户类型(扩展 next-auth 的 Session.user)
export interface SessionUser {
id?: string
email?: string
name?: string
image?: string
role?: 'ADMIN' | 'USER'
isSuperAdmin?: boolean
}
// API 响应类型
export interface ApiErrorResponse {
error: string
}
export interface ApiSuccessResponse<T = unknown> {
success?: boolean
data?: T
[key: string]: unknown
}
// Dashboard 数据类型
export interface DashboardData {
overview: {
totalUsers: number
activeUsers: number
totalMaterials: number
newUsersThisMonth: number
newMaterialsThisMonth: number
}
trends: {
userGrowth: Array<{ date: string; count: number }>
materialUploads: Array<{ date: string; count: number }>
}
distribution: {
departments: Array<{ department: string; count: number }>
roles: Array<{ role: string; isSuperAdmin: boolean; count: number }>
}
systemInfo: {
registrationEnabled: boolean
userActivationRequired: boolean
siteName: string
}
recentActivity: Array<{
id: string
title: string
createdAt: string
uploadedBy: {
username: string
email: string
}
}>
status: {
database: string
timestamp: string
uptime: number
}
}

View File

@ -0,0 +1,20 @@
'use client'
import { useTheme, cn } from '@/lib/ui-styles'
interface EmptyStateProps {
message?: string
className?: string
}
export default function EmptyState({ message = '暂无数据', className }: EmptyStateProps) {
const theme = useTheme()
const textColor = theme === 'dark' ? 'text-slate-400' : 'text-slate-500'
return (
<div className={cn('text-center py-12', textColor, className)}>
{message}
</div>
)
}

View File

@ -0,0 +1,45 @@
'use client'
import { useThemeStyles, useTheme, cn } from '@/lib/ui-styles'
interface ErrorStateProps {
message: string
onRetry?: () => void
retryLabel?: string
className?: string
}
export default function ErrorState({
message,
onRetry,
retryLabel = '重试',
className,
}: ErrorStateProps) {
const styles = useThemeStyles()
const theme = useTheme()
// 根据主题选择错误状态的颜色
const errorBg = theme === 'dark' ? 'bg-red-900/30' : 'bg-red-50'
const errorBorder = theme === 'dark' ? 'border-red-800' : 'border-red-200'
const errorText = theme === 'dark' ? 'text-red-300' : 'text-red-700'
return (
<div className={cn('text-center py-12', className)}>
<div
className={cn(
styles.cardStyles.base,
`${errorBg} ${errorBorder} ${errorText} px-4 py-3`,
)}
>
<strong className="font-bold">错误:</strong>
<span className="block sm:inline">{message}</span>
</div>
{onRetry && (
<button onClick={onRetry} className={cn(styles.buttonStyles.primary, 'mt-4')}>
{retryLabel}
</button>
)}
</div>
)
}

View File

@ -0,0 +1,38 @@
'use client'
import { useThemeStyles, useTheme, cn } from '@/lib/ui-styles'
interface LoadingStateProps {
message?: string
className?: string
size?: 'sm' | 'md' | 'lg'
}
export default function LoadingState({ message = '加载中...', className, size = 'md' }: LoadingStateProps) {
const styles = useThemeStyles()
const theme = useTheme()
const sizeClasses = {
sm: 'h-8 w-8 border-b-2',
md: 'h-12 w-12 border-b-2',
lg: 'h-16 w-16 border-b-2',
}
// 根据主题选择边框和文字颜色
const borderColor = theme === 'dark' ? 'border-indigo-400' : 'border-indigo-600'
const textColor = theme === 'dark' ? 'text-slate-400' : 'text-slate-600'
return (
<div className={cn(styles.cardStyles.base, 'flex items-center justify-center py-12', className)}>
<div className="text-center">
<div
className={cn(
`animate-spin rounded-full ${borderColor} mx-auto`,
sizeClasses[size],
)}
/>
{message && <p className={cn('mt-4', textColor)}>{message}</p>}
</div>
</div>
)
}

View File

@ -0,0 +1,264 @@
'use client'
import { memo, useMemo } from 'react'
import type { DepartmentNode, DepartmentUserGrouping, User } from '../types'
import { cardStyles, cn } from '@/lib/ui-styles'
interface DepartmentHierarchyProps {
departmentTree: DepartmentNode[]
departmentUsersMap: Map<string, DepartmentUserGrouping>
departmentMaterialCounts: Map<string, number>
collapsedDepartments: Set<string>
onToggleDepartmentCollapse: (name: string) => void
renderUserRow: (user: User, isManager?: boolean) => JSX.Element
totalUsers: number
}
function DepartmentHierarchyComponent({
departmentTree,
departmentUsersMap,
departmentMaterialCounts,
collapsedDepartments,
onToggleDepartmentCollapse,
renderUserRow,
totalUsers,
}: DepartmentHierarchyProps) {
const sections = useMemo(() => {
const visitedDepartments = new Set<string>()
const renderDepartmentUsers = (entry?: DepartmentUserGrouping) => {
const managerCount = entry?.managers.length ?? 0
const regularCount = entry?.regulars.length ?? 0
const total = managerCount + regularCount
if (!total) {
return (
<div
className={cn(
cardStyles.flat,
'px-4 py-2 border-dashed border-2 text-xs text-slate-500 dark:text-slate-400',
)}
>
暂无成员
</div>
)
}
return (
<div className="space-y-3">
{managerCount > 0 && (
<div className="space-y-1">
<div className="text-xs font-semibold text-indigo-600 dark:text-indigo-300">管理员</div>
<div className="space-y-1">{entry!.managers.map((user) => renderUserRow(user, true))}</div>
</div>
)}
{regularCount > 0 && (
<div className="space-y-1">
<div className="text-xs font-semibold text-slate-500 dark:text-slate-400">成员</div>
<div className="space-y-1">{entry!.regulars.map((user) => renderUserRow(user))}</div>
</div>
)}
</div>
)
}
const renderDepartmentBranch = (node: DepartmentNode, depth = 0): JSX.Element | null => {
visitedDepartments.add(node.fullName)
const entry = departmentUsersMap.get(node.fullName)
const memberCount = (entry?.managers.length ?? 0) + (entry?.regulars.length ?? 0)
const materialCount = departmentMaterialCounts.get(node.fullName) || 0
const managerNames = (entry?.managers || []).map((manager) => manager.username).filter(Boolean)
const children = node.children
.map((child) => renderDepartmentBranch(child, depth + 1))
.filter(Boolean) as JSX.Element[]
const isCollapsed = collapsedDepartments.has(node.fullName)
const canToggle = memberCount > 0 || children.length > 0
const depthLevel = Math.min(depth, 2)
const depthContainerClasses =
depthLevel === 0
? 'bg-indigo-50/90 dark:bg-indigo-900/50 border-indigo-400 dark:border-indigo-400'
: depthLevel === 1
? 'bg-indigo-50/70 dark:bg-indigo-900/30 border-indigo-300 dark:border-indigo-500/80'
: 'bg-indigo-50/50 dark:bg-indigo-900/20 border-indigo-200 dark:border-indigo-700/80'
return (
<div key={node.fullName} className="space-y-2">
<div
className={cn(
'group flex items-center justify-between px-4 py-2.5 border-l-[3px] rounded-xl shadow-[0_1px_3px_rgba(15,23,42,0.06)] dark:shadow-[0_1px_3px_rgba(15,23,42,0.55)] transition-all duration-300 ease-out hover:shadow-md hover:scale-[1.01]',
depthContainerClasses,
)}
onClick={() => {
if (!canToggle) return
onToggleDepartmentCollapse(node.fullName)
}}
style={{ marginLeft: depth * 12 }}
>
<div className="flex items-center gap-3 min-w-0 flex-1">
{canToggle ? (
<button
type="button"
className="flex-shrink-0 w-6 h-6 flex items-center justify-center rounded hover:bg-indigo-200/80 dark:hover:bg-indigo-700/80 transition-all duration-200 hover:scale-110"
title={isCollapsed ? '展开' : '折叠'}
onClick={(e) => {
e.stopPropagation()
onToggleDepartmentCollapse(node.fullName)
}}
>
<svg
className={cn(
'w-4 h-4 transform transition-transform duration-300 ease-out text-indigo-600 dark:text-indigo-300',
!isCollapsed && 'rotate-90',
)}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
) : (
<span className="flex-shrink-0 w-6 h-6" />
)}
<div className="flex flex-col min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-slate-900 dark:text-white">{node.fullName}</span>
{!!managerNames.length && (
<div className="flex items-center gap-1.5 flex-wrap text-xs text-slate-500 dark:text-slate-400">
<span>管理员:</span>
{managerNames.map((name) => (
<span
key={name}
className="px-2 py-0.5 bg-gradient-to-r from-purple-500 to-indigo-600 text-white text-xs font-semibold rounded-full shadow-sm hover:shadow-md transition-all duration-200 hover:scale-105"
>
{name}
</span>
))}
</div>
)}
</div>
<div className="flex items-center gap-3 mt-0.5 text-xs text-slate-500 dark:text-slate-400">
<span>成员:{memberCount}</span>
<span>素材:{materialCount}</span>
</div>
</div>
</div>
</div>
{!isCollapsed && (
<div className="ml-6 space-y-2 animate-in fade-in slide-in-from-top-2 duration-300">
{renderDepartmentUsers(entry)}
{children}
</div>
)}
</div>
)
}
const sections: JSX.Element[] = []
const rootCollapsed = collapsedDepartments.has('ROOT')
const departmentBranches = departmentTree
.map((node) => {
visitedDepartments.add(node.fullName)
return renderDepartmentBranch(node)
})
.filter(Boolean) as JSX.Element[]
sections.push(
<div key="user-tree-root" className="space-y-2">
<div className="group flex items-center justify-between px-4 py-2.5 bg-gradient-to-r from-indigo-50/90 to-purple-50/90 dark:from-indigo-900/50 dark:to-purple-900/50 border-l-4 border-indigo-500 dark:border-indigo-400 rounded-xl shadow-sm hover:shadow-md transition-all duration-300 ease-out hover:scale-[1.01]">
<div className="flex items-center gap-3">
<button
type="button"
className="flex-shrink-0 w-7 h-7 flex items-center justify-center rounded-xl hover:bg-indigo-200/80 dark:hover:bg-indigo-700/80 transition-all duration-200 hover:scale-110 active:scale-95"
title={rootCollapsed ? '展开' : '折叠'}
onClick={() => onToggleDepartmentCollapse('ROOT')}
>
<svg
className={cn(
'w-5 h-5 transform transition-transform duration-200 text-indigo-600 dark:text-indigo-300',
!rootCollapsed && 'rotate-90',
)}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
<div className="flex flex-col">
<span className="text-base font-semibold text-slate-900 dark:text-white">主公司</span>
<span className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">一级分支:{departmentTree.length}</span>
</div>
</div>
<span className="text-xs text-slate-500 dark:text-slate-400">成员总数:{totalUsers}</span>
</div>
{!rootCollapsed && (
<div className="mt-2 space-y-2 ml-6 animate-in fade-in slide-in-from-top-2 duration-300">
{departmentBranches.length ? (
departmentBranches
) : (
<div className="text-xs text-slate-500 dark:text-slate-400 px-4 py-2">暂无分支,请先在架构管理中创建分支</div>
)}
</div>
)}
</div>,
)
const orphanNames = Array.from(departmentUsersMap.keys()).filter(
(name) => name !== '未分配部门' && !visitedDepartments.has(name),
)
orphanNames.forEach((name) => {
const entry = departmentUsersMap.get(name)
const total = (entry?.managers.length ?? 0) + (entry?.regulars.length ?? 0)
if (!total) return
sections.push(
<div key={`orphan-${name}`} className="space-y-2">
<div className="px-4 py-2 bg-amber-50 dark:bg-amber-900/30 border-l-4 border-amber-400 dark:border-amber-500 rounded-lg">
<div className="text-sm font-semibold text-amber-800 dark:text-amber-200">{name}(未在架构中)</div>
</div>
<div className="ml-6">{renderDepartmentUsers(entry)}</div>
</div>,
)
})
const unassignedEntry = departmentUsersMap.get('未分配部门')
const unassignedCount = (unassignedEntry?.managers.length ?? 0) + (unassignedEntry?.regulars.length ?? 0)
if (unassignedCount > 0) {
sections.push(
<div key="unassigned" className="space-y-2">
<div className="px-4 py-2 bg-slate-100 dark:bg-slate-800 border-l-4 border-slate-400 dark:border-slate-600 rounded-lg">
<div className="text-sm font-semibold text-slate-800 dark:text-slate-100">未分配部门</div>
</div>
<div className="ml-6">{renderDepartmentUsers(unassignedEntry)}</div>
</div>,
)
}
if (!sections.length) {
sections.push(
<div key="empty-users" className="text-center py-12 text-slate-500 dark:text-slate-400">
暂无用户
</div>,
)
}
return sections
}, [
collapsedDepartments,
departmentMaterialCounts,
departmentTree,
departmentUsersMap,
onToggleDepartmentCollapse,
renderUserRow,
totalUsers,
])
return <div className="space-y-2">{sections}</div>
}
export const DepartmentHierarchy = memo(DepartmentHierarchyComponent)
export default DepartmentHierarchy

View File

@ -0,0 +1,64 @@
'use client'
import type { User } from '../types'
import { buttonStyles, cn, inputStyles } from '@/lib/ui-styles'
interface TransferSuperAdminModalProps {
isOpen: boolean
targetUser: User | null
password: string
onClose: () => void
onChangePassword: (value: string) => void
onConfirm: () => void
}
export default function TransferSuperAdminModal({
isOpen,
targetUser,
password,
onClose,
onChangePassword,
onConfirm,
}: TransferSuperAdminModalProps) {
if (!isOpen || !targetUser) return null
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl max-w-md w-full border border-slate-200/70 dark:border-slate-700/80">
<div className="px-6 py-4 border-b border-slate-200 dark:border-slate-700">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">转移最高权限</h3>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">请输入您的密码以确认此操作</p>
</div>
<div className="p-6 space-y-4">
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3 text-sm text-amber-800 dark:text-amber-100">
<strong>警告:</strong>转移给 <strong>{targetUser.username}</strong>({targetUser.email})后,当前账号将失去最高权限。
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">密码</label>
<input
type="password"
value={password}
onChange={(e) => onChangePassword(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
onConfirm()
}
}}
className={inputStyles.base}
placeholder="请输入您的密码"
/>
</div>
</div>
<div className="px-6 py-4 border-t border-slate-200 dark:border-slate-700 flex justify-end gap-3">
<button type="button" onClick={onClose} className={cn(buttonStyles.ghost)}>
取消
</button>
<button type="button" onClick={onConfirm} className={cn(buttonStyles.primary)}>
确认转移
</button>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,203 @@
'use client'
import type { Department, User } from '../types'
import { buttonModifiers, buttonStyles, cn, inputStyles } from '@/lib/ui-styles'
import { formatDateTime } from '../../_utils/formatters'
interface UserEditModalProps {
isOpen: boolean
user: User | null
departments: Department[]
editingUsername: string
selectedDepartment: string
managerNames: string[]
showTransferButton: boolean
onChangeUsername: (value: string) => void
onChangeDepartment: (value: string) => void
onClose: () => void
onSave: () => void
onToggleRole: () => void
onToggleActive: () => void
onToggleDownload: () => void
onDelete: () => void
onOpenTransferModal: () => void
hasDepartments: boolean
}
export default function UserEditModal({
isOpen,
user,
departments,
editingUsername,
selectedDepartment,
managerNames,
showTransferButton,
onChangeUsername,
onChangeDepartment,
onClose,
onSave,
onToggleRole,
onToggleActive,
onToggleDownload,
onDelete,
onOpenTransferModal,
hasDepartments,
}: UserEditModalProps) {
if (!isOpen || !user) return null
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto border border-slate-200/70 dark:border-slate-700/80">
<div className="px-6 py-4 border-b border-slate-200 dark:border-slate-700">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">编辑用户</h3>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1 break-all">{user.email}</p>
</div>
<button
type="button"
className={cn(buttonStyles.ghost, buttonModifiers.icon)}
aria-label="关闭编辑用户"
onClick={onClose}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div className="p-6 space-y-5">
<section className="grid gap-4 md:grid-cols-3">
<div>
<p className="text-sm text-slate-500 dark:text-slate-400">注册时间</p>
<p className="text-sm font-medium text-slate-900 dark:text-white">
{formatDateTime(user.createdAt)}
</p>
</div>
<div className="md:col-span-2">
<p className="text-sm text-slate-500 dark:text-slate-400">直属上级</p>
<p className="text-sm font-medium text-slate-900 dark:text-white">
{managerNames.length ? managerNames.join('、') : '暂无'}
</p>
</div>
</section>
<section className="space-y-2">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">用户名</label>
<input
type="text"
value={editingUsername}
onChange={(e) => onChangeUsername(e.target.value)}
className={inputStyles.base}
placeholder="请输入用户名"
/>
</section>
<section className="space-y-2">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">选择部门</label>
<select
value={selectedDepartment}
onChange={(e) => onChangeDepartment(e.target.value)}
className={cn(inputStyles.base, 'pr-10')}
>
<option value="">-- 未分配部门 --</option>
{departments.map((dept) => (
<option key={dept.id} value={dept.name}>
{dept.name}
</option>
))}
</select>
<p className="text-xs text-slate-500 dark:text-slate-400">
更改部门后将自动为其匹配该部门的管理员作为直属上级。
</p>
</section>
<section className="space-y-3">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">角色</label>
<button
type="button"
onClick={onToggleRole}
className={cn(
buttonStyles.secondary,
'w-full justify-center',
user.role === 'ADMIN' && 'bg-purple-600 text-white hover:bg-purple-700',
)}
>
{user.role === 'ADMIN' ? '管理员' : '设为管理员'}
</button>
</section>
<section className="grid grid-cols-1 md:grid-cols-2 gap-3">
<button
type="button"
onClick={onToggleActive}
className={cn(
buttonStyles.secondary,
user.isActive
? 'bg-rose-100 dark:bg-rose-900/30 text-rose-700 dark:text-rose-300 hover:bg-rose-200 dark:hover:bg-rose-900/50'
: 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 hover:bg-emerald-200 dark:hover:bg-emerald-900/50',
)}
>
{user.isActive ? '禁用用户' : '激活用户'}
</button>
<button
type="button"
onClick={onToggleDownload}
className={cn(
buttonStyles.secondary,
user.canDownload
? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 hover:bg-amber-200 dark:hover:bg-amber-900/50'
: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 hover:bg-blue-200 dark:hover:bg-blue-900/50',
)}
>
{user.canDownload ? '禁止下载' : '允许下载'}
</button>
</section>
{showTransferButton && (
<section className="space-y-2 border-t border-slate-200 dark:border-slate-700 pt-4">
<button
type="button"
onClick={onOpenTransferModal}
className={cn(buttonStyles.primary, 'w-full justify-center gap-2')}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
转移最高权限
</button>
<p className="text-xs text-slate-500 dark:text-slate-400">仅当前最高权限持有者可执行此操作。</p>
</section>
)}
<section className="space-y-2 border-t border-slate-200 dark:border-slate-700 pt-4">
<button
type="button"
onClick={onDelete}
className={cn(buttonStyles.danger, 'w-full justify-center')}
>
删除用户
</button>
</section>
{!hasDepartments && (
<div className="text-sm text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
⚠️ 暂无部门,请先在系统设置中添加部门。
</div>
)}
</div>
<div className="px-6 py-4 border-t border-slate-200 dark:border-slate-700 flex justify-end gap-3">
<button type="button" onClick={onClose} className={cn(buttonStyles.ghost)}>
取消
</button>
<button type="button" onClick={onSave} className={cn(buttonStyles.primary)}>
保存
</button>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,84 @@
'use client'
import { memo, useCallback } from 'react'
import type { User, UserTreeNode } from '../types'
import { cardStyles, cn } from '@/lib/ui-styles'
interface UserTreeProps {
nodes: UserTreeNode[]
collapsedNodeIds: Set<string>
searchTerm: string
onToggleUserNode: (id: string) => void
renderStatusBadges: (user: User) => JSX.Element
renderActionButtons: (user: User) => JSX.Element
}
function UserTreeComponent({
nodes,
collapsedNodeIds,
searchTerm,
onToggleUserNode,
renderStatusBadges,
renderActionButtons,
}: UserTreeProps) {
const renderNodes = useCallback(
(tree: UserTreeNode[], depth = 0): JSX.Element[] =>
tree.map((node) => {
const { user } = node
const hasChildren = node.children.length > 0
const collapsed = searchTerm.trim() ? false : collapsedNodeIds.has(user.id)
return (
<div key={user.id} className="first:border-t-0">
<div
className={cn(cardStyles.hover, 'flex items-start gap-3 group')}
style={{ paddingLeft: `${depth * 24 + 16}px` }}
>
{hasChildren ? (
<button
type="button"
onClick={() => onToggleUserNode(user.id)}
className="flex-shrink-0 w-6 h-6 flex items-center justify-center rounded hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors"
title={collapsed ? '展开' : '折叠'}
>
<svg
className={cn('w-4 h-4 transform transition-transform', !collapsed && 'rotate-90')}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
) : (
<span className="flex-shrink-0 w-6 h-6" />
)}
<div className="flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-slate-900 dark:text-white">{user.username}</span>
{renderStatusBadges(user)}
</div>
<div className="text-xs text-slate-500 dark:text-slate-400">素材:{user._count.materials}</div>
</div>
{renderActionButtons(user)}
</div>
{hasChildren && !collapsed && <div>{renderNodes(node.children, depth + 1)}</div>}
</div>
)
}),
[collapsedNodeIds, onToggleUserNode, renderActionButtons, renderStatusBadges, searchTerm],
)
if (!nodes.length) {
return <div className="text-center py-12 text-slate-500 dark:text-slate-400">暂无用户</div>
}
return <div className="divide-y divide-slate-200 dark:divide-slate-800">{renderNodes(nodes)}</div>
}
export const UserTree = memo(UserTreeComponent)
export default UserTree

View File

@ -0,0 +1,116 @@
'use client'
import { useCallback, useState } from 'react'
import type { DashboardData, ToastFn } from '../_components/types'
type SystemSettingKey = 'registrationEnabled' | 'userActivationRequired'
interface UseDashboardDataOptions {
showToast?: ToastFn
}
interface RefreshOptions {
background?: boolean
}
export function useDashboardData({ showToast }: UseDashboardDataOptions = {}) {
const [data, setData] = useState<DashboardData | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [lastUpdated, setLastUpdated] = useState<number | null>(null)
const [refreshing, setRefreshing] = useState(false)
const refresh = useCallback(
async (options: RefreshOptions = {}) => {
const { background = false } = options
if (!background) {
setLoading(true)
}
setRefreshing(true)
setError(null)
try {
const response = await fetch('/api/admin/system-status', { credentials: 'include' })
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const payload: DashboardData = await response.json()
setData(payload)
setLastUpdated(Date.now())
} catch (err) {
console.error('Failed to fetch dashboard data:', err)
const message = err instanceof Error ? err.message : '获取数据失败'
setError(message)
} finally {
if (!background) {
setLoading(false)
}
setRefreshing(false)
}
},
[],
)
const updateSystemSetting = useCallback(
async (key: SystemSettingKey, value: boolean) => {
let previousValue: boolean | undefined
setData((prev) => {
if (!prev) return prev
previousValue = prev.systemInfo[key]
return {
...prev,
systemInfo: {
...prev.systemInfo,
[key]: value,
},
}
})
try {
const body =
key === 'registrationEnabled'
? { enableRegistration: value }
: { enableUserActivation: value }
const response = await fetch('/api/system-settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!response.ok) {
throw new Error('更新失败')
}
showToast?.('设置已更新')
} catch (err) {
console.error('Update system setting error:', err)
setData((prev) => {
if (!prev) return prev
return {
...prev,
systemInfo: {
...prev.systemInfo,
[key]: previousValue ?? prev.systemInfo[key],
},
}
})
showToast?.('更新失败', 'error')
throw err
}
},
[showToast],
)
return {
data,
loading,
error,
refresh,
updateSystemSetting,
lastUpdated,
refreshing,
}
}
export type UseDashboardDataReturn = ReturnType<typeof useDashboardData>

View File

@ -0,0 +1,81 @@
'use client'
import { useCallback } from 'react'
import type { AdminTab, Department, ToastFn, User } from '../_components/types'
import type { Dispatch, SetStateAction } from 'react'
import { parseErrorResponse } from '../_utils/api'
interface UseDataFetchOptions {
showToast: ToastFn
departmentCollator: Intl.Collator
setUsers: Dispatch<SetStateAction<User[]>>
setDepartments: Dispatch<SetStateAction<Department[]>>
}
export function useDataFetch({
showToast,
departmentCollator,
setUsers,
setDepartments,
}: UseDataFetchOptions) {
const loadUsersData = useCallback(async () => {
try {
const response = await fetch('/api/admin/users?pageSize=1000')
if (response.ok) {
const data = await response.json()
setUsers(data.users || [])
} else {
const errorData = await parseErrorResponse(response)
console.error('Error fetching users:', errorData)
showToast(errorData.error || '获取用户列表失败', 'error')
}
} catch (error) {
console.error('Error fetching users:', error)
showToast('获取用户列表失败', 'error')
}
}, [setUsers, showToast])
const fetchDepartments = useCallback(async () => {
try {
const response = await fetch('/api/departments')
if (response.ok) {
const data = await response.json()
const sorted = Array.isArray(data)
? [...data].sort((a, b) => departmentCollator.compare(a.name, b.name))
: []
setDepartments(sorted)
} else {
const errorData = await parseErrorResponse(response)
showToast(errorData.error || '获取部门列表失败', 'error')
}
} catch (error) {
console.error('Error fetching departments:', error)
showToast('获取部门列表失败', 'error')
}
}, [departmentCollator, setDepartments, showToast])
const fetchTabData = useCallback(
async (tab: AdminTab) => {
switch (tab) {
case 'users':
await Promise.all([loadUsersData(), fetchDepartments()])
break
case 'departments':
await fetchDepartments()
break
default:
break
}
},
[fetchDepartments, loadUsersData],
)
return {
loadUsersData,
fetchDepartments,
fetchTabData,
}
}
export type UseDataFetchReturn = ReturnType<typeof useDataFetch>

View File

@ -0,0 +1,139 @@
/**
* 部门操作 Hook
* 统一管理部门相关的操作(创建、删除、重命名等)
*/
'use client'
import { useCallback } from 'react'
import type { Department } from '../_components/types'
import { parseErrorResponse } from '../_utils/api'
import { validateDepartmentName } from '../_utils/validators'
import type { ToastFn } from '../_components/types'
interface UseDepartmentOperationsOptions {
departments: Department[]
setDepartments: React.Dispatch<React.SetStateAction<Department[]>>
showToast: ToastFn
fetchDepartments: () => Promise<void>
loadUsersData: () => Promise<void>
}
export function useDepartmentOperations({
departments,
setDepartments,
showToast,
fetchDepartments,
loadUsersData,
}: UseDepartmentOperationsOptions) {
const createDepartment = useCallback(
async (name: string) => {
const nameValidation = validateDepartmentName(name)
if (!nameValidation.valid) {
showToast(nameValidation.error || '部门名称不能为空', 'error')
return false
}
const trimmed = name.trim()
try {
const response = await fetch('/api/departments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: trimmed }),
})
if (response.ok) {
await fetchDepartments()
showToast('部门添加成功')
return true
}
const errorData = await parseErrorResponse(response)
showToast(errorData.error || '添加失败', 'error')
return false
} catch (error) {
console.error('Add department error:', error)
showToast('添加失败,请稍后重试', 'error')
return false
}
},
[fetchDepartments, showToast]
)
const deleteDepartment = useCallback(
async (id: string, name: string) => {
try {
const response = await fetch(`/api/departments?id=${id}`, {
method: 'DELETE',
})
if (response.ok) {
const result = await response.json().catch(() => ({})) as { message?: string }
await Promise.all([fetchDepartments(), loadUsersData()])
showToast(result.message || '部门删除成功')
return true
}
const errorData = await parseErrorResponse(response)
showToast(errorData.error || '删除失败', 'error')
return false
} catch (error) {
console.error('Delete department error:', error)
showToast('删除失败,请稍后重试', 'error')
return false
}
},
[fetchDepartments, loadUsersData, showToast]
)
const renameDepartment = useCallback(
async (deptId: string, newName: string) => {
const nameValidation = validateDepartmentName(newName)
if (!nameValidation.valid) {
showToast(nameValidation.error || '部门名称不能为空', 'error')
return false
}
if (!deptId) {
showToast('部门ID不能为空', 'error')
return false
}
const trimmed = newName.trim()
try {
const response = await fetch('/api/departments', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ id: deptId, name: trimmed }),
})
if (response.ok) {
await fetchDepartments()
showToast('部门重命名成功')
return true
}
const errorData = await parseErrorResponse(response)
showToast(errorData.error || '重命名失败', 'error')
return false
} catch (error) {
console.error('Error renaming department:', error)
showToast('重命名失败', 'error')
return false
}
},
[fetchDepartments, showToast]
)
return {
createDepartment,
deleteDepartment,
renameDepartment,
}
}
export type UseDepartmentOperationsReturn = ReturnType<typeof useDepartmentOperations>

View File

@ -0,0 +1,29 @@
'use client'
import { useCallback, useState } from 'react'
export function useModalState<T = void>() {
const [isOpen, setIsOpen] = useState(false)
const [data, setData] = useState<T | null>(null)
const open = useCallback((payload?: T) => {
setData((payload ?? null) as T | null)
setIsOpen(true)
}, [])
const close = useCallback(() => {
setIsOpen(false)
setData(null)
}, [])
return {
isOpen,
data,
open,
close,
setData,
}
}
export type UseModalStateReturn<T> = ReturnType<typeof useModalState<T>>

View File

@ -0,0 +1,92 @@
'use client'
import { useCallback, useMemo } from 'react'
import { buildDepartmentDescendants, isDepartmentWithinScope } from '@/lib/departments'
import type { Department, User } from '../_components/types'
interface UsePermissionsOptions {
sessionUser?: Record<string, any> | null
users: User[]
departments: Department[]
}
export default function usePermissions({
sessionUser,
users,
departments,
}: UsePermissionsOptions) {
const currentUserId = sessionUser?.id as string | undefined
const currentUserRecord = useMemo(
() => (currentUserId ? users.find((user) => user.id === currentUserId) : undefined),
[currentUserId, users],
)
const currentIsSuperAdmin = useMemo(
() => Boolean(currentUserRecord?.isSuperAdmin ?? sessionUser?.isSuperAdmin),
[currentUserRecord, sessionUser],
)
const currentRole = (sessionUser?.role ?? currentUserRecord?.role) as
| 'ADMIN'
| 'USER'
| undefined
const currentIsAdminRole = useMemo(
() => currentIsSuperAdmin || currentRole === 'ADMIN' || currentUserRecord?.role === 'ADMIN',
[currentIsSuperAdmin, currentRole, currentUserRecord?.role],
)
const currentManagedDepartment = useMemo(
() => currentUserRecord?.managedDepartment ?? sessionUser?.managedDepartment,
[currentUserRecord?.managedDepartment, sessionUser?.managedDepartment],
)
const currentDepartmentScope = useMemo(() => {
const fallback = (currentUserRecord?.department || '').trim() || undefined
return currentManagedDepartment || fallback
}, [currentManagedDepartment, currentUserRecord?.department])
const departmentDescendantsMap = useMemo(
() => buildDepartmentDescendants(departments),
[departments],
)
const canManageUser = useCallback(
(target: User) => {
if (!currentUserId) return false
if (currentIsSuperAdmin) return true
if (target.isSuperAdmin) return false
const targetDept = (target.department || '').trim()
if (!targetDept && currentIsAdminRole) {
return true
}
if (currentDepartmentScope) {
return isDepartmentWithinScope(
currentDepartmentScope,
target.department,
departmentDescendantsMap,
)
}
return target.id === currentUserId
},
[
currentDepartmentScope,
currentIsAdminRole,
currentIsSuperAdmin,
currentUserId,
departmentDescendantsMap,
],
)
return {
currentUserId,
currentIsSuperAdmin,
canManageUser,
}
}

View File

@ -0,0 +1,65 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
export type ToastType = 'success' | 'error'
export interface ToastState {
id: number
message: string
type: ToastType
}
interface UseToastOptions {
duration?: number
}
export function useToast(options: UseToastOptions = {}) {
const { duration = 3000 } = options
const [toast, setToast] = useState<ToastState | null>(null)
const timerRef = useRef<NodeJS.Timeout | null>(null)
const idRef = useRef(0)
const dismissToast = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
setToast(null)
}, [])
useEffect(() => {
if (!toast) return undefined
if (timerRef.current) {
clearTimeout(timerRef.current)
}
timerRef.current = setTimeout(() => {
setToast(null)
timerRef.current = null
}, duration)
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
}
}, [toast, duration])
const showToast = useCallback((message: string, type: ToastType = 'success') => {
idRef.current += 1
setToast({
id: idRef.current,
message,
type,
})
}, [])
return {
toast,
showToast,
dismissToast,
}
}
export type UseToastReturn = ReturnType<typeof useToast>

View File

@ -0,0 +1,264 @@
/**
* 用户操作 Hook
* 统一管理用户相关的操作(更新、删除、切换状态等)
*/
'use client'
import { useCallback } from 'react'
import type { User, Department, ApiSuccessResponse } from '../_components/types'
import { parseErrorResponse } from '../_utils/api'
import type { ToastFn } from '../_components/types'
interface UseUserOperationsOptions {
users: User[]
setUsers: React.Dispatch<React.SetStateAction<User[]>>
showToast: ToastFn
fetchData: () => Promise<void>
findDepartmentManagers: (departmentName: string | null | undefined) => User[] | null
}
export function useUserOperations({
users,
setUsers,
showToast,
fetchData,
findDepartmentManagers,
}: UseUserOperationsOptions) {
const updateUser = useCallback(
async (user: User, payload: { username: string; department: string }) => {
const { username, department } = payload
const usernameChanged = username !== user.username
const departmentChanged = department !== (user.department || '')
if (!usernameChanged && !departmentChanged) {
showToast('未做任何修改', 'error')
return false
}
try {
let materialsUpdated = false
let parentIdToSet: string | null = null
if (departmentChanged && department) {
const managers = findDepartmentManagers(department) || []
if (managers.length > 0) {
const firstManager = managers.find((manager) => manager.id !== user.id) || managers[0]
parentIdToSet = firstManager.id
}
}
if (usernameChanged || departmentChanged) {
const response = await fetch('/api/admin/users/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
userId: user.id,
username,
department,
}),
})
if (!response.ok) {
const errorData = await parseErrorResponse(response)
showToast(errorData.error || '更新失败', 'error')
return false
}
const successData = (await response.json()) as ApiSuccessResponse<{ materialsUpdated?: boolean }>
materialsUpdated = Boolean(successData.materialsUpdated)
if (departmentChanged && parentIdToSet) {
const hierarchyResponse = await fetch('/api/admin/users/update-hierarchy', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
userId: user.id,
parentId: parentIdToSet,
}),
})
if (!hierarchyResponse.ok) {
const errorData = await parseErrorResponse(hierarchyResponse)
showToast(errorData.error || '调整上下级失败', 'error')
return false
}
}
}
await fetchData()
showToast(materialsUpdated ? '用户信息已更新,素材信息已同步' : '用户信息已更新')
return true
} catch (error) {
console.error('Update user error:', error)
showToast('更新失败,请稍后重试', 'error')
return false
}
},
[showToast, fetchData, findDepartmentManagers]
)
const deleteUser = useCallback(
async (id: string) => {
try {
const response = await fetch(`/api/admin/users?id=${id}`, {
method: 'DELETE',
})
if (response.ok) {
setUsers((prevUsers) => prevUsers.filter((u) => u.id !== id))
showToast('删除成功')
return true
}
const errorData = await parseErrorResponse(response)
showToast(errorData.error || '删除失败', 'error')
return false
} catch (error) {
console.error('Error deleting user:', error)
showToast('删除失败', 'error')
return false
}
},
[setUsers, showToast]
)
const toggleDownload = useCallback(
async (userId: string, currentCanDownload: boolean) => {
const action = currentCanDownload ? '关闭' : '开启'
try {
const response = await fetch('/api/admin/users/toggle-download', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ userId }),
})
if (response.ok) {
// 直接更新本地状态,避免重新加载整个列表
setUsers((prevUsers) =>
prevUsers.map((user) =>
user.id === userId ? { ...user, canDownload: !currentCanDownload } : user
)
)
showToast(`下载权限已${action}`)
} else {
const errorData = await parseErrorResponse(response)
showToast(errorData.error || '操作失败', 'error')
}
} catch (error) {
console.error('Error toggling download permission:', error)
showToast('操作失败', 'error')
}
},
[setUsers, showToast]
)
const toggleActive = useCallback(
async (userId: string, currentStatus: boolean) => {
const action = currentStatus ? '禁用' : '激活'
try {
const response = await fetch('/api/admin/users/toggle-active', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
userId,
isActive: !currentStatus,
}),
})
if (response.ok) {
// 直接更新本地状态
setUsers((prevUsers) =>
prevUsers.map((user) => (user.id === userId ? { ...user, isActive: !currentStatus } : user))
)
showToast(`${action}成功`)
} else {
const errorData = await parseErrorResponse(response)
showToast(errorData.error || `${action}失败`, 'error')
}
} catch (error) {
console.error('Toggle active error:', error)
showToast(`${action}失败`, 'error')
}
},
[setUsers, showToast]
)
const setRole = useCallback(
async (userId: string, currentRole: string) => {
const newRole = currentRole === 'ADMIN' ? 'USER' : 'ADMIN'
const action = newRole === 'ADMIN' ? '设置为管理员' : '取消管理员权限'
try {
const response = await fetch('/api/admin/users/set-role', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
userId,
role: newRole,
}),
})
if (response.ok) {
// 刷新数据,用户树会同步更新最新的管理员信息
await fetchData()
showToast(`${action}成功`)
} else {
const errorData = await parseErrorResponse(response)
showToast(errorData.error || `${action}失败`, 'error')
}
} catch (error) {
console.error('Set role error:', error)
showToast(`${action}失败`, 'error')
}
},
[showToast, fetchData]
)
const transferSuperAdmin = useCallback(
async (targetEmail: string, password: string) => {
try {
const res = await fetch('/api/admin/transfer-superadmin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetEmail, password }),
})
if (res.ok) {
showToast('最高权限已转移')
await fetchData()
return true
}
const errorData = await parseErrorResponse(res)
showToast(errorData.error || '操作失败', 'error')
return false
} catch (err) {
console.error('transfer super admin error:', err)
showToast('操作失败,请稍后重试', 'error')
return false
}
},
[showToast, fetchData]
)
return {
updateUser,
deleteUser,
toggleDownload,
toggleActive,
setRole,
transferSuperAdmin,
}
}
export type UseUserOperationsReturn = ReturnType<typeof useUserOperations>

94
app/admin/_utils/api.ts Normal file
View File

@ -0,0 +1,94 @@
/**
* API 工具函数
* 统一处理 API 请求和错误响应
*/
import type { ApiErrorResponse, ApiSuccessResponse } from '../_components/types'
/**
* 安全地解析 API 错误响应
* @param response - fetch Response 对象
* @returns Promise<ApiErrorResponse>
*/
export async function parseErrorResponse(response: Response): Promise<ApiErrorResponse> {
try {
const data = await response.json()
return (data as ApiErrorResponse) || { error: '请求失败' }
} catch {
// 如果响应不是 JSON,返回默认错误
return { error: `HTTP ${response.status}: ${response.statusText || '请求失败'}` }
}
}
/**
* 安全地解析 API 成功响应
* @param response - fetch Response 对象
* @returns Promise<T>
*/
export async function parseSuccessResponse<T = unknown>(response: Response): Promise<T> {
const data = await response.json()
return data as T
}
/**
* 统一的 API 请求函数,自动处理错误
* @param url - API 端点
* @param options - fetch 选项
* @returns Promise<{ success: true; data: T } | { success: false; error: string }>
*/
export async function apiRequest<T = unknown>(
url: string,
options?: RequestInit
): Promise<{ success: true; data: T } | { success: false; error: string }> {
try {
const response = await fetch(url, options)
if (!response.ok) {
const errorData = await parseErrorResponse(response)
return {
success: false,
error: errorData.error || `HTTP ${response.status}: ${response.statusText || '请求失败'}`,
}
}
const data = await parseSuccessResponse<T>(response)
return {
success: true,
data,
}
} catch (error) {
console.error('API request error:', error)
const errorMessage = error instanceof Error ? error.message : '网络请求失败,请稍后重试'
return {
success: false,
error: errorMessage,
}
}
}
/**
* 获取错误消息的辅助函数
* @param error - 错误对象或错误响应
* @param defaultMessage - 默认错误消息
* @returns 错误消息字符串
*/
export function getErrorMessage(
error: unknown,
defaultMessage: string = '操作失败'
): string {
if (typeof error === 'string') {
return error
}
if (error instanceof Error) {
return error.message || defaultMessage
}
if (error && typeof error === 'object' && 'error' in error) {
const apiError = error as ApiErrorResponse
return apiError.error || defaultMessage
}
return defaultMessage
}

View File

@ -0,0 +1,79 @@
import { getDepartmentCollator } from '@/lib/departments'
import type { Department, DepartmentNode } from '../_components/types'
const cloneNode = (node: DepartmentNode, children: DepartmentNode[] = []) => ({
...node,
children,
})
export const buildDepartmentTree = (
departments: Department[] = [],
collator: Intl.Collator = getDepartmentCollator(),
): DepartmentNode[] => {
if (!departments.length) return []
const nodeMap = new Map<string, DepartmentNode>()
departments.forEach((dept) => {
nodeMap.set(dept.name, {
id: dept.id,
name: dept.name,
fullName: dept.name,
children: [],
})
})
const roots: DepartmentNode[] = []
departments.forEach((dept) => {
const node = nodeMap.get(dept.name)
if (!node) {
return
}
const lastDashIndex = dept.name.lastIndexOf('-')
if (lastDashIndex === -1) {
roots.push(node)
} else {
const parentName = dept.name.substring(0, lastDashIndex)
const parentNode = nodeMap.get(parentName)
if (parentNode) {
parentNode.children.push(node)
} else {
roots.push(node)
}
}
})
const sortNodes = (nodes: DepartmentNode[]) => {
nodes.sort((a, b) => collator.compare(a.fullName, b.fullName))
nodes.forEach((child) => sortNodes(child.children))
}
sortNodes(roots)
return roots
}
export const filterDepartmentTree = (
tree: DepartmentNode[],
term: string,
): DepartmentNode[] => {
const normalized = term.trim().toLowerCase()
if (!normalized) return tree
const filterNodes = (nodes: DepartmentNode[]): DepartmentNode[] =>
nodes
.map((node) => {
const matchesSelf =
node.name.toLowerCase().includes(normalized) ||
node.fullName.toLowerCase().includes(normalized)
const filteredChildren = filterNodes(node.children)
return matchesSelf || filteredChildren.length
? cloneNode(node, filteredChildren)
: null
})
.filter((node): node is DepartmentNode => Boolean(node))
return filterNodes(tree)
}

View File

@ -0,0 +1,177 @@
/**
* 格式化工具函数
* 统一管理各种格式化逻辑(日期、时间、数字等)
*/
import { format } from 'date-fns'
/**
* 格式化日期时间(完整格式)
* @param date - 日期对象或时间戳字符串
* @param formatStr - 格式化字符串,默认为 'yyyy-MM-dd HH:mm:ss'
* @returns 格式化后的日期时间字符串
*/
export function formatDateTime(date: Date | string | number, formatStr: string = 'yyyy-MM-dd HH:mm:ss'): string {
try {
const dateObj = typeof date === 'string' || typeof date === 'number' ? new Date(date) : date
if (isNaN(dateObj.getTime())) {
return '无效日期'
}
return format(dateObj, formatStr)
} catch (error) {
console.error('Date formatting error:', error)
return '无效日期'
}
}
/**
* 格式化日期(仅日期部分)
* @param date - 日期对象或时间戳字符串
* @returns 格式化后的日期字符串(yyyy-MM-dd)
*/
export function formatDate(date: Date | string | number): string {
return formatDateTime(date, 'yyyy-MM-dd')
}
/**
* 格式化时间(仅时间部分)
* @param date - 日期对象或时间戳字符串
* @returns 格式化后的时间字符串(HH:mm:ss)
*/
export function formatTime(date: Date | string | number): string {
return formatDateTime(date, 'HH:mm:ss')
}
/**
* 格式化日期时间(简短格式,不含秒)
* @param date - 日期对象或时间戳字符串
* @returns 格式化后的日期时间字符串(yyyy-MM-dd HH:mm)
*/
export function formatDateTimeShort(date: Date | string | number): string {
return formatDateTime(date, 'yyyy-MM-dd HH:mm')
}
/**
* 使用本地化格式格式化日期时间
* @param date - 日期对象或时间戳字符串
* @param locale - 语言环境,默认为 'zh-CN'
* @param options - Intl.DateTimeFormat 选项
* @returns 格式化后的日期时间字符串
*/
export function formatDateTimeLocale(
date: Date | string | number,
locale: string = 'zh-CN',
options?: Intl.DateTimeFormatOptions
): string {
try {
const dateObj = typeof date === 'string' || typeof date === 'number' ? new Date(date) : date
if (isNaN(dateObj.getTime())) {
return '无效日期'
}
const defaultOptions: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}
return new Intl.DateTimeFormat(locale, options || defaultOptions).format(dateObj)
} catch (error) {
console.error('Date formatting error:', error)
return '无效日期'
}
}
/**
* 格式化相对时间(如:2小时前、3天前)
* @param date - 日期对象或时间戳字符串
* @returns 相对时间字符串
*/
export function formatRelativeTime(date: Date | string | number): string {
try {
const dateObj = typeof date === 'string' || typeof date === 'number' ? new Date(date) : date
if (isNaN(dateObj.getTime())) {
return '无效日期'
}
const now = new Date()
const diffMs = now.getTime() - dateObj.getTime()
const diffSeconds = Math.floor(diffMs / 1000)
const diffMinutes = Math.floor(diffSeconds / 60)
const diffHours = Math.floor(diffMinutes / 60)
const diffDays = Math.floor(diffHours / 24)
if (diffSeconds < 60) {
return '刚刚'
} else if (diffMinutes < 60) {
return `${diffMinutes}分钟前`
} else if (diffHours < 24) {
return `${diffHours}小时前`
} else if (diffDays < 7) {
return `${diffDays}天前`
} else {
return formatDateTimeShort(dateObj)
}
} catch (error) {
console.error('Relative time formatting error:', error)
return '无效日期'
}
}
/**
* 格式化文件大小
* @param bytes - 字节数
* @param decimals - 小数位数,默认为 2
* @returns 格式化后的文件大小字符串
*/
export function formatFileSize(bytes: number, decimals: number = 2): string {
if (bytes === 0) return '0 Bytes'
const k = 1024
const dm = decimals < 0 ? 0 : decimals
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
}
/**
* 格式化数字(添加千位分隔符)
* @param num - 数字
* @param decimals - 小数位数,默认为 0
* @returns 格式化后的数字字符串
*/
export function formatNumber(num: number, decimals: number = 0): string {
try {
return num.toLocaleString('zh-CN', {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
})
} catch (error) {
console.error('Number formatting error:', error)
return String(num)
}
}
/**
* 格式化百分比
* @param value - 数值(0-1 或 0-100)
* @param isDecimal - 是否为小数格式(0-1),默认为 false(0-100)
* @param decimals - 小数位数,默认为 1
* @returns 格式化后的百分比字符串
*/
export function formatPercent(value: number, isDecimal: boolean = false, decimals: number = 1): string {
try {
const percent = isDecimal ? value * 100 : value
return `${percent.toFixed(decimals)}%`
} catch (error) {
console.error('Percent formatting error:', error)
return '0%'
}
}

View File

@ -0,0 +1,170 @@
/**
* 表单验证工具函数
* 统一管理各种表单验证逻辑
*/
/**
* 验证结果接口
*/
export interface ValidationResult {
valid: boolean
error?: string
}
/**
* 验证字符串是否非空(去除空白字符后)
* @param value - 要验证的值
* @param fieldName - 字段名称,用于错误消息
* @returns 验证结果
*/
export function validateNotEmpty(value: string, fieldName: string = '字段'): ValidationResult {
const trimmed = value.trim()
if (!trimmed) {
return {
valid: false,
error: `${fieldName}不能为空`,
}
}
return { valid: true }
}
/**
* 验证部门名称
* @param name - 部门名称
* @returns 验证结果
*/
export function validateDepartmentName(name: string): ValidationResult {
return validateNotEmpty(name, '部门名称')
}
/**
* 验证用户邮箱
* @param email - 邮箱地址
* @returns 验证结果
*/
export function validateEmail(email: string): ValidationResult {
if (!email) {
return {
valid: false,
error: '邮箱不能为空',
}
}
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailPattern.test(email)) {
return {
valid: false,
error: '请输入有效的邮箱地址',
}
}
return { valid: true }
}
/**
* 验证字段键名(必须符合标识符规则)
* @param key - 字段键名
* @param index - 字段索引(可选,用于错误消息)
* @returns 验证结果
*/
export function validateFieldKey(key: string, index?: number): ValidationResult {
const trimmed = key.trim()
if (!trimmed) {
return {
valid: false,
error: index !== undefined ? `字段 ${index + 1} 的键名不能为空` : '字段键名不能为空',
}
}
// 键名必须以字母开头,只能包含字母、数字和下划线
const keyPattern = /^[a-zA-Z][a-zA-Z0-9_]*$/
if (!keyPattern.test(trimmed)) {
return {
valid: false,
error:
index !== undefined
? `字段 ${index + 1} 的键名需以字母开头,并仅包含字母、数字或下划线`
: '字段键名需以字母开头,并仅包含字母、数字或下划线',
}
}
return { valid: true }
}
/**
* 验证字段键名是否唯一
* @param keys - 所有字段键名数组
* @param currentIndex - 当前字段索引(可选)
* @returns 验证结果,如果发现重复则返回第一个重复的索引
*/
export function validateUniqueKeys(keys: string[], currentIndex?: number): ValidationResult {
const seenKeys = new Set<string>()
for (let i = 0; i < keys.length; i++) {
const trimmedKey = keys[i].trim()
if (!trimmedKey) {
continue // 空键名由 validateFieldKey 处理
}
if (seenKeys.has(trimmedKey)) {
return {
valid: false,
error: `字段键名 "${trimmedKey}" 已重复,请重新命名`,
}
}
seenKeys.add(trimmedKey)
}
return { valid: true }
}
/**
* 验证模板名称
* @param name - 模板名称
* @returns 验证结果
*/
export function validateTemplateName(name: string): ValidationResult {
return validateNotEmpty(name, '模板名称')
}
/**
* 验证密码(可选)
* @param password - 密码
* @param minLength - 最小长度,默认 0(不验证长度)
* @returns 验证结果
*/
export function validatePassword(password: string, minLength: number = 0): ValidationResult {
if (!password) {
return {
valid: false,
error: '密码不能为空',
}
}
if (minLength > 0 && password.length < minLength) {
return {
valid: false,
error: `密码长度至少为 ${minLength} 个字符`,
}
}
return { valid: true }
}
/**
* 组合多个验证结果
* @param results - 验证结果数组
* @returns 第一个失败的验证结果,或成功结果
*/
export function combineValidationResults(...results: ValidationResult[]): ValidationResult {
for (const result of results) {
if (!result.valid) {
return result
}
}
return { valid: true }
}

View File

@ -3,6 +3,7 @@
import React, { useState, useEffect } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { cardStyles, buttonStyles, spacingStyles, cn } from '@/lib/ui-styles'
interface DashboardData {
overview: {
@ -128,10 +129,10 @@ export default function SystemMonitor() {
if (status === 'loading' || loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">加载系统监控数据...</p>
<p className="mt-4 text-slate-600">加载系统监控数据...</p>
</div>
</div>
)
@ -139,7 +140,7 @@ export default function SystemMonitor() {
if (error) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 flex items-center justify-center">
<div className="text-center">
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
<strong className="font-bold">错误:</strong>
@ -158,8 +159,8 @@ export default function SystemMonitor() {
if (!dashboardData) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<p className="text-gray-600">暂无数据</p>
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 flex items-center justify-center">
<p className="text-slate-600 dark:text-slate-300">暂无数据</p>
</div>
)
}
@ -167,14 +168,14 @@ export default function SystemMonitor() {
const { overview, trends, distribution, systemInfo, recentActivity, status: systemStatus } = dashboardData
return (
<div className="min-h-screen bg-gray-50">
<div className="min-h-screen bg-slate-50 dark:bg-slate-950">
{/* 头部 */}
<div className="bg-white shadow-sm border-b">
<div className="bg-white/90 dark:bg-slate-900/90 backdrop-blur-sm shadow-sm border-b border-slate-200/70 dark:border-slate-800/70">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center py-6">
<div>
<h1 className="text-3xl font-bold text-gray-900">📊 系统监控仪表板</h1>
<p className="mt-1 text-sm text-gray-500">
<h1 className="text-3xl font-bold text-slate-900 dark:text-white">📊 系统监控仪表板</h1>
<p className="mt-1 text-sm text-slate-600 dark:text-slate-300">
{systemInfo.siteName} - 实时系统状态监控
</p>
</div>
@ -182,11 +183,14 @@ export default function SystemMonitor() {
<button
onClick={handleRefresh}
disabled={loading}
className="bg-blue-600 hover:bg-blue-700 disabled:bg-blue-300 text-white font-medium py-2 px-4 rounded-md"
className={cn(
buttonStyles.primary,
'py-2 px-4 rounded-xl disabled:opacity-60 disabled:cursor-not-allowed'
)}
>
{loading ? '刷新中...' : '🔄 刷新'}
</button>
<div className="text-sm text-gray-500">
<div className="text-sm text-slate-500 dark:text-slate-400">
最后更新:{formatTimestamp(systemStatus.timestamp)}
</div>
</div>
@ -194,93 +198,93 @@ export default function SystemMonitor() {
</div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
{/* 系统概览卡片 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6 mb-8">
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className={cn(cardStyles.base, 'overflow-hidden')}>
<div className="p-5 space-y-2">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center">
<div className="w-8 h-8 bg-blue-500/90 dark:bg-blue-500 rounded-full flex items-center justify-center shadow-sm shadow-blue-500/40">
<span className="text-white font-medium">👥</span>
</div>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">总用户数</dt>
<dd className="text-lg font-medium text-gray-900">{overview.totalUsers}</dd>
<dt className="text-sm font-medium text-slate-500 dark:text-slate-400 truncate">总用户数</dt>
<dd className="text-lg font-semibold text-slate-900 dark:text-white">{overview.totalUsers}</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className={cn(cardStyles.base, 'overflow-hidden')}>
<div className="p-5 space-y-2">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-green-500 rounded-full flex items-center justify-center">
<div className="w-8 h-8 bg-green-500/90 dark:bg-green-500 rounded-full flex items-center justify-center shadow-sm shadow-green-500/40">
<span className="text-white font-medium">⚡</span>
</div>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">活跃用户</dt>
<dd className="text-lg font-medium text-gray-900">{overview.activeUsers}</dd>
<dt className="text-sm font-medium text-slate-500 dark:text-slate-400 truncate">活跃用户</dt>
<dd className="text-lg font-semibold text-slate-900 dark:text-white">{overview.activeUsers}</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className={cn(cardStyles.base, 'overflow-hidden')}>
<div className="p-5 space-y-2">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-purple-500 rounded-full flex items-center justify-center">
<div className="w-8 h-8 bg-purple-500/90 dark:bg-purple-500 rounded-full flex items-center justify-center shadow-sm shadow-purple-500/40">
<span className="text-white font-medium">📦</span>
</div>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">总素材数</dt>
<dd className="text-lg font-medium text-gray-900">{overview.totalMaterials}</dd>
<dt className="text-sm font-medium text-slate-500 dark:text-slate-400 truncate">总素材数</dt>
<dd className="text-lg font-semibold text-slate-900 dark:text-white">{overview.totalMaterials}</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className={cn(cardStyles.base, 'overflow-hidden')}>
<div className="p-5 space-y-2">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-yellow-500 rounded-full flex items-center justify-center">
<div className="w-8 h-8 bg-yellow-500/90 dark:bg-yellow-400 rounded-full flex items-center justify-center shadow-sm shadow-yellow-500/40">
<span className="text-white font-medium">🆕</span>
</div>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">本月新用户</dt>
<dd className="text-lg font-medium text-gray-900">{overview.newUsersThisMonth}</dd>
<dt className="text-sm font-medium text-slate-500 dark:text-slate-400 truncate">本月新用户</dt>
<dd className="text-lg font-semibold text-slate-900 dark:text-white">{overview.newUsersThisMonth}</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className={cn(cardStyles.base, 'overflow-hidden')}>
<div className="p-5 space-y-2">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-indigo-500 rounded-full flex items-center justify-center">
<div className="w-8 h-8 bg-indigo-500/90 dark:bg-indigo-500 rounded-full flex items-center justify-center shadow-sm shadow-indigo-500/40">
<span className="text-white font-medium">📈</span>
</div>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">本月新素材</dt>
<dd className="text-lg font-medium text-gray-900">{overview.newMaterialsThisMonth}</dd>
<dt className="text-sm font-medium text-slate-500 dark:text-slate-400 truncate">本月新素材</dt>
<dd className="text-lg font-semibold text-slate-900 dark:text-white">{overview.newMaterialsThisMonth}</dd>
</dl>
</div>
</div>
@ -290,21 +294,21 @@ export default function SystemMonitor() {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
{/* 用户增长趋势 */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">📈 用户增长趋势(最近30天)</h3>
<div className={cn(cardStyles.base, 'p-6')}>
<h3 className="text-lg font-medium text-slate-900 dark:text-white mb-4">📈 用户增长趋势(最近30天)</h3>
<div className="h-64 overflow-y-auto">
<div className="space-y-2">
{trends.userGrowth.map((item, index) => (
<div key={index} className="flex justify-between items-center py-2 border-b border-gray-100">
<span className="text-sm text-gray-600">{formatDate(item.date)}</span>
<div key={index} className="flex justify-between items-center py-2 border-b border-slate-100 dark:border-slate-800/80">
<span className="text-sm text-slate-600 dark:text-slate-300">{formatDate(item.date)}</span>
<div className="flex items-center">
<div className="w-32 bg-gray-200 rounded-full h-2 mr-3">
<div className="w-32 bg-slate-200 dark:bg-slate-700 rounded-full h-2 mr-3">
<div
className="bg-blue-600 h-2 rounded-full"
className="bg-blue-600 dark:bg-blue-500 h-2 rounded-full"
style={{ width: `${Math.min(100, (item.count / Math.max(...trends.userGrowth.map(d => d.count))) * 100)}%` }}
></div>
</div>
<span className="text-sm font-medium text-gray-900">{item.count}</span>
<span className="text-sm font-medium text-slate-900 dark:text-white">{item.count}</span>
</div>
</div>
))}
@ -313,21 +317,21 @@ export default function SystemMonitor() {
</div>
{/* 素材上传趋势 */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">📊 素材上传趋势(最近30天)</h3>
<div className={cn(cardStyles.base, 'p-6')}>
<h3 className="text-lg font-medium text-slate-900 dark:text-white mb-4">📊 素材上传趋势(最近30天)</h3>
<div className="h-64 overflow-y-auto">
<div className="space-y-2">
{trends.materialUploads.map((item, index) => (
<div key={index} className="flex justify-between items-center py-2 border-b border-gray-100">
<span className="text-sm text-gray-600">{formatDate(item.date)}</span>
<div key={index} className="flex justify-between items-center py-2 border-b border-slate-100 dark:border-slate-800/80">
<span className="text-sm text-slate-600 dark:text-slate-300">{formatDate(item.date)}</span>
<div className="flex items-center">
<div className="w-32 bg-gray-200 rounded-full h-2 mr-3">
<div className="w-32 bg-slate-200 dark:bg-slate-700 rounded-full h-2 mr-3">
<div
className="bg-purple-600 h-2 rounded-full"
className="bg-purple-600 dark:bg-purple-500 h-2 rounded-full"
style={{ width: `${Math.min(100, (item.count / Math.max(...trends.materialUploads.map(d => d.count))) * 100)}%` }}
></div>
</div>
<span className="text-sm font-medium text-gray-900">{item.count}</span>
<span className="text-sm font-medium text-slate-900 dark:text-white">{item.count}</span>
</div>
</div>
))}
@ -338,59 +342,80 @@ export default function SystemMonitor() {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-8">
{/* 部门分布 */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">🏢 部门分布</h3>
<div className={cn(cardStyles.base, 'p-6')}>
<h3 className="text-lg font-medium text-slate-900 dark:text-white mb-4">🏢 部门分布</h3>
<div className="space-y-3">
{distribution.departments.map((item, index) => (
<div key={index} className="flex justify-between items-center">
<span className="text-sm text-gray-600">{item.department || '未设置'}</span>
<span className="text-sm font-medium text-gray-900">{item.count}</span>
<span className="text-sm text-slate-600 dark:text-slate-300">{item.department || '未设置'}</span>
<span className="text-sm font-medium text-slate-900 dark:text-white">{item.count}</span>
</div>
))}
</div>
</div>
{/* 权限分布 */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">🛡️ 权限分布</h3>
<div className={cn(cardStyles.base, 'p-6')}>
<h3 className="text-lg font-medium text-slate-900 dark:text-white mb-4">🛡️ 权限分布</h3>
<div className="space-y-3">
{distribution.roles.map((item, index) => (
<div key={index} className="flex justify-between items-center">
<span className="text-sm text-gray-600">
<span className="text-sm text-slate-600 dark:text-slate-300">
{item.role === 'ADMIN' && item.isSuperAdmin ? '超级管理员' :
item.role === 'ADMIN' ? '管理员' : '普通用户'}
</span>
<span className="text-sm font-medium text-gray-900">{item.count}</span>
<span className="text-sm font-medium text-slate-900 dark:text-white">{item.count}</span>
</div>
))}
</div>
</div>
{/* 系统信息 */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">⚙️ 系统信息</h3>
{/* 系统信息 - 只读状态展示 */}
<div className={cn(cardStyles.base, 'p-6')}>
<h3 className="text-lg font-medium text-slate-900 dark:text-white mb-4">⚙️ 系统信息</h3>
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-600">注册功能</span>
<span className={`text-sm font-medium ${systemInfo.registrationEnabled ? 'text-green-600' : 'text-red-600'}`}>
<span className="text-sm text-slate-600 dark:text-slate-300">注册功能</span>
<span
className={cn(
'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium',
systemInfo.registrationEnabled
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300'
: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300'
)}
>
{systemInfo.registrationEnabled ? '开启' : '关闭'}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-600">用户激活</span>
<span className={`text-sm font-medium ${systemInfo.userActivationRequired ? 'text-yellow-600' : 'text-green-600'}`}>
<span className="text-sm text-slate-600 dark:text-slate-300">用户激活</span>
<span
className={cn(
'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium',
systemInfo.userActivationRequired
? 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300'
: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300'
)}
>
{systemInfo.userActivationRequired ? '需要' : '关闭'}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-600">数据库状态</span>
<span className={`text-sm font-medium ${systemStatus.database === 'healthy' ? 'text-green-600' : 'text-red-600'}`}>
<span className="text-sm text-slate-600 dark:text-slate-300">数据库状态</span>
<span
className={cn(
'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium',
systemStatus.database === 'healthy'
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300'
: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300'
)}
>
{systemStatus.database === 'healthy' ? '正常' : '异常'}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-600">运行时间</span>
<span className="text-sm font-medium text-gray-900">
<span className="text-sm text-slate-600 dark:text-slate-300">运行时间</span>
<span className="text-sm font-medium text-slate-900 dark:text-white">
{Math.floor(systemStatus.uptime / 3600)}小时
</span>
</div>
@ -399,33 +424,33 @@ export default function SystemMonitor() {
</div>
{/* 最近活动 */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">🕒 最近活动</h3>
<div className={cn(cardStyles.base, 'p-6')}>
<h3 className="text-lg font-medium text-slate-900 dark:text-white mb-4">🕒 最近活动</h3>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<table className="min-w-full divide-y divide-slate-200 dark:divide-slate-700/80">
<thead className="bg-slate-50 dark:bg-slate-900/60">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
素材标题
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
上传者
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
上传时间
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
<tbody className="bg-white dark:bg-slate-900/40 divide-y divide-slate-200 dark:divide-slate-800/80">
{recentActivity.map((activity) => (
<tr key={activity.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<tr key={activity.id} className="hover:bg-slate-50 dark:hover:bg-slate-800/60 transition-colors">
<td className="px-6 py-4 whitespace-nowrap text-sm text-slate-900 dark:text-white">
{activity.title}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<td className="px-6 py-4 whitespace-nowrap text-sm text-slate-500 dark:text-slate-300">
{activity.uploadedBy.username}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<td className="px-6 py-4 whitespace-nowrap text-sm text-slate-500">
{formatTimestamp(activity.createdAt)}
</td>
</tr>

File diff suppressed because it is too large Load Diff

View File

@ -1,837 +1,40 @@
'use client'
import { useEffect, useState } from 'react'
import { useEffect } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import Image from 'next/image'
import Header from '@/components/Header'
import AppearanceSettingsPanel from '../_components/AppearanceSettingsPanel'
export default function SystemSettings() {
export default function SystemSettingsPage() {
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 [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)
// S3配置状态
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)
// 权限检查
useEffect(() => {
if (status === 'unauthenticated') {
router.push('/login')
router.replace('/login')
} else if (status === 'authenticated' && session?.user?.role !== 'ADMIN') {
router.push('/')
router.replace('/')
}
}, [status, session, router])
// 加载设置
useEffect(() => {
if (status === 'authenticated' && session?.user?.role === 'ADMIN') {
fetchSettings()
}
}, [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素材')
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)
}
} catch (error) {
console.error('Error fetching settings:', error)
alert('加载设置失败')
} finally {
setLoading(false)
}
}
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) {
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 handleHeaderLogoUpload = 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
}
setUploadingHeader(true)
try {
const formData = new FormData()
formData.append('logo', file)
formData.append('type', 'header')
const response = await fetch('/api/system-settings/upload-logo', {
method: 'POST',
body: formData,
})
if (response.ok) {
const data = await response.json()
setHeaderLogoUrl(data.logoUrl)
setHeaderLogoPreview(data.logoUrl)
alert('导航栏LOGO上传成功!')
} else {
const error = await response.json()
alert(error.error || 'LOGO上传失败')
}
} catch (error) {
console.error('Upload logo error:', error)
alert('上传失败,请稍后重试')
} finally {
setUploadingHeader(false)
}
}
const handleLoginLogoUpload = 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
}
setUploadingLogin(true)
try {
const formData = new FormData()
formData.append('logo', file)
formData.append('type', 'login')
const response = await fetch('/api/system-settings/upload-logo', {
method: 'POST',
body: formData,
})
if (response.ok) {
const data = await response.json()
setLoginLogoUrl(data.logoUrl)
setLoginLogoPreview(data.logoUrl)
alert('登录页LOGO上传成功!')
} else {
const error = await response.json()
alert(error.error || 'LOGO上传失败')
}
} catch (error) {
console.error('Upload logo error:', error)
alert('上传失败,请稍后重试')
} finally {
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)
}
if (status === 'loading' || loading) {
if (status === '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 className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900">
<div className="text-sm text-slate-500 dark:text-slate-400">正在加载外观设置...</div>
</div>
)
}
if (!session || session.user.role !== 'ADMIN') {
if (!session || session.user?.role !== 'ADMIN') {
return null
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<div className="min-h-screen bg-slate-50 dark:bg-slate-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" style={{ backgroundColor: headerBgColor }}>
{headerLogoPreview ? (
<Image
src={headerLogoPreview}
alt="导航栏LOGO"
width={128}
height={128}
unoptimized
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">
用于深色导航栏,建议使用浅色LOGO
</p>
<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">
{uploadingHeader ? (
<>
<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={handleHeaderLogoUpload}
disabled={uploadingHeader}
className="hidden"
/>
</label>
<button
onClick={handleHeaderLogoReset}
className="btn-secondary"
disabled={uploadingHeader}
>
恢复默认
</button>
</div>
</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-white">
{loginLogoPreview ? (
<Image
src={loginLogoPreview}
alt="登录页LOGO"
width={128}
height={128}
unoptimized
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">
用于浅色登录页面,建议使用深色LOGO
</p>
<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">
{uploadingLogin ? (
<>
<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={handleLoginLogoUpload}
disabled={uploadingLogin}
className="hidden"
/>
</label>
<button
onClick={handleLoginLogoReset}
className="btn-secondary"
disabled={uploadingLogin}
>
恢复默认
</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-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>主题颜色建议选择深色系,以确保白色文字清晰可见;主题颜色会应用到导航栏、按钮等UI元素</li>
</ul>
</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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
功能开关
</h2>
<div className="space-y-6">
{/* 用户激活功能开关 */}
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div className="flex-1">
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-1">
用户激活功能
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
开启后,新注册的用户需要管理员激活才能登录。默认关闭。
</p>
</div>
<label className={`relative inline-flex items-center ${!(session?.user as any)?.isSuperAdmin ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}>
<input
type="checkbox"
checked={enableUserActivation}
onChange={(e) => setEnableUserActivation(e.target.checked)}
disabled={!(session?.user as any)?.isSuperAdmin}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
</label>
</div>
{!(session?.user as any)?.isSuperAdmin && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
仅最高权限账号可修改此开关
</p>
)}
{/* 用户注册功能开关 */}
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div className="flex-1">
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-1">
用户手动注册功能
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
关闭后,用户将无法通过注册页面手动注册。默认开启。
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={enableRegistration}
onChange={(e) => setEnableRegistration(e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
</label>
</div>
{/* AI改图功能 */}
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div className="flex-1">
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-1">
AI改图功能(预留)
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
控制素材详情页的 AI 改图按钮显示。功能目前为占位,启用后将连通正式 API。
</p>
</div>
<label
className={`relative inline-flex items-center ${
!(session?.user as any)?.isSuperAdmin ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'
}`}
>
<input
type="checkbox"
checked={enableAiImageEdit}
onChange={(e) => setEnableAiImageEdit(e.target.checked)}
disabled={!(session?.user as any)?.isSuperAdmin}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
</label>
</div>
{/* 内网穿透功能 */}
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div className="flex-1">
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-1">
内网穿透功能(付费)
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
开启后可在后台调度付费内网穿透服务,目前仅展示占位操作。请确认购买服务后再启用。
</p>
</div>
<label
className={`relative inline-flex items-center ${
!(session?.user as any)?.isSuperAdmin ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'
}`}
>
<input
type="checkbox"
checked={enableTunnelService}
onChange={(e) => setEnableTunnelService(e.target.checked)}
disabled={!(session?.user as any)?.isSuperAdmin}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
</label>
</div>
{!(session?.user as any)?.isSuperAdmin && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
最高权限账号才可修改 AI/内网穿透等受保护功能开关
</p>
)}
</div>
</div>
{/* S3存储桶配置 */}
<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="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
</svg>
S3存储桶配置
</h2>
<div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
配置S3对象存储,用于存储上传的图片和文件
</p>
{/* S3端点 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
S3端点 (Endpoint)
</label>
<input
type="text"
value={s3Endpoint}
onChange={(e) => setS3Endpoint(e.target.value)}
placeholder="例如: s3.1080p.hk"
className="w-full 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"
/>
</div>
{/* Access Key & Secret Key */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Access Key (访问密钥)
</label>
<input
type="text"
value={s3AccessKey}
onChange={(e) => setS3AccessKey(e.target.value)}
placeholder="输入Access Key"
className="w-full 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 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Secret Key (密钥)
</label>
<input
type="password"
value={s3SecretKey}
onChange={(e) => setS3SecretKey(e.target.value)}
placeholder="输入Secret Key"
className="w-full 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 text-sm"
/>
</div>
</div>
{/* Bucket & Region */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
存储桶名称 (Bucket)
</label>
<input
type="text"
value={s3Bucket}
onChange={(e) => setS3Bucket(e.target.value)}
placeholder="例如: my-bucket"
className="w-full 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
区域 (Region)
</label>
<input
type="text"
value={s3Region}
onChange={(e) => setS3Region(e.target.value)}
placeholder="例如: us-east-1"
className="w-full 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"
/>
</div>
</div>
{/* 公网访问URL */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
公网访问URL (Public URL)
</label>
<input
type="text"
value={s3PublicUrl}
onChange={(e) => setS3PublicUrl(e.target.value)}
placeholder="例如: https://cdn.example.com"
className="w-full 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"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
如果配置了CDN或自定义域名,请填写此项
</p>
</div>
{/* 提示信息 */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mt-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-semibold mb-2">配置说明:</p>
<ul className="space-y-1 list-disc list-inside">
<li>S3端点通常是您的对象存储服务器地址</li>
<li>Access Key和Secret Key用于身份验证</li>
<li>存储桶需要预先创建并设置公开读取权限</li>
<li>修改配置后需要重启应用才能生效</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<main className="container mx-auto px-4 py-8 max-w-6xl space-y-8">
<AppearanceSettingsPanel sessionUser={session.user as any} variant="standalone" />
</main>
</div>
)

View File

@ -57,13 +57,14 @@ export async function GET() {
}
export async function POST(req: NextRequest) {
let body: any = null
try {
const session = await getServerSession(authOptions)
if (!session || (session.user as any)?.role !== 'ADMIN') {
return NextResponse.json({ error: '无权限' }, { status: 403 })
}
const body = await req.json()
body = await req.json()
const {
name,
description,
@ -87,7 +88,10 @@ export async function POST(req: NextRequest) {
layoutWidth: field.layoutWidth || 'full',
validations: field.validations ?? null,
optionsSource: field.optionsSource ?? null,
optionsStatic: field.optionsStatic ?? null,
// optionsStatic 需要是字符串或 null,如果是数组则转换为 JSON 字符串
optionsStatic: field.optionsStatic !== null && field.optionsStatic !== undefined
? (Array.isArray(field.optionsStatic) ? JSON.stringify(field.optionsStatic) : String(field.optionsStatic))
: null,
allowedExtensions: field.allowedExtensions ?? null,
visibleOnHomeFilter: !!field.visibleOnHomeFilter,
isSearchable: !!field.isSearchable,
@ -111,9 +115,29 @@ export async function POST(req: NextRequest) {
})
return NextResponse.json(template, { status: 201 })
} catch (error) {
} catch (error: any) {
console.error('Create upload template error:', error)
return NextResponse.json({ error: '创建上传模板失败' }, { status: 500 })
console.error('Error details:', {
message: error?.message,
code: error?.code,
meta: error?.meta,
stack: error?.stack,
body: body,
})
// 如果是 Prisma 错误,返回更详细的错误信息
if (error?.code) {
if (error.code === 'P2002') {
return NextResponse.json({ error: '模板名称已存在,请使用其他名称' }, { status: 400 })
}
if (error.code === 'P2003') {
return NextResponse.json({ error: '关联数据不存在' }, { status: 400 })
}
}
// 返回更详细的错误信息
const errorMessage = error?.message || '创建上传模板失败'
return NextResponse.json({ error: errorMessage }, { status: 500 })
}
}

View File

@ -30,14 +30,36 @@ export async function generateMetadata(): Promise<Metadata> {
}
}
const themeInitScript = `
(function() {
try {
var storageKey = 'admin-theme-preference';
var classNameDark = 'dark';
var stored = typeof window !== 'undefined' ? window.localStorage.getItem(storageKey) : null;
var prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
var theme = stored === 'light' || stored === 'dark' ? stored : (prefersDark ? 'dark' : 'light');
var root = document.documentElement;
if (theme === 'dark') {
root.classList.add(classNameDark);
} else {
root.classList.remove(classNameDark);
}
root.dataset.theme = theme;
} catch (error) {
console.warn('初始化主题失败', error);
}
})();
`
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="zh-CN">
<html lang="zh-CN" suppressHydrationWarning>
<body className="font-sans antialiased">
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
<Providers>
{children}
</Providers>

View File

@ -4,13 +4,15 @@ import Image from 'next/image'
import Link from 'next/link'
import { signOut } from 'next-auth/react'
import { Session } from 'next-auth'
import { useState, useRef, useEffect } from 'react'
import { useState, useRef, useEffect, useCallback } from 'react'
import { initializeNetwork } from '@/lib/network'
interface HeaderProps {
session: Session | null
}
const THEME_STORAGE_KEY = 'admin-theme-preference'
export default function Header({ session }: HeaderProps) {
const isAdmin = session ? ((session.user as any)?.role === 'ADMIN' || (session.user as any)?.isSuperAdmin) : false
const [showDropdown, setShowDropdown] = useState(false)
@ -28,6 +30,42 @@ export default function Header({ session }: HeaderProps) {
// Logo 加载状态
const [logoError, setLogoError] = useState(false)
const [logoRetryCount, setLogoRetryCount] = useState(0)
const [theme, setTheme] = useState<'light' | 'dark'>('light')
const [themeReady, setThemeReady] = useState(false)
const applyTheme = useCallback((nextTheme: 'light' | 'dark') => {
if (typeof document === 'undefined') return
const root = document.documentElement
if (nextTheme === 'dark') {
root.classList.add('dark')
} else {
root.classList.remove('dark')
}
root.dataset.theme = nextTheme
try {
window.localStorage.setItem(THEME_STORAGE_KEY, nextTheme)
} catch {
// ignore storage errors
}
}, [])
useEffect(() => {
if (typeof window === 'undefined') return
let initial: 'light' | 'dark' = 'light'
try {
const stored = window.localStorage.getItem(THEME_STORAGE_KEY)
if (stored === 'light' || stored === 'dark') {
initial = stored
} else {
initial = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
} catch {
initial = 'light'
}
setTheme(initial)
applyTheme(initial)
setThemeReady(true)
}, [applyTheme])
useEffect(() => {
if (!headerLogoUrl) return
@ -155,6 +193,7 @@ export default function Header({ session }: HeaderProps) {
<button
onClick={switchToInternal}
className="px-4 py-2 bg-white text-green-600 rounded-lg font-medium hover:bg-green-50 transition-all shadow-sm"
aria-pressed={theme === 'dark'}
>
立即切换
</button>
@ -264,6 +303,68 @@ export default function Header({ session }: HeaderProps) {
<span>{isInternalNetwork ? '内网' : '公网'}</span>
</div>
)}
{/* Theme toggle */}
<div className="flex items-center">
{themeReady ? (
<button
type="button"
onClick={() => {
const next = theme === 'dark' ? 'light' : 'dark'
setTheme(next)
applyTheme(next)
}}
aria-label={`切换为${theme === 'dark' ? '浅色' : '深色'}模式`}
aria-pressed={theme === 'dark'}
className="relative flex items-center px-2 py-1 rounded-full border border-white/25 bg-white/15 dark:bg-white/5 shadow-[0_5px_14px_rgba(15,23,42,0.25)] backdrop-blur focus:outline-none focus-visible:outline-none focus-visible:ring-0 transition-all w-16 h-8 overflow-hidden"
>
<div className="absolute inset-0 flex items-center justify-between px-2">
<svg
className={`w-4 h-4 transition-colors ${theme === 'light' ? 'text-amber-300' : 'text-white/35'}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364-6.364l-.707.707M6.343 17.657l-.707.707m12.728 0l-.707-.707M6.343 6.343l-.707-.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<svg
className={`w-4 h-4 transition-colors ${theme === 'dark' ? 'text-indigo-200' : 'text-white/35'}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6} d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z" />
</svg>
</div>
<span
className={`absolute top-1 left-1 w-6 h-6 rounded-full bg-white shadow-lg transition-transform duration-300 ${
theme === 'dark' ? 'translate-x-7' : 'translate-x-0'
}`}
>
<span className="sr-only">{theme === 'dark' ? '深色模式' : '浅色模式'}</span>
<span className="relative block w-full h-full text-slate-700">
<svg
className={`absolute inset-0 m-auto w-3.5 h-3.5 transition-opacity ${theme === 'light' ? 'opacity-100 text-amber-500' : 'opacity-0'}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364-6.364l-.707.707M6.343 17.657l-.707.707m12.728 0l-.707-.707M6.343 6.343l-.707-.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<svg
className={`absolute inset-0 m-auto w-3.5 h-3.5 transition-opacity ${theme === 'dark' ? 'opacity-100 text-indigo-400' : 'opacity-0'}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6} d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z" />
</svg>
</span>
</span>
</button>
) : (
<div className="w-16 h-8 rounded-full border border-white/20 bg-white/10 animate-pulse" />
)}
</div>
{/* User Dropdown */}
{session && (

129
components/ui/Modal.tsx Normal file
View File

@ -0,0 +1,129 @@
'use client'
import { useEffect, useRef, ReactNode } from 'react'
import { useThemeStyles } from '@/lib/ui-styles'
export type ModalSize = 'small' | 'medium' | 'large' | 'xlarge' | 'fullscreen'
interface ModalProps {
isOpen: boolean
onClose: () => void
title?: string
children: ReactNode
size?: ModalSize
showCloseButton?: boolean
closeOnOverlayClick?: boolean
closeOnEscape?: boolean
footer?: ReactNode
className?: string
}
export default function Modal({
isOpen,
onClose,
title,
children,
size = 'medium',
showCloseButton = true,
closeOnOverlayClick = true,
closeOnEscape = true,
footer,
className = '',
}: ModalProps) {
const styles = useThemeStyles()
const modalRef = useRef<HTMLDivElement>(null)
// ESC键关闭
useEffect(() => {
if (!isOpen || !closeOnEscape) return
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose()
}
}
document.addEventListener('keydown', handleEscape)
return () => document.removeEventListener('keydown', handleEscape)
}, [isOpen, closeOnEscape, onClose])
// 阻止body滚动
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
return () => {
document.body.style.overflow = ''
}
}, [isOpen])
if (!isOpen) return null
const handleOverlayClick = (e: React.MouseEvent) => {
if (closeOnOverlayClick && e.target === e.currentTarget) {
onClose()
}
}
const sizeClass = styles.modalStyles.sizes[size]
return (
<div
className={`${styles.modalStyles.overlay} ${className}`}
onClick={handleOverlayClick}
role="dialog"
aria-modal="true"
aria-labelledby={title ? 'modal-title' : undefined}
>
<div
ref={modalRef}
className={`${styles.modalStyles.container} ${sizeClass}`}
onClick={(e) => e.stopPropagation()}
>
<div className={styles.modalStyles.content}>
{/* 头部 */}
{(title || showCloseButton) && (
<div className={styles.modalStyles.header}>
{title && (
<h3 id="modal-title" className={styles.modalStyles.title}>
{title}
</h3>
)}
{showCloseButton && (
<button
type="button"
onClick={onClose}
className={styles.modalStyles.closeButton}
aria-label="关闭"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
)}
</div>
)}
{/* 主体 */}
<div className={styles.modalStyles.body}>{children}</div>
{/* 底部 */}
{footer && <div className={styles.modalStyles.footer}>{footer}</div>}
</div>
</div>
</div>
)
}

View File

@ -179,7 +179,7 @@ if ($Help) {
Write-ColorOutput "示例:" "White"
Write-ColorOutput " .\deploy-new-fixed.ps1" "Yellow"
Write-ColorOutput " .\deploy-new-fixed.ps1 -Password 'your-password'" "Yellow"
Write-ColorOutput " .\deploy-new-fixed.ps1 -KeyPath 'C:\Users\.ssh\id_rsa'" "Yellow"
Write-ColorOutput " .\deploy-new-fixed.ps1 -KeyPath 'C:\Users\YourUsername\.ssh\youyidian_prod_ed25519'" "Yellow"
Write-ColorOutput " .\deploy-new-fixed.ps1 -SkipCheck -SkipUpload" "Yellow"
exit 0
}
@ -361,6 +361,9 @@ function Test-Command {
# 查找SSH密钥文件
function Find-SSHKeys {
$possibleKeys = @(
# 优先使用项目专用的生产环境密钥(已验证可用)
"$env:USERPROFILE\.ssh\youyidian_prod_ed25519",
# 其他常见密钥位置
"$env:USERPROFILE\.ssh\id_rsa",
"$env:USERPROFILE\.ssh\id_ed25519",
"$env:USERPROFILE\.ssh\id_ecdsa",

View File

@ -0,0 +1,203 @@
# 深浅色样式分离迁移指南
## 概述
为了解决深浅色适配问题,我们重构了 UI 样式系统,采用两套独立的配置文件:
- **浅色模式**:`lib/ui-styles-light.ts`
- **深色模式**:`lib/ui-styles-dark.ts`
## 优势
### 之前的问题
- 每个样式字符串都包含 `dark:` 前缀,代码冗长
- 容易遗漏 `dark:` 前缀,导致深色模式显示不正确
- 维护困难,需要在同一字符串中同时考虑两种模式
### 现在的优势
- ✅ 代码更清晰:每个样式只包含一套类名
- ✅ 更容易维护:修改时只需要考虑一种模式
- ✅ 避免遗漏:不会忘记添加 `dark:` 前缀
- ✅ 类型安全:TypeScript 能更好地检查样式使用
## 使用方式
### 方式一:使用 `useThemeStyles()` Hook(推荐)
适用于客户端组件(`'use client'`):
```tsx
'use client'
import { useThemeStyles } from '@/lib/ui-styles'
export default function MyComponent() {
// 自动根据当前主题返回对应的样式配置
const styles = useThemeStyles()
return (
<div className={styles.cardStyles.base}>
<button className={styles.buttonStyles.primary}>
主要按钮
</button>
<button className={`${styles.buttonStyles.secondary} ${styles.buttonModifiers.compact}`}>
次要按钮
</button>
</div>
)
}
```
### 方式二:继续使用原有导入(向后兼容)
原有的 `lib/ui-styles.ts` 仍然保留,包含 `dark:` 前缀的样式:
```tsx
import { cardStyles, buttonStyles, buttonModifiers } from '@/lib/ui-styles'
export default function MyComponent() {
return (
<div className={cardStyles.base}>
<button className={buttonStyles.primary}>
主要按钮
</button>
</div>
)
}
```
**注意**:这种方式仍然可以工作,但建议逐步迁移到 `useThemeStyles()`。
## 迁移步骤
### 1. 将组件改为客户端组件
如果组件是服务端组件,需要添加 `'use client'`:
```tsx
'use client' // 添加这一行
import { useThemeStyles } from '@/lib/ui-styles'
```
### 2. 替换样式导入
**之前:**
```tsx
import { cardStyles, buttonStyles } from '@/lib/ui-styles'
export default function MyComponent() {
return (
<div className={cardStyles.base}>
<button className={buttonStyles.primary}>按钮</button>
</div>
)
}
```
**之后:**
```tsx
'use client'
import { useThemeStyles } from '@/lib/ui-styles'
export default function MyComponent() {
const styles = useThemeStyles()
return (
<div className={styles.cardStyles.base}>
<button className={styles.buttonStyles.primary}>按钮</button>
</div>
)
}
```
### 3. 批量替换模式
如果有多个组件需要迁移,可以使用搜索替换:
**查找:**
```tsx
import { cardStyles, buttonStyles, inputStyles, buttonModifiers } from '@/lib/ui-styles'
```
**替换为:**
```tsx
'use client'
import { useThemeStyles } from '@/lib/ui-styles'
// 在组件内部:
const styles = useThemeStyles()
// 然后使用 styles.cardStyles、styles.buttonStyles 等
```
## 可用样式
两套配置的结构完全相同,包含:
- `cardStyles` - 卡片样式
- `base`, `hover`, `large`, `flat`, `rainbow`, `glass`, `neon`
- `buttonStyles` - 按钮样式
- `primary`, `secondary`, `ghost`, `danger`, `success`, `rainbow`, `neon`, `glass`
- `buttonModifiers` - 按钮修饰符
- `compact`, `compactXs`, `icon`, `block`, `pill`
- `inputStyles` - 输入框样式
- `base`, `error`
- `inputModifiers` - 输入框修饰符
- `compact`, `compactXs`
- `navItemStyles` - 导航项样式
- `base`, `hover`, `active`, `rainbow`, `tabBase`, `tabActive`
- `badgeStyles` - 徽章样式
- `base`, `primary`, `success`, `warning`, `danger`
- `spacingStyles` - 间距样式
- `section`, `card`, `form`, `compact`
## 示例
### 完整的组件示例
```tsx
'use client'
import { useThemeStyles } from '@/lib/ui-styles'
import { cn } from '@/lib/ui-styles'
export default function UserCard({ user }: { user: User }) {
const styles = useThemeStyles()
return (
<div className={styles.cardStyles.hover}>
<h3 className="text-lg font-semibold">{user.name}</h3>
<p className="text-sm opacity-75">{user.email}</p>
<div className="mt-4 flex gap-2">
<button className={styles.buttonStyles.primary}>
编辑
</button>
<button className={cn(styles.buttonStyles.secondary, styles.buttonModifiers.compact)}>
删除
</button>
</div>
</div>
)
}
```
## 注意事项
1. **客户端组件**:使用 `useThemeStyles()` 的组件必须是客户端组件(添加 `'use client'`)
2. **主题切换**:Hook 会自动监听主题变化,组件会自动更新样式
3. **性能**:样式配置是静态导入的,不会影响打包大小
4. **向后兼容**:原有代码仍然可以工作,可以逐步迁移
## 下一步
- [ ] 迁移现有组件到新的样式系统
- [ ] 移除 `lib/ui-styles.ts` 中的 `dark:` 前缀(迁移完成后)
- [ ] 统一使用 `useThemeStyles()` Hook

View File

@ -0,0 +1,242 @@
# UI 样式系统架构说明
## 架构设计
为了解决深浅色适配问题,同时保持视觉设计的统一性,我们采用了**三层架构**:
```
┌─────────────────────────────────────┐
│ ui-styles-light.ts / dark.ts │ ← 最终导出的样式(由组合器生成)
│ (组件直接使用) │
└──────────────┬──────────────────────┘
│
┌──────────────▼──────────────────────┐
│ ui-styles-composer.ts │ ← 样式组合器(组合基础样式 + 颜色主题)
│ (自动组合生成) │
└──────────────┬──────────────────────┘
│
┌────────┴────────┐
│ │
┌─────▼──────┐ ┌───────▼────────┐
│ ui-styles- │ │ ui-theme- │
│ base.ts │ │ colors-*.ts │
│ │ │ │
│ 布局/大小/ │ │ 仅颜色配置 │
│ 动画等 │ │ │
└────────────┘ └────────────────┘
```
## 文件结构
### 1. `lib/ui-styles-base.ts` - 基础样式配置
**包含内容:**
- 布局(padding, margin, spacing)
- 大小(rounded, border-width)
- 动画(transition, hover effects)
- 结构(flex, grid, positioning)
**不包含:**
- 颜色(bg-*, text-*, border-* 的颜色部分)
- 阴影颜色(shadow-* 的颜色部分)
**示例:**
```typescript
export const baseCardStyles = {
base: 'rounded-2xl border backdrop-blur-sm shadow-lg p-6 transition-all duration-300',
// ↑ 只有结构,没有颜色
}
```
### 2. `lib/ui-theme-colors-light.ts` - 浅色模式颜色配置
**包含内容:**
- 所有颜色相关的 Tailwind 类名
- 浅色模式下的颜色值
**示例:**
```typescript
export const themeColors = {
card: {
border: 'border-slate-200/70',
bg: 'bg-white',
shadow: 'shadow-slate-200/50',
// ...
},
// ...
}
```
### 3. `lib/ui-theme-colors-dark.ts` - 深色模式颜色配置
**包含内容:**
- 所有颜色相关的 Tailwind 类名
- 深色模式下的颜色值
**示例:**
```typescript
export const themeColors = {
card: {
border: 'border-slate-700/70',
bg: 'bg-slate-800',
shadow: 'shadow-black/50',
// ...
},
// ...
}
```
### 4. `lib/ui-styles-composer.ts` - 样式组合器
**功能:**
- 将基础样式和颜色主题组合成完整的样式字符串
- 自动生成 `lightThemeStyles` 和 `darkThemeStyles`
**示例:**
```typescript
function composeCardStyles(colors: ColorTheme) {
return {
base: `${baseStyles.baseCardStyles.base} ${colors.card.border} ${colors.card.bg} ${colors.card.shadow}`,
// ↑ 基础结构 + 颜色 = 完整样式
}
}
```
### 5. `lib/ui-styles-light.ts` / `lib/ui-styles-dark.ts` - 最终导出
**功能:**
- 从组合器导入生成的样式
- 提供给组件使用
## 修改指南
### 修改布局、大小、动画等视觉设计
**修改文件:** `lib/ui-styles-base.ts`
**示例:** 如果要修改卡片的内边距从 `p-6` 改为 `p-8`:
```typescript
// lib/ui-styles-base.ts
export const baseCardStyles = {
base: 'rounded-2xl border backdrop-blur-sm shadow-lg p-8 transition-all duration-300',
// ↑ 改为 p-8
// 这个修改会自动应用到浅色和深色模式
}
```
### 修改颜色
**修改文件:**
- 浅色模式:`lib/ui-theme-colors-light.ts`
- 深色模式:`lib/ui-theme-colors-dark.ts`
**示例:** 如果要修改浅色模式下的主要按钮颜色:
```typescript
// lib/ui-theme-colors-light.ts
export const themeColors = {
button: {
primary: {
bg: 'bg-indigo-700', // 从 bg-indigo-600 改为 bg-indigo-700
// ...
},
},
}
```
## 优势
### ✅ 统一视觉设计
- 修改布局、大小、动画时,只需修改 `ui-styles-base.ts` 一处
- 浅色和深色模式自动同步更新
### ✅ 独立颜色适配
- 颜色配置完全分离,互不影响
- 可以独立调整浅色或深色模式的颜色
### ✅ 易于维护
- 清晰的职责分离
- 修改时不会遗漏或出错
### ✅ 类型安全
- TypeScript 类型检查确保配置正确
## 使用方式
组件中的使用方式不变:
```tsx
'use client'
import { useThemeStyles } from '@/lib/ui-styles'
export default function MyComponent() {
const styles = useThemeStyles()
return (
<div className={styles.cardStyles.base}>
<button className={styles.buttonStyles.primary}>按钮</button>
</div>
)
}
```
## 迁移说明
现有的组件无需修改,因为:
- `ui-styles-light.ts` 和 `ui-styles-dark.ts` 的导出接口保持不变
- `useThemeStyles()` Hook 的行为保持不变
- 只是内部实现从硬编码改为组合生成
## 注意事项
1. **不要直接修改** `ui-styles-light.ts` 或 `ui-styles-dark.ts` 的内容
- 这些文件是从组合器自动生成的
- 修改会被覆盖
2. **修改基础样式**时,确保不包含颜色
- 颜色应该在 `ui-theme-colors-*.ts` 中定义
- 基础样式中的颜色类名(如 `bg-white`)应该移除,改为在颜色主题中定义
3. **修改颜色**时,确保浅色和深色模式都更新
- 保持视觉一致性
- 如果只修改一处,另一处可能显示不正确
## 实际示例
### 示例 1:修改卡片内边距
**场景:** 要将所有卡片的内边距从 `p-6` 改为 `p-8`
**步骤:**
1. 打开 `lib/ui-styles-base.ts`
2. 找到 `baseCardStyles` 中的所有 `p-6`,改为 `p-8`
3. 保存文件
**结果:**
- ✅ 浅色模式的所有卡片内边距自动更新为 `p-8`
- ✅ 深色模式的所有卡片内边距自动更新为 `p-8`
- ✅ 只需修改一处,两套主题同步更新
### 示例 2:修改按钮颜色
**场景:** 要将浅色模式的主要按钮颜色从 `indigo-600` 改为 `indigo-700`
**步骤:**
1. 打开 `lib/ui-theme-colors-light.ts`
2. 找到 `button.primary.bg`,将 `bg-indigo-600` 改为 `bg-indigo-700`
3. 保存文件
**结果:**
- ✅ 浅色模式的主要按钮颜色更新
- ✅ 深色模式不受影响(保持 `indigo-500`)
- ✅ 布局、大小、动画等其他样式不受影响
### 示例 3:修改动画效果
**场景:** 要将卡片的悬停提升效果从 `-translate-y-1` 改为 `-translate-y-2`
**步骤:**
1. 打开 `lib/ui-styles-base.ts`
2. 找到 `baseCardStyles.hover` 中的 `hover:-translate-y-1`,改为 `hover:-translate-y-2`
3. 保存文件
**结果:**
- ✅ 浅色和深色模式的所有卡片悬停效果同步更新
- ✅ 颜色不受影响

31
hooks/useModal.ts Normal file
View File

@ -0,0 +1,31 @@
'use client'
import { useCallback, useState } from 'react'
/**
* 统一弹窗管理 Hook
* 提供简单的弹窗开关控制
*/
export function useModal() {
const [isOpen, setIsOpen] = useState(false)
const open = useCallback(() => {
setIsOpen(true)
}, [])
const close = useCallback(() => {
setIsOpen(false)
}, [])
const toggle = useCallback(() => {
setIsOpen((prev) => !prev)
}, [])
return {
isOpen,
open,
close,
toggle,
}
}

110
lib/hooks/useThemeStyles.ts Normal file
View File

@ -0,0 +1,110 @@
/**
* 主题感知的样式 Hook
* 根据当前主题返回对应的样式配置
*/
'use client'
'use client'
import { useEffect, useState } from 'react'
import * as lightStyles from '../ui-styles-light'
import * as darkStyles from '../ui-styles-dark'
// 统一类型定义(两种主题的样式结构相同)
type ThemeStyles = typeof lightStyles
/**
* 检测当前主题
*/
function getCurrentTheme(): 'light' | 'dark' {
if (typeof document === 'undefined') return 'light'
return document.documentElement.classList.contains('dark') ? 'dark' : 'light'
}
/**
* 使用主题样式 Hook
* 返回当前主题对应的样式配置,并在主题切换时自动更新
*/
export function useThemeStyles(): ThemeStyles {
const [theme, setTheme] = useState<'light' | 'dark'>(() => getCurrentTheme())
const [styles, setStyles] = useState<ThemeStyles>(() => {
// 初始加载时根据当前主题返回对应样式
if (typeof window !== 'undefined' && getCurrentTheme() === 'dark') {
return darkStyles as ThemeStyles
}
return lightStyles as ThemeStyles
})
useEffect(() => {
const updateTheme = () => {
const newTheme = getCurrentTheme()
setTheme(newTheme)
// 根据主题返回对应样式(静态导入,类型兼容)
setStyles(newTheme === 'dark' ? (darkStyles as ThemeStyles) : (lightStyles as ThemeStyles))
}
// 初始设置
updateTheme()
// 监听 DOM 变化(主题切换时 class 会变化)
const observer = new MutationObserver(updateTheme)
const root = document.documentElement
observer.observe(root, {
attributes: true,
attributeFilter: ['class'],
})
// 监听 storage 变化(如果其他标签页切换了主题)
const handleStorageChange = (e: StorageEvent) => {
if (e.key === 'admin-theme-preference') {
updateTheme()
}
}
window.addEventListener('storage', handleStorageChange)
return () => {
observer.disconnect()
window.removeEventListener('storage', handleStorageChange)
}
}, [])
return styles
}
/**
* 获取当前主题
*/
export function useTheme(): 'light' | 'dark' {
const [theme, setTheme] = useState<'light' | 'dark'>(() => getCurrentTheme())
useEffect(() => {
const updateTheme = () => {
setTheme(getCurrentTheme())
}
updateTheme()
const observer = new MutationObserver(updateTheme)
const root = document.documentElement
observer.observe(root, {
attributes: true,
attributeFilter: ['class'],
})
const handleStorageChange = (e: StorageEvent) => {
if (e.key === 'admin-theme-preference') {
updateTheme()
}
}
window.addEventListener('storage', handleStorageChange)
return () => {
observer.disconnect()
window.removeEventListener('storage', handleStorageChange)
}
}, [])
return theme
}

168
lib/ui-animations.ts Normal file
View File

@ -0,0 +1,168 @@
/**
* 统一的动画工具类
* 苹果风格 + 炫彩元素
*/
// 基础动画类
export const animations = {
// 淡入淡出
fadeIn: 'animate-in fade-in duration-500',
fadeOut: 'animate-out fade-out duration-300',
fadeInUp: 'animate-in fade-in slide-in-from-bottom-4 duration-500',
fadeInDown: 'animate-in fade-in slide-in-from-top-4 duration-500',
fadeInLeft: 'animate-in fade-in slide-in-from-left-4 duration-500',
fadeInRight: 'animate-in fade-in slide-in-from-right-4 duration-500',
// 缩放
scaleIn: 'animate-in zoom-in-95 duration-300',
scaleOut: 'animate-out zoom-out-95 duration-200',
scaleUp: 'hover:scale-105 active:scale-95 transition-transform duration-200',
// 旋转
spin: 'animate-spin',
spinSlow: 'animate-spin-slow',
// 脉冲
pulse: 'animate-pulse',
pulseSlow: 'animate-pulse-slow',
// 弹跳
bounce: 'animate-bounce',
bounceIn: 'animate-in bounce-in duration-500',
// 滑动
slideInFromLeft: 'animate-in slide-in-from-left duration-500',
slideInFromRight: 'animate-in slide-in-from-right duration-500',
slideInFromTop: 'animate-in slide-in-from-top duration-500',
slideInFromBottom: 'animate-in slide-in-from-bottom duration-500',
// 交错动画延迟
stagger: {
'0': 'delay-0',
'75': 'delay-75',
'100': 'delay-100',
'150': 'delay-150',
'200': 'delay-200',
'300': 'delay-300',
'500': 'delay-500',
},
} as const
// 炫彩渐变背景
export const gradientBackgrounds = {
// 苹果风格渐变(统一 Hero 卡片背景颜色)
appleLight: 'bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50 dark:from-slate-900 dark:via-slate-800 dark:to-indigo-900',
appleDark: 'bg-gradient-to-br from-slate-900 via-indigo-900 to-purple-900 dark:from-slate-950 dark:via-indigo-950 dark:to-purple-950',
// Hero 卡片背景渐变(CSS linear-gradient 格式,用于内联样式)
heroBase: {
light: 'linear-gradient(135deg, #f8fafc 0%, #eff6ff 50%, #eef2ff 100%)',
dark: 'linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #1e1b4b 100%)',
},
// 炫彩渐变
rainbow: 'bg-gradient-to-r from-pink-500 via-red-500 via-yellow-500 via-green-500 via-blue-500 via-indigo-500 to-purple-500',
rainbowSubtle: 'bg-gradient-to-br from-pink-100 via-purple-100 via-indigo-100 to-blue-100 dark:from-pink-900/20 dark:via-purple-900/20 dark:via-indigo-900/20 dark:to-blue-900/20',
rainbowVibrant: 'bg-gradient-to-br from-pink-500/20 via-purple-500/20 via-indigo-500/20 to-blue-500/20',
// 霓虹效果
neon: 'bg-gradient-to-r from-cyan-400 via-blue-500 to-purple-500',
neonSubtle: 'bg-gradient-to-br from-cyan-100 via-blue-100 to-purple-100 dark:from-cyan-900/30 dark:via-blue-900/30 dark:to-purple-900/30',
// 金属质感
metallic: 'bg-gradient-to-br from-gray-200 via-gray-300 to-gray-400 dark:from-gray-700 dark:via-gray-600 dark:to-gray-500',
// 玻璃态
glass: 'bg-white/10 dark:bg-white/5 backdrop-blur-md border border-white/20',
glassStrong: 'bg-white/20 dark:bg-white/10 backdrop-blur-lg border border-white/30',
} as const
// 炫彩光晕效果
export const glowEffects = {
// 基础光晕
glow: 'shadow-lg shadow-indigo-500/50',
glowStrong: 'shadow-xl shadow-indigo-500/70',
glowRainbow: 'shadow-xl shadow-[0_0_30px_rgba(139,92,246,0.5)]',
// 彩色光晕
glowPink: 'shadow-lg shadow-pink-500/50',
glowPurple: 'shadow-lg shadow-purple-500/50',
glowBlue: 'shadow-lg shadow-blue-500/50',
glowCyan: 'shadow-lg shadow-cyan-500/50',
// 悬停光晕
glowHover: 'hover:shadow-xl hover:shadow-indigo-500/60 transition-shadow duration-300',
glowHoverRainbow: 'hover:shadow-2xl hover:shadow-[0_0_40px_rgba(139,92,246,0.6)] transition-shadow duration-300',
} as const
// 边框动画
export const borderAnimations = {
// 渐变边框
gradientBorder: 'border-2 border-transparent bg-gradient-to-r from-pink-500 via-purple-500 to-indigo-500 bg-clip-border',
// 闪烁边框
shimmer: 'relative overflow-hidden before:absolute before:inset-0 before:-translate-x-full before:animate-[shimmer_2s_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/20 before:to-transparent',
// 霓虹边框
neon: 'border-2 border-cyan-400 shadow-[0_0_10px_rgba(34,211,238,0.5)]',
neonHover: 'hover:border-cyan-300 hover:shadow-[0_0_20px_rgba(34,211,238,0.7)] transition-all duration-300',
} as const
// 文本动画
export const textAnimations = {
// 渐变文本
gradientText: 'bg-gradient-to-r from-indigo-600 via-purple-600 to-pink-600 bg-clip-text text-transparent',
gradientTextRainbow: 'bg-gradient-to-r from-pink-500 via-red-500 via-yellow-500 via-green-500 via-blue-500 via-indigo-500 to-purple-500 bg-clip-text text-transparent',
// 闪烁文本
shimmerText: 'animate-[shimmer_2s_infinite] bg-gradient-to-r from-gray-400 via-white to-gray-400 bg-clip-text text-transparent',
// 发光文本
glowText: 'text-indigo-400 drop-shadow-[0_0_8px_rgba(99,102,241,0.8)]',
glowTextRainbow: 'drop-shadow-[0_0_10px_rgba(139,92,246,0.6)]',
} as const
// 卡片动画
export const cardAnimations = {
// 悬停提升
lift: 'transition-all duration-300 hover:-translate-y-1 hover:shadow-xl',
liftStrong: 'transition-all duration-300 hover:-translate-y-2 hover:shadow-2xl',
// 3D 倾斜效果
tilt: 'transition-all duration-300 hover:rotate-1 hover:scale-105',
// 闪烁效果
shimmer: 'relative overflow-hidden before:absolute before:inset-0 before:-translate-x-full before:animate-[shimmer_2s_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/10 before:to-transparent',
// 光晕跟随鼠标
glowFollow: 'transition-all duration-500',
} as const
// 按钮动画
export const buttonAnimations = {
// 基础按钮动画
base: 'transition-all duration-200 active:scale-95',
// 炫彩按钮
rainbow: 'bg-gradient-to-r from-pink-500 via-purple-500 to-indigo-500 hover:from-pink-600 hover:via-purple-600 hover:to-indigo-600 transition-all duration-300',
// 霓虹按钮
neon: 'bg-cyan-500 hover:bg-cyan-400 shadow-lg shadow-cyan-500/50 hover:shadow-xl hover:shadow-cyan-500/70 transition-all duration-300',
// 玻璃态按钮
glass: 'bg-white/10 dark:bg-white/5 backdrop-blur-md border border-white/20 hover:bg-white/20 dark:hover:bg-white/10 transition-all duration-300',
// 脉冲按钮
pulse: 'animate-pulse hover:animate-none',
} as const
// 组合动画工具函数
export function combineAnimations(...animations: string[]): string {
return animations.filter(Boolean).join(' ')
}
// 条件动画
export function conditionalAnimation(condition: boolean, animation: string, fallback = ''): string {
return condition ? animation : fallback
}

147
lib/ui-styles-base.ts Normal file
View File

@ -0,0 +1,147 @@
/**
* UI 样式基础配置
* 包含所有非颜色相关的样式:布局、大小、动画、间距、圆角等
* 这些样式在浅色和深色模式下完全相同
*/
// 基础结构样式(不包含颜色)
export const baseCardStyles = {
// 基础卡片结构
base: 'rounded-2xl border backdrop-blur-sm shadow-lg p-6 transition-all duration-300',
// 悬停卡片结构(带提升效果)
hover: 'rounded-2xl border backdrop-blur-sm shadow-lg hover:shadow-xl hover:-translate-y-1 transition-all duration-300 p-6',
// 大卡片结构(带炫彩光晕)
large: 'rounded-3xl border backdrop-blur-md shadow-xl hover:shadow-2xl transition-all duration-500 p-8',
// 扁平卡片结构
flat: 'rounded-2xl border backdrop-blur-sm p-6 transition-all duration-300',
// 炫彩卡片结构(带渐变背景)
rainbow: 'rounded-2xl border backdrop-blur-sm shadow-lg hover:shadow-xl hover:-translate-y-1 transition-all duration-300 p-6',
// 玻璃态卡片结构
glass: 'rounded-2xl backdrop-blur-md border shadow-lg hover:shadow-xl transition-all duration-300 p-6',
// 霓虹卡片结构
neon: 'rounded-2xl border-2 backdrop-blur-sm shadow-lg hover:shadow-xl transition-all duration-300 p-6',
} as const
export const baseButtonStyles = {
// 主要按钮结构
primary: 'px-6 py-3 rounded-xl text-white font-medium shadow-lg hover:shadow-xl active:scale-95 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed',
// 次要按钮结构
secondary: 'px-6 py-3 rounded-xl border backdrop-blur-sm font-medium hover:border-indigo-400 active:scale-95 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed',
// 幽灵按钮结构
ghost: 'px-6 py-3 rounded-xl font-medium active:scale-95 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed',
// 危险按钮结构
danger: 'px-6 py-3 rounded-xl text-white font-medium shadow-lg hover:shadow-xl active:scale-95 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed',
// 成功按钮结构
success: 'px-6 py-3 rounded-xl text-white font-medium shadow-lg hover:shadow-xl active:scale-95 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed',
// 炫彩渐变按钮结构
rainbow: 'px-6 py-3 rounded-xl bg-gradient-to-r from-pink-500 via-purple-500 to-indigo-500 text-white font-medium shadow-lg hover:from-pink-600 hover:via-purple-600 hover:to-indigo-600 hover:shadow-xl active:scale-95 transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed',
// 霓虹按钮结构
neon: 'px-6 py-3 rounded-xl bg-cyan-500 text-white font-medium shadow-lg hover:bg-cyan-400 hover:shadow-xl active:scale-95 transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed',
// 玻璃态按钮结构
glass: 'px-6 py-3 rounded-xl backdrop-blur-md border text-white font-medium hover:border-white/30 active:scale-95 transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed',
} as const
export const baseInputStyles = {
// 基础输入框结构(focus 颜色在主题中定义)
base: 'w-full px-4 py-3 rounded-xl border focus:border-indigo-500 focus:ring-2 outline-none transition-all',
// 错误输入框结构(focus 颜色在主题中定义)
error: 'w-full px-4 py-3 rounded-xl border-2 focus:ring-2 outline-none transition-all',
} as const
export const baseNavItemStyles = {
// 基础导航项结构
base: 'px-4 py-3 rounded-xl border backdrop-blur-sm transition-all duration-300',
// 悬停导航项结构(带光晕效果)
hover: 'px-4 py-3 rounded-xl border backdrop-blur-sm hover:border-indigo-400 hover:shadow-md transition-all duration-300',
// 激活导航项结构(带炫彩光晕)
active: 'px-4 py-3 rounded-xl border-2 border-indigo-500 backdrop-blur-sm shadow-lg transition-all duration-300',
// 炫彩导航项结构
rainbow: 'px-4 py-3 rounded-xl border backdrop-blur-sm hover:shadow-md transition-all duration-300',
// 标签页导航项结构
tabBase: 'relative pb-4 px-5 font-medium transition-all duration-300 ease-out',
tabActive: 'relative pb-4 px-5 font-medium transition-all duration-300 ease-out',
} as const
export const baseBadgeStyles = {
base: 'px-3 py-1 rounded-lg text-xs font-medium',
primary: 'px-3 py-1 rounded-lg text-xs font-medium',
success: 'px-3 py-1 rounded-lg text-xs font-medium',
warning: 'px-3 py-1 rounded-lg text-xs font-medium',
danger: 'px-3 py-1 rounded-lg text-xs font-medium',
} as const
// 修饰符(不包含颜色)
export const baseButtonModifiers = {
compact: 'px-3 py-1.5 text-sm',
compactXs: 'px-2 py-1 text-xs',
icon: 'p-2 rounded-xl',
block: 'w-full justify-center',
pill: 'rounded-full',
} as const
export const baseInputModifiers = {
compact: 'px-3 py-2 text-sm rounded-lg',
compactXs: 'px-2.5 py-1.5 text-xs rounded-lg',
} as const
export const baseSpacingStyles = {
section: 'space-y-8', // 区块间距
card: 'space-y-6', // 卡片内间距
form: 'space-y-4', // 表单元素间距
compact: 'space-y-2', // 紧密间距
} as const
// Modal 基础样式(不包含颜色)
export const baseModalStyles = {
// 遮罩层
overlay: 'fixed inset-0 z-50 flex items-center justify-center p-4 transition-opacity duration-300',
// 弹窗容器
container: 'relative w-full max-h-[90vh] overflow-hidden transition-all duration-300',
// 弹窗内容
content: 'rounded-2xl border backdrop-blur-sm shadow-2xl transition-all duration-300',
// 弹窗尺寸
sizes: {
small: 'max-w-sm',
medium: 'max-w-md',
large: 'max-w-lg',
xlarge: 'max-w-2xl',
fullscreen: 'max-w-full h-full max-h-[90vh]',
},
// 弹窗头部
header: 'px-6 py-4 border-b flex items-center justify-between',
// 弹窗标题
title: 'text-lg font-semibold',
// 弹窗关闭按钮
closeButton: 'p-1 rounded-lg hover:bg-opacity-10 transition-colors duration-200',
// 弹窗主体
body: 'px-6 py-4 overflow-y-auto',
// 弹窗底部
footer: 'px-6 py-4 border-t flex items-center justify-end gap-3',
} as const

189
lib/ui-styles-composer.ts Normal file
View File

@ -0,0 +1,189 @@
/**
* UI 样式组合器
* 将基础样式和颜色主题组合成完整的样式配置
*/
import * as baseStyles from './ui-styles-base'
import * as lightColors from './ui-theme-colors-light'
import * as darkColors from './ui-theme-colors-dark'
// 定义颜色主题的通用类型结构
type ColorTheme = {
card: {
border: string
bg: string
shadow: string
shadowHover: string
shadowLarge: string
shadowLargeHover: string
shadowRainbow: string
shadowRainbowHover: string
shadowGlass: string
shadowGlassHover: string
shadowNeon: string
shadowNeonHover: string
borderNeon: string
bgRainbow: string
bgGlass: string
borderGlass: string
}
button: {
primary: { bg: string; hover: string; shadow: string; shadowHover: string }
secondary: { border: string; bg: string; text: string; hover: string }
ghost: { text: string; hover: string }
danger: { bg: string; hover: string; shadow: string; shadowHover: string }
success: { bg: string; hover: string; shadow: string; shadowHover: string }
glass: { bg: string; hover: string; border: string }
}
input: {
border: string
bg: string
text: string
placeholder: string
focusRing: string
errorBorder: string
errorBg: string
errorText: string
errorPlaceholder: string
errorFocusRing: string
}
navItem: {
border: string
bg: string
text: string
hoverBg: string
hoverShadow: string
activeBg: string
activeText: string
activeShadow: string
rainbowBg: string
rainbowShadow: string
tabText: string
tabTextHover: string
tabTextActive: string
}
badge: {
primary: { bg: string; text: string }
success: { bg: string; text: string }
warning: { bg: string; text: string }
danger: { bg: string; text: string }
}
modal: {
overlay: string
bg: string
border: string
shadow: string
headerBorder: string
title: string
body: string
footerBorder: string
closeButton: string
}
}
/**
* 组合卡片样式
*/
function composeCardStyles(colors: ColorTheme) {
return {
base: `${baseStyles.baseCardStyles.base} ${colors.card.border} ${colors.card.bg} ${colors.card.shadow}`,
hover: `${baseStyles.baseCardStyles.hover} ${colors.card.border} ${colors.card.bg} ${colors.card.shadow} ${colors.card.shadowHover}`,
large: `${baseStyles.baseCardStyles.large} ${colors.card.border} ${colors.card.bg} ${colors.card.shadowLarge} ${colors.card.shadowLargeHover}`,
flat: `${baseStyles.baseCardStyles.flat} ${colors.card.border} ${colors.card.bg}`,
rainbow: `${baseStyles.baseCardStyles.rainbow} ${colors.card.border} ${colors.card.bgRainbow} ${colors.card.shadowRainbow} ${colors.card.shadowRainbowHover}`,
glass: `${baseStyles.baseCardStyles.glass} ${colors.card.bgGlass} ${colors.card.borderGlass} ${colors.card.shadowGlass} ${colors.card.shadowGlassHover}`,
neon: `${baseStyles.baseCardStyles.neon} ${colors.card.borderNeon} ${colors.card.bg} ${colors.card.shadowNeon} ${colors.card.shadowNeonHover}`,
} as const
}
/**
* 组合按钮样式
*/
function composeButtonStyles(colors: ColorTheme) {
return {
primary: `${baseStyles.baseButtonStyles.primary} ${colors.button.primary.bg} ${colors.button.primary.hover} ${colors.button.primary.shadow} ${colors.button.primary.shadowHover}`,
secondary: `${baseStyles.baseButtonStyles.secondary} ${colors.button.secondary.border} ${colors.button.secondary.bg} ${colors.button.secondary.text} ${colors.button.secondary.hover}`,
ghost: `${baseStyles.baseButtonStyles.ghost} ${colors.button.ghost.text} ${colors.button.ghost.hover}`,
danger: `${baseStyles.baseButtonStyles.danger} ${colors.button.danger.bg} ${colors.button.danger.hover} ${colors.button.danger.shadow} ${colors.button.danger.shadowHover}`,
success: `${baseStyles.baseButtonStyles.success} ${colors.button.success.bg} ${colors.button.success.hover} ${colors.button.success.shadow} ${colors.button.success.shadowHover}`,
rainbow: baseStyles.baseButtonStyles.rainbow, // 渐变按钮颜色固定
neon: baseStyles.baseButtonStyles.neon, // 霓虹按钮颜色固定
glass: `${baseStyles.baseButtonStyles.glass} ${colors.button.glass.bg} ${colors.button.glass.hover} ${colors.button.glass.border}`,
} as const
}
/**
* 组合输入框样式
*/
function composeInputStyles(colors: ColorTheme) {
return {
base: `${baseStyles.baseInputStyles.base} ${colors.input.border} ${colors.input.bg} ${colors.input.text} ${colors.input.placeholder} ${colors.input.focusRing}`,
error: `${baseStyles.baseInputStyles.error} ${colors.input.errorBorder} ${colors.input.errorBg} ${colors.input.errorText} ${colors.input.errorPlaceholder} ${colors.input.errorFocusRing}`,
} as const
}
/**
* 组合导航项样式
*/
function composeNavItemStyles(colors: ColorTheme) {
return {
base: `${baseStyles.baseNavItemStyles.base} ${colors.navItem.border} ${colors.navItem.bg} ${colors.navItem.text}`,
hover: `${baseStyles.baseNavItemStyles.hover} ${colors.navItem.border} ${colors.navItem.bg} ${colors.navItem.hoverBg} ${colors.navItem.hoverShadow} ${colors.navItem.text}`,
active: `${baseStyles.baseNavItemStyles.active} ${colors.navItem.activeBg} ${colors.navItem.activeText} ${colors.navItem.activeShadow}`,
rainbow: `${baseStyles.baseNavItemStyles.rainbow} ${colors.navItem.border} ${colors.navItem.rainbowBg} ${colors.navItem.rainbowShadow} ${colors.navItem.text}`,
tabBase: `${baseStyles.baseNavItemStyles.tabBase} ${colors.navItem.tabText} ${colors.navItem.tabTextHover}`,
tabActive: `${baseStyles.baseNavItemStyles.tabActive} ${colors.navItem.tabTextActive}`,
} as const
}
/**
* 组合徽章样式
*/
function composeBadgeStyles(colors: ColorTheme) {
return {
base: baseStyles.baseBadgeStyles.base,
primary: `${baseStyles.baseBadgeStyles.primary} ${colors.badge.primary.bg} ${colors.badge.primary.text}`,
success: `${baseStyles.baseBadgeStyles.success} ${colors.badge.success.bg} ${colors.badge.success.text}`,
warning: `${baseStyles.baseBadgeStyles.warning} ${colors.badge.warning.bg} ${colors.badge.warning.text}`,
danger: `${baseStyles.baseBadgeStyles.danger} ${colors.badge.danger.bg} ${colors.badge.danger.text}`,
} as const
}
/**
* 组合Modal样式
*/
function composeModalStyles(colors: ColorTheme) {
return {
overlay: `${baseStyles.baseModalStyles.overlay} ${colors.modal.overlay}`,
container: baseStyles.baseModalStyles.container,
content: `${baseStyles.baseModalStyles.content} ${colors.modal.bg} ${colors.modal.border} ${colors.modal.shadow}`,
sizes: baseStyles.baseModalStyles.sizes,
header: `${baseStyles.baseModalStyles.header} ${colors.modal.headerBorder}`,
title: `${baseStyles.baseModalStyles.title} ${colors.modal.title}`,
closeButton: `${baseStyles.baseModalStyles.closeButton} ${colors.modal.closeButton}`,
body: `${baseStyles.baseModalStyles.body} ${colors.modal.body}`,
footer: `${baseStyles.baseModalStyles.footer} ${colors.modal.footerBorder}`,
} as const
}
/**
* 组合完整的样式配置
*/
export function composeThemeStyles(colors: ColorTheme) {
return {
cardStyles: composeCardStyles(colors),
buttonStyles: composeButtonStyles(colors),
buttonModifiers: baseStyles.baseButtonModifiers,
inputStyles: composeInputStyles(colors),
inputModifiers: baseStyles.baseInputModifiers,
navItemStyles: composeNavItemStyles(colors),
badgeStyles: composeBadgeStyles(colors),
modalStyles: composeModalStyles(colors),
spacingStyles: baseStyles.baseSpacingStyles,
} as const
}
// 导出浅色和深色主题的完整样式
export const lightThemeStyles = composeThemeStyles(lightColors.themeColors)
export const darkThemeStyles = composeThemeStyles(darkColors.themeColors)

20
lib/ui-styles-dark.ts Normal file
View File

@ -0,0 +1,20 @@
/**
* 深色模式 UI 样式配置
* 由基础样式和颜色主题组合生成
*
* ⚠️ 注意:此文件由 ui-styles-composer.ts 自动生成
* 如需修改布局、大小、动画等,请修改 lib/ui-styles-base.ts
* 如需修改颜色,请修改 lib/ui-theme-colors-dark.ts
*/
import { darkThemeStyles } from './ui-styles-composer'
export const cardStyles = darkThemeStyles.cardStyles
export const buttonStyles = darkThemeStyles.buttonStyles
export const buttonModifiers = darkThemeStyles.buttonModifiers
export const inputStyles = darkThemeStyles.inputStyles
export const inputModifiers = darkThemeStyles.inputModifiers
export const navItemStyles = darkThemeStyles.navItemStyles
export const badgeStyles = darkThemeStyles.badgeStyles
export const modalStyles = darkThemeStyles.modalStyles
export const spacingStyles = darkThemeStyles.spacingStyles

20
lib/ui-styles-light.ts Normal file
View File

@ -0,0 +1,20 @@
/**
* 浅色模式 UI 样式配置
* 由基础样式和颜色主题组合生成
*
* ⚠️ 注意:此文件由 ui-styles-composer.ts 自动生成
* 如需修改布局、大小、动画等,请修改 lib/ui-styles-base.ts
* 如需修改颜色,请修改 lib/ui-theme-colors-light.ts
*/
import { lightThemeStyles } from './ui-styles-composer'
export const cardStyles = lightThemeStyles.cardStyles
export const buttonStyles = lightThemeStyles.buttonStyles
export const buttonModifiers = lightThemeStyles.buttonModifiers
export const inputStyles = lightThemeStyles.inputStyles
export const inputModifiers = lightThemeStyles.inputModifiers
export const navItemStyles = lightThemeStyles.navItemStyles
export const badgeStyles = lightThemeStyles.badgeStyles
export const modalStyles = lightThemeStyles.modalStyles
export const spacingStyles = lightThemeStyles.spacingStyles

View File

@ -0,0 +1,8 @@
/**
* 主题样式导出文件
* 统一导出浅色和深色配置
*/
export * as lightStyles from './ui-styles-light'
export * as darkStyles from './ui-styles-dark'

151
lib/ui-styles.ts Normal file
View File

@ -0,0 +1,151 @@
/**
* 统一的 UI 样式工具类
* 苹果风格 + 炫彩元素
*
* ⚠️ 注意:此文件已重构为支持两套独立配置(浅色/深色)
*
* 推荐使用方式:
* 1. 在新组件中使用 `useThemeStyles()` Hook(推荐)
* 2. 在服务端组件或需要向后兼容的地方,继续使用此文件的导出
*
* 迁移步骤:
* - 将组件改为客户端组件('use client')
* - 使用 `const styles = useThemeStyles()` 替代直接导入
* - 使用 `styles.cardStyles.base` 替代 `cardStyles.base`
*/
import { gradientBackgrounds, glowEffects, cardAnimations } from './ui-animations'
// 导出主题 Hook(推荐使用)
export { useThemeStyles, useTheme } from './hooks/useThemeStyles'
// 导出两套独立的样式配置
export { lightStyles, darkStyles } from './ui-styles-theme-exports'
// 为了向后兼容,保留原有导出(包含 dark: 前缀)
// ⚠️ 这些样式会在深色模式下正确工作,但代码较长
// 建议逐步迁移到 useThemeStyles Hook
export const cardStyles = {
// 基础卡片 - 确保深色模式完全适配(兼容模式)
base: 'rounded-2xl border border-slate-200/70 dark:border-slate-700/70 bg-white dark:bg-slate-800 backdrop-blur-sm shadow-lg shadow-slate-200/50 dark:shadow-black/50 p-6 transition-all duration-300',
// 悬停卡片(带提升效果)- 确保深色模式完全适配
hover: 'rounded-2xl border border-slate-200/70 dark:border-slate-700/70 bg-white dark:bg-slate-800 backdrop-blur-sm shadow-lg shadow-slate-200/50 dark:shadow-black/50 hover:shadow-xl hover:shadow-indigo-500/20 dark:hover:shadow-indigo-500/30 hover:-translate-y-1 transition-all duration-300 p-6',
// 大卡片(带炫彩光晕)- 确保深色模式完全适配
large: 'rounded-3xl border border-slate-200/70 dark:border-slate-700/70 bg-white dark:bg-slate-800 backdrop-blur-md shadow-xl shadow-slate-200/60 dark:shadow-black/60 hover:shadow-2xl hover:shadow-indigo-500/25 dark:hover:shadow-indigo-500/35 transition-all duration-500 p-8',
// 扁平卡片 - 确保深色模式完全适配
flat: 'rounded-2xl border border-slate-200/70 dark:border-slate-700/70 bg-white dark:bg-slate-800 backdrop-blur-sm p-6 transition-all duration-300',
// 炫彩卡片(带渐变背景)- 确保深色模式完全适配
rainbow: `rounded-2xl border border-slate-200/70 dark:border-slate-700/70 ${gradientBackgrounds.rainbowSubtle} backdrop-blur-sm shadow-lg shadow-purple-500/20 dark:shadow-purple-500/30 hover:shadow-xl hover:shadow-purple-500/30 dark:hover:shadow-purple-500/40 hover:-translate-y-1 transition-all duration-300 p-6`,
// 玻璃态卡片 - 确保深色模式完全适配
glass: `rounded-2xl ${gradientBackgrounds.glass} shadow-lg shadow-slate-200/50 dark:shadow-black/50 hover:shadow-xl hover:shadow-indigo-500/20 dark:hover:shadow-indigo-500/30 transition-all duration-300 p-6`,
// 霓虹卡片 - 确保深色模式完全适配
neon: 'rounded-2xl border-2 border-cyan-400/50 dark:border-cyan-400/70 bg-white dark:bg-slate-800 backdrop-blur-sm shadow-lg shadow-cyan-500/30 dark:shadow-cyan-500/40 hover:shadow-xl hover:shadow-cyan-500/50 dark:hover:shadow-cyan-500/60 transition-all duration-300 p-6',
} as const
export const buttonStyles = {
// 主要按钮(带炫彩光晕)- 确保深色模式完全适配
primary: 'px-6 py-3 rounded-xl bg-indigo-600 dark:bg-indigo-500 text-white font-medium shadow-lg shadow-indigo-500/40 dark:shadow-indigo-500/50 hover:bg-indigo-700 dark:hover:bg-indigo-600 hover:shadow-xl hover:shadow-indigo-500/50 dark:hover:shadow-indigo-500/60 active:scale-95 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed',
// 次要按钮 - 确保深色模式完全适配
secondary: 'px-6 py-3 rounded-xl border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 backdrop-blur-sm text-slate-700 dark:text-slate-200 font-medium hover:bg-slate-50 dark:hover:bg-slate-700 hover:border-indigo-400 dark:hover:border-indigo-400 active:scale-95 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed',
// 幽灵按钮 - 确保深色模式完全适配
ghost: 'px-6 py-3 rounded-xl text-slate-700 dark:text-slate-200 font-medium hover:bg-slate-100 dark:hover:bg-slate-800 active:scale-95 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed',
// 危险按钮 - 确保深色模式完全适配
danger: 'px-6 py-3 rounded-xl bg-red-600 dark:bg-red-500 text-white font-medium shadow-lg shadow-red-500/40 dark:shadow-red-500/50 hover:bg-red-700 dark:hover:bg-red-600 hover:shadow-xl hover:shadow-red-500/50 dark:hover:shadow-red-500/60 active:scale-95 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed',
// 成功按钮 - 确保深色模式完全适配
success: 'px-6 py-3 rounded-xl bg-emerald-600 dark:bg-emerald-500 text-white font-medium shadow-lg shadow-emerald-500/40 dark:shadow-emerald-500/50 hover:bg-emerald-700 dark:hover:bg-emerald-600 hover:shadow-xl hover:shadow-emerald-500/50 dark:hover:shadow-emerald-500/60 active:scale-95 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed',
// 炫彩渐变按钮
rainbow: 'px-6 py-3 rounded-xl bg-gradient-to-r from-pink-500 via-purple-500 to-indigo-500 text-white font-medium shadow-lg shadow-purple-500/40 hover:from-pink-600 hover:via-purple-600 hover:to-indigo-600 hover:shadow-xl hover:shadow-purple-500/50 active:scale-95 transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed',
// 霓虹按钮
neon: 'px-6 py-3 rounded-xl bg-cyan-500 text-white font-medium shadow-lg shadow-cyan-500/50 hover:bg-cyan-400 hover:shadow-xl hover:shadow-cyan-500/70 active:scale-95 transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed',
// 玻璃态按钮
glass: 'px-6 py-3 rounded-xl bg-white/10 dark:bg-white/5 backdrop-blur-md border border-white/20 text-white font-medium hover:bg-white/20 dark:hover:bg-white/10 hover:border-white/30 active:scale-95 transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed',
} as const
export const buttonModifiers = {
compact: 'px-3 py-1.5 text-sm',
compactXs: 'px-2 py-1 text-xs',
icon: 'p-2 rounded-xl',
block: 'w-full justify-center',
pill: 'rounded-full',
} as const
export const inputStyles = {
// 基础输入框 - 确保深色模式完全适配
base: 'w-full px-4 py-3 rounded-xl border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-slate-400 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',
// 错误输入框 - 确保深色模式完全适配
error: 'w-full px-4 py-3 rounded-xl border-2 border-red-500 dark:border-red-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-slate-400 focus:ring-2 focus:ring-red-200 dark:focus:ring-red-800 outline-none transition-all',
} as const
export const inputModifiers = {
compact: 'px-3 py-2 text-sm rounded-lg',
compactXs: 'px-2.5 py-1.5 text-xs rounded-lg',
} as const
export const navItemStyles = {
// 基础导航项 - 确保深色模式完全适配
base: 'px-4 py-3 rounded-xl border border-slate-200/70 dark:border-slate-700/70 bg-white dark:bg-slate-800 backdrop-blur-sm text-slate-700 dark:text-slate-200 transition-all duration-300',
// 悬停导航项(带光晕效果)- 确保深色模式完全适配
hover: 'px-4 py-3 rounded-xl border border-slate-200/70 dark:border-slate-700/70 bg-white dark:bg-slate-800 backdrop-blur-sm hover:border-indigo-400 dark:hover:border-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 hover:shadow-md hover:shadow-indigo-500/20 dark:hover:shadow-indigo-500/30 text-slate-700 dark:text-slate-200 transition-all duration-300',
// 激活导航项(带炫彩光晕)- 确保深色模式完全适配
active: 'px-4 py-3 rounded-xl border-2 border-indigo-500 dark:border-indigo-400 bg-indigo-50 dark:bg-indigo-900/40 backdrop-blur-sm text-indigo-700 dark:text-indigo-200 shadow-lg shadow-indigo-500/30 dark:shadow-indigo-500/40 transition-all duration-300',
// 炫彩导航项 - 确保深色模式完全适配
rainbow: 'px-4 py-3 rounded-xl border border-slate-200/70 dark:border-slate-700/70 bg-gradient-to-r from-pink-50/50 via-purple-50/50 to-indigo-50/50 dark:from-pink-900/20 dark:via-purple-900/20 dark:to-indigo-900/20 backdrop-blur-sm hover:shadow-md hover:shadow-purple-500/20 dark:hover:shadow-purple-500/30 text-slate-700 dark:text-slate-200 transition-all duration-300',
// 标签页导航项 - 确保深色模式完全适配
tabBase: 'relative pb-4 px-5 font-medium transition-all duration-300 ease-out text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white',
tabActive: 'relative pb-4 px-5 font-medium transition-all duration-300 ease-out text-slate-900 dark:text-white',
} as const
export const badgeStyles = {
base: 'px-3 py-1 rounded-lg text-xs font-medium',
primary: 'px-3 py-1 rounded-lg bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300 text-xs font-medium',
success: 'px-3 py-1 rounded-lg bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 text-xs font-medium',
warning: 'px-3 py-1 rounded-lg bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300 text-xs font-medium',
danger: 'px-3 py-1 rounded-lg bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-xs font-medium',
} as const
export const spacingStyles = {
section: 'space-y-8', // 区块间距
card: 'space-y-6', // 卡片内间距
form: 'space-y-4', // 表单元素间距
compact: 'space-y-2', // 紧密间距
} as const
/**
* 组合多个样式类
*/
export function cn(...classes: (string | undefined | false | null)[]): string {
return classes.filter(Boolean).join(' ')
}
/**
* 运行时根据主题获取样式配置
* ⚠️ 不推荐使用,建议在组件中使用 useThemeStyles Hook
*
* @param theme - 主题 'light' | 'dark'
* @returns 对应主题的样式配置
*/
export function getThemeStyles(theme: 'light' | 'dark') {
// 动态导入以避免类型冲突
if (theme === 'dark') {
return require('./ui-styles-dark')
}
return require('./ui-styles-light')
}

128
lib/ui-theme-colors-dark.ts Normal file
View File

@ -0,0 +1,128 @@
/**
* 深色模式颜色主题配置
* 仅包含颜色相关的样式类名
*/
export const themeColors = {
// 卡片颜色
card: {
border: 'border-slate-700/70',
bg: 'bg-slate-800',
shadow: 'shadow-black/50',
shadowHover: 'hover:shadow-indigo-500/30',
shadowLarge: 'shadow-black/60',
shadowLargeHover: 'hover:shadow-indigo-500/35',
shadowRainbow: 'shadow-purple-500/30',
shadowRainbowHover: 'hover:shadow-purple-500/40',
shadowGlass: 'shadow-black/50',
shadowGlassHover: 'hover:shadow-indigo-500/30',
shadowNeon: 'shadow-cyan-500/40',
shadowNeonHover: 'hover:shadow-cyan-500/60',
borderNeon: 'border-cyan-400/70',
bgRainbow: 'bg-gradient-to-br from-pink-900/20 via-purple-900/20 via-indigo-900/20 to-blue-900/20',
bgGlass: 'bg-white/5',
borderGlass: 'border-white/20',
},
// 按钮颜色
button: {
primary: {
bg: 'bg-indigo-500',
hover: 'hover:bg-indigo-600',
shadow: 'shadow-indigo-500/50',
shadowHover: 'hover:shadow-indigo-500/60',
},
secondary: {
border: 'border-slate-600',
bg: 'bg-slate-800',
text: 'text-slate-200',
hover: 'hover:bg-slate-700',
},
ghost: {
text: 'text-slate-200',
hover: 'hover:bg-slate-800',
},
danger: {
bg: 'bg-red-500',
hover: 'hover:bg-red-600',
shadow: 'shadow-red-500/50',
shadowHover: 'hover:shadow-red-500/60',
},
success: {
bg: 'bg-emerald-500',
hover: 'hover:bg-emerald-600',
shadow: 'shadow-emerald-500/50',
shadowHover: 'hover:shadow-emerald-500/60',
},
glass: {
bg: 'bg-white/5',
hover: 'hover:bg-white/10',
border: 'border-white/20',
},
},
// 输入框颜色
input: {
border: 'border-slate-600',
bg: 'bg-slate-800',
text: 'text-white',
placeholder: 'placeholder:text-slate-400',
focusRing: 'focus:ring-indigo-800',
errorBorder: 'border-red-600',
errorBg: 'bg-slate-800',
errorText: 'text-white',
errorPlaceholder: 'placeholder:text-slate-400',
errorFocusRing: 'focus:ring-red-800',
},
// 导航项颜色
navItem: {
border: 'border-slate-700/70',
bg: 'bg-slate-800',
text: 'text-slate-200',
hoverBg: 'hover:bg-indigo-900/30',
hoverShadow: 'hover:shadow-indigo-500/30',
activeBg: 'bg-indigo-900/40',
activeText: 'text-indigo-200',
activeShadow: 'shadow-indigo-500/40',
rainbowBg: 'bg-gradient-to-r from-pink-900/20 via-purple-900/20 to-indigo-900/20',
rainbowShadow: 'hover:shadow-purple-500/30',
tabText: 'text-slate-400',
tabTextHover: 'hover:text-white',
tabTextActive: 'text-white',
},
// 徽章颜色
badge: {
primary: {
bg: 'bg-indigo-900/30',
text: 'text-indigo-300',
},
success: {
bg: 'bg-green-900/30',
text: 'text-green-300',
},
warning: {
bg: 'bg-yellow-900/30',
text: 'text-yellow-300',
},
danger: {
bg: 'bg-red-900/30',
text: 'text-red-300',
},
},
// Modal 颜色
modal: {
overlay: 'bg-black/70',
bg: 'bg-slate-800',
border: 'border-slate-700/70',
shadow: 'shadow-black/60',
headerBorder: 'border-slate-700/70',
title: 'text-white',
body: 'text-slate-200',
footerBorder: 'border-slate-700/70',
closeButton: 'text-slate-400 hover:text-slate-200 hover:bg-slate-700',
},
} as const

View File

@ -0,0 +1,128 @@
/**
* 浅色模式颜色主题配置
* 仅包含颜色相关的样式类名
*/
export const themeColors = {
// 卡片颜色
card: {
border: 'border-slate-200/70',
bg: 'bg-white',
shadow: 'shadow-slate-200/50',
shadowHover: 'hover:shadow-indigo-500/20',
shadowLarge: 'shadow-slate-200/60',
shadowLargeHover: 'hover:shadow-indigo-500/25',
shadowRainbow: 'shadow-purple-500/20',
shadowRainbowHover: 'hover:shadow-purple-500/30',
shadowGlass: 'shadow-slate-200/50',
shadowGlassHover: 'hover:shadow-indigo-500/20',
shadowNeon: 'shadow-cyan-500/30',
shadowNeonHover: 'hover:shadow-cyan-500/50',
borderNeon: 'border-cyan-400/50',
bgRainbow: 'bg-gradient-to-br from-pink-100 via-purple-100 via-indigo-100 to-blue-100',
bgGlass: 'bg-white/10',
borderGlass: 'border-white/20',
},
// 按钮颜色
button: {
primary: {
bg: 'bg-indigo-600',
hover: 'hover:bg-indigo-700',
shadow: 'shadow-indigo-500/40',
shadowHover: 'hover:shadow-indigo-500/50',
},
secondary: {
border: 'border-slate-300',
bg: 'bg-white',
text: 'text-slate-700',
hover: 'hover:bg-slate-50',
},
ghost: {
text: 'text-slate-700',
hover: 'hover:bg-slate-100',
},
danger: {
bg: 'bg-red-600',
hover: 'hover:bg-red-700',
shadow: 'shadow-red-500/40',
shadowHover: 'hover:shadow-red-500/50',
},
success: {
bg: 'bg-emerald-600',
hover: 'hover:bg-emerald-700',
shadow: 'shadow-emerald-500/40',
shadowHover: 'hover:shadow-emerald-500/50',
},
glass: {
bg: 'bg-white/10',
hover: 'hover:bg-white/20',
border: 'border-white/20',
},
},
// 输入框颜色
input: {
border: 'border-slate-300',
bg: 'bg-white',
text: 'text-slate-900',
placeholder: 'placeholder:text-slate-400',
focusRing: 'focus:ring-indigo-200',
errorBorder: 'border-red-500',
errorBg: 'bg-white',
errorText: 'text-slate-900',
errorPlaceholder: 'placeholder:text-slate-400',
errorFocusRing: 'focus:ring-red-200',
},
// 导航项颜色
navItem: {
border: 'border-slate-200/70',
bg: 'bg-white',
text: 'text-slate-700',
hoverBg: 'hover:bg-indigo-50',
hoverShadow: 'hover:shadow-indigo-500/20',
activeBg: 'bg-indigo-50',
activeText: 'text-indigo-700',
activeShadow: 'shadow-indigo-500/30',
rainbowBg: 'bg-gradient-to-r from-pink-50/50 via-purple-50/50 to-indigo-50/50',
rainbowShadow: 'hover:shadow-purple-500/20',
tabText: 'text-slate-600',
tabTextHover: 'hover:text-slate-900',
tabTextActive: 'text-slate-900',
},
// 徽章颜色
badge: {
primary: {
bg: 'bg-indigo-100',
text: 'text-indigo-700',
},
success: {
bg: 'bg-green-100',
text: 'text-green-700',
},
warning: {
bg: 'bg-yellow-100',
text: 'text-yellow-700',
},
danger: {
bg: 'bg-red-100',
text: 'text-red-700',
},
},
// Modal 颜色
modal: {
overlay: 'bg-black/50',
bg: 'bg-white',
border: 'border-slate-200/70',
shadow: 'shadow-slate-200/50',
headerBorder: 'border-slate-200/70',
title: 'text-slate-900',
body: 'text-slate-700',
footerBorder: 'border-slate-200/70',
closeButton: 'text-slate-400 hover:text-slate-600 hover:bg-slate-100',
},
} as const

View File

@ -6,7 +6,7 @@ const config: Config = {
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
],
darkMode: 'media', // 自动跟随系统黑暗模式设置
darkMode: 'class',
theme: {
extend: {
colors: {
@ -25,10 +25,45 @@ const config: Config = {
},
borderRadius: {
'apple': '12px',
'card': '16px', // 标准卡片圆角
'card-lg': '24px', // 大卡片圆角
},
boxShadow: {
'apple': '0 4px 12px rgba(0, 0, 0, 0.08)',
'apple-lg': '0 8px 24px rgba(0, 0, 0, 0.12)',
'card': '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
'card-hover': '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
'card-lg': '0 25px 50px -12px rgba(0, 0, 0, 0.15)',
},
spacing: {
'card': '24px', // 标准卡片内边距
'section': '32px', // 区块间距
},
animation: {
'spin-slow': 'spin 3s linear infinite',
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'shimmer': 'shimmer 2s infinite',
'glow': 'glow 2s ease-in-out infinite alternate',
'float': 'float 3s ease-in-out infinite',
'gradient': 'gradient 8s ease infinite',
},
keyframes: {
shimmer: {
'0%': { transform: 'translateX(-100%)' },
'100%': { transform: 'translateX(100%)' },
},
glow: {
'0%': { opacity: '0.5', transform: 'scale(1)' },
'100%': { opacity: '1', transform: 'scale(1.05)' },
},
float: {
'0%, 100%': { transform: 'translateY(0px)' },
'50%': { transform: 'translateY(-10px)' },
},
gradient: {
'0%, 100%': { backgroundPosition: '0% 50%' },
'50%': { backgroundPosition: '100% 50%' },
},
},
},
},

540
优化计划与优先级.md Normal file
View File

@ -0,0 +1,540 @@
# 优化计划与优先级
> **项目宗旨**:性能开销优先、稳定性优先、易于部署、统一UI三层架构、遵循苹果设计语言
## 📋 优化原则
1. **统一UI三层架构优先**:所有UI改动必须遵循三层架构(基础层、颜色层、组合层)
2. **基础组件优先**:先建立统一的基础组件,再实现具体功能
3. **数据一致性优先**:确保数据同步和一致性
4. **性能优化优先**:考虑低性能NAS机器,使用动态加载、瀑布流等
5. **用户体验优先**:修复已知问题,优化交互体验
---
## 🎯 优先级规划
### 优先级 1:基础组件与UI统一(核心基础设施)
#### 1.1 创建统一弹窗组件 ⭐⭐⭐⭐⭐
**需求编号**:16
**优先级**:最高
**状态**:✅ 已完成(部分)
**原因**:
- 这是所有UI统一的基础
- 后续多个功能都需要使用弹窗
- 必须遵循三层架构设计
- 替换浏览器原生弹窗,提升视觉一致性
**实施要点**:
- ✅ 创建 `components/ui/Modal.tsx` 统一弹窗组件
- ✅ 使用 `useThemeStyles()` 和三层架构
- ✅ 支持多种尺寸(small, medium, large, xlarge, fullscreen)
- ✅ 支持关闭动画、遮罩层、键盘ESC关闭
- ✅ 提供 `useModal` Hook(`hooks/useModal.ts`)统一管理弹窗状态
- ✅ 完整的ARIA标签和键盘导航支持
- ✅ 样式参考苹果设计语言,遵循统一视觉方案
- ⏳ 替换所有浏览器原生弹窗(confirm/alert)- 待后续逐步替换
**依赖关系**:无(基础组件)
**影响范围**:全局(所有需要弹窗的地方)
---
#### 1.2 素材预览面板统一 ⭐⭐⭐⭐⭐
**需求编号**:15
**优先级**:最高
**原因**:
- 统一退出逻辑和交互体验
- 必须使用统一弹窗组件(依赖1.1)
- 提升用户体验一致性
**实施要点**:
- 使用统一弹窗组件重构素材预览面板
- 统一退出逻辑(ESC键、点击遮罩、关闭按钮)
- 使用三层架构样式系统
- 确保深浅色模式适配
**依赖关系**:依赖 1.1(统一弹窗组件)
**影响范围**:首页素材预览
---
#### 1.3 首页素材展示响应式优化 ⭐⭐⭐⭐
**需求编号**:8
**优先级**:高
**原因**:
- 修复已知UI问题(卡片比例、边界间距)
- 必须遵循统一UI原则
- 影响用户体验
**实施要点**:
- 修复卡片比例固定问题(使用 `aspect-ratio`)
- 添加合适的边距和间距(使用 `spacingStyles`)
- 使用三层架构样式系统
- 响应式断点优化
- 确保深浅色模式适配
**依赖关系**:无
**影响范围**:首页素材展示
---
#### 1.4 首页UI统一到三层架构 ⭐⭐⭐⭐
**需求编号**:22
**优先级**:高
**原因**:
- 统一UI架构,确保视觉一致性
- 首页是核心页面,必须遵循三层架构
**实施要点**:
- 将首页所有UI组件迁移到三层架构
- 使用 `useThemeStyles()` Hook
- 移除所有 `dark:` 前缀样式
- 确保深浅色模式完美适配
**依赖关系**:无
**影响范围**:首页所有UI组件
---
#### 1.5 统一UI三层架构通用组件库 ⭐⭐⭐⭐
**需求编号**:23
**优先级**:高
**原因**:
- 提高开发效率
- 确保组件一致性
- 减少重复代码
**实施要点**:
- 在 `components/ui/` 目录下创建通用组件
- 所有组件必须使用三层架构
- 提供常用组件:Button、Input、Card、Badge、Tag、Avatar等
- 每个组件提供完整的TypeScript类型定义
- 使用 `useThemeStyles()` 确保主题适配
**依赖关系**:无(基础组件库)
**影响范围**:全局(所有使用通用组件的地方)
---
#### 1.6 深浅色主题适配优化 ⭐⭐⭐
**需求编号**:17
**优先级**:中
**原因**:
- 提升主题切换体验
- 确保所有组件在深浅色模式下都完美显示
**实施要点**:
- 检查所有组件的深浅色适配
- 优化颜色对比度,确保可读性
- 统一过渡动画效果
- 测试所有页面的主题切换
**依赖关系**:建议在 1.4(首页UI统一)之后
**影响范围**:全局
---
#### 1.7 低饱和度炫彩元素设计 ⭐⭐⭐
**需求编号**:18
**优先级**:中
**原因**:
- 遵循苹果设计语言
- 提升视觉美感
- 保持统一视觉方案
**实施要点**:
- 在颜色层(`ui-theme-colors-*.ts`)中添加低饱和度炫彩元素
- 用于标签、徽章、强调色等场景
- 确保深浅色模式都有对应的炫彩元素
- 参考苹果设计语言,但遵循统一视觉方案
**依赖关系**:建议在 1.5(通用组件库)之后
**影响范围**:全局UI元素
---
### 优先级 2:数据一致性与核心功能(业务逻辑)
#### 2.1 用户信息修改后同步素材信息 ⭐⭐⭐⭐⭐
**需求编号**:1
**优先级**:最高(业务逻辑)
**原因**:
- 数据一致性是核心要求
- 影响数据准确性
- 已有 `useUserOperations` hook,可以在此扩展
**实施要点**:
- 在 `useUserOperations.updateUser` 中扩展逻辑
- 当用户信息(用户名、部门)修改时,同步更新该用户上传的所有素材
- 使用事务确保数据一致性
- 添加错误处理和回滚机制
**依赖关系**:无
**影响范围**:用户管理、素材数据
---
#### 2.2 模板管理整合到上传和分类 ⭐⭐⭐⭐⭐
**需求编号**:14
**优先级**:最高(核心功能)
**原因**:
- 这是核心业务逻辑,将前后端通过模板串起来
- 影响整个系统的数据架构
- 需要严格检查模板管理面板逻辑
**实施要点**:
- 首页上传素材面板:将"素材类型"改为"素材模板",从模板管理拉取模板名称
- 选择模板后,动态显示对应模板的字段
- 系统管理中的分类管理:使用模板管理中的下拉元素文字做分类
- 严格检查模板管理面板逻辑:
- 字段验证(键名唯一性、类型检查)
- 模板保存/更新逻辑
- 模板删除时的依赖检查
- 模板使用情况统计
- 确保数据一致性:模板修改后,已上传的素材如何处理
**依赖关系**:无(但需要先确保模板管理逻辑完善)
**影响范围**:上传功能、分类管理、模板管理
---
#### 2.3 首页类型筛选TAG系统 ⭐⭐⭐⭐
**需求编号**:19
**优先级**:高
**原因**:
- 与模板管理整合相关,是核心功能的一部分
- 提升用户体验,方便筛选
- TAG样式需要符合整体风格
**实施要点**:
- 将筛选内的类型分页功能移到首页
- 使用TAG形式显示,第一个TAG是"全部"
- TAG数据来源:根据上传模板设置(模板管理中的下拉元素文字)
- TAG样式设计:好看大气,符合整体风格
- 使用三层架构样式系统
- 支持点击切换筛选
- 考虑低饱和度炫彩元素(需求18)
**依赖关系**:依赖 2.2(模板管理整合)
**影响范围**:首页筛选功能
---
### 优先级 3:用户体验优化(交互与视觉)
#### 3.1 上传素材进度优化 ⭐⭐⭐⭐
**需求编号**:2
**优先级**:高
**原因**:
- 修复已知问题,影响用户体验
- 进度到100%后延迟关闭的问题
**实施要点**:
- 检查上传完成后的处理逻辑
- 确保进度100%后立即关闭弹窗
- 可能需要添加延迟以确保数据保存完成
- 优化用户体验:显示"上传完成"状态
**依赖关系**:建议使用统一弹窗组件(1.1)
**影响范围**:上传功能
---
#### 3.2 素材卡片信息优化 ⭐⭐⭐⭐
**需求编号**:10
**优先级**:高
**原因**:
- 优化空间占用,提升视觉体验
- 必须遵循统一UI原则
**实施要点**:
- 移除"作者:"文字,直接显示作者名字
- 作者名字前显示头像
- 移除文件名和文件大小显示
- 上传时间叠在预览图右上角,默认隐藏,hover时显示
- 使用三层架构样式系统
- 确保hover状态的可见性
**依赖关系**:无
**影响范围**:首页素材卡片
---
#### 3.3 素材卡片标签系统 ⭐⭐⭐
**需求编号**:11
**优先级**:中
**原因**:
- UI增强功能
- 需要参考最高权限显示UI
- 必须遵循统一视觉方案
**实施要点**:
- 在素材名称前添加标签(点赞榜、精品榜、下载榜、收藏榜等)
- 样式参考最高权限显示UI
- 偏向苹果UI设计风格
- 使用三层架构样式系统
- 标签数据来源:排行榜数据
**依赖关系**:无
**影响范围**:首页素材卡片
---
#### 3.4 素材卡片大小设置和瀑布流 ⭐⭐⭐⭐
**需求编号**:9
**优先级**:高
**原因**:
- 性能优化(动态加载)
- 用户体验提升(自定义大小)
- 考虑低性能NAS机器
**实施要点**:
- 添加横向滑块设置卡片大小
- 实现瀑布流布局(使用 `react-masonry-css` 或自定义实现)
- 动态加载卡片数量(初始加载20-30个,滚动加载更多)
- 使用 `IntersectionObserver` 实现懒加载
- 考虑性能:图片懒加载、虚拟滚动(如需要)
- 使用三层架构样式系统
**依赖关系**:建议在 3.2(素材卡片信息优化)之后
**影响范围**:首页素材展示
---
#### 3.5 多预览图上传 ⭐⭐⭐
**需求编号**:3
**优先级**:中
**原因**:
- 功能增强
- 自动设置第一张为预览图
**实施要点**:
- 修改上传逻辑,支持多文件选择
- 自动将第一张设置为预览图
- 在素材详情中展示所有预览图
- 使用统一弹窗组件(1.1)展示预览图
**依赖关系**:建议使用统一弹窗组件(1.1)
**影响范围**:上传功能、素材详情
---
### 优先级 4:功能增强(次要功能)
#### 4.1 用户头像设置 ⭐⭐⭐
**需求编号**:6
**优先级**:中
**原因**:
- 功能增强
- 钉钉头像抓取逻辑
**实施要点**:
- 用户管理增加头像设置功能
- 用户可以自己设置头像
- 钉钉登录用户:尝试抓取钉钉头像,抓不到则不抓取
- 没有头像的用户:使用默认头像策略(和LOGO策略一样)
- 使用统一弹窗组件(1.1)进行头像上传/选择
**依赖关系**:建议使用统一弹窗组件(1.1)
**影响范围**:用户管理、个人中心
---
#### 4.2 个人中心功能 ⭐⭐⭐
**需求编号**:5
**优先级**:中
**原因**:
- 功能增强
- 用户个人信息管理
**实施要点**:
- 在点击头像弹出的菜单中增加"个人中心"选项
- 创建个人中心页面
- 显示用户信息、上传的素材、下载记录等
- 使用三层架构样式系统
**依赖关系**:建议使用统一弹窗组件(1.1)
**影响范围**:用户菜单、个人中心页面
---
#### 4.3 菜单调整(系统设置改名为管理、外观设置移动) ⭐⭐
**需求编号**:12、13
**优先级**:低
**原因**:
- UI调整,不影响核心功能
**实施要点**:
- 将"系统设置"改名为"管理",更换图标
- 将外观设置移动到点击头像弹出的菜单里
- 添加图标
- 使用三层架构样式系统
**依赖关系**:无
**影响范围**:用户菜单
---
#### 4.4 排行榜缩略按钮拖动 ⭐⭐
**需求编号**:7
**优先级**:低
**原因**:
- 交互优化,不影响核心功能
**实施要点**:
- 排行榜关闭后的缩略按钮可拖动
- 位置根据排行榜悬浮窗关闭前的位置
- 使用 `react-draggable` 或自定义实现
**依赖关系**:无
**影响范围**:排行榜组件
---
#### 4.5 性能监控导出CSV ⭐⭐
**需求编号**:4
**优先级**:低
**原因**:
- 功能增强,非核心功能
**实施要点**:
- 在性能监控版添加导出CSV按钮
- 使用 `papaparse` 或类似库生成CSV
- 使用三层架构样式系统
**依赖关系**:无
**影响范围**:性能监控页面
---
#### 4.6 系统监控数据点击跳转 ⭐⭐⭐
**需求编号**:20
**优先级**:中
**原因**:
- 提升数据可操作性
- 方便管理员快速查看详细数据
**实施要点**:
- 用户总数、活跃用户、总素材数、本月新用户、本月新素材等数据项可点击
- 点击后跳转到对应的数据显示页面(如用户管理、素材管理等)
- 使用三层架构样式系统
- 添加hover效果,提示可点击
**依赖关系**:无
**影响范围**:系统监控页面
---
#### 4.7 系统监控当月上传趋势 ⭐⭐
**需求编号**:21
**优先级**:低
**原因**:
- 功能增强,提供更详细的数据展示
**实施要点**:
- 在素材上传趋势卡片前增加当月上传趋势显示
- 可以以数字或图表形式展示
- 使用三层架构样式系统
- 确保数据准确性
**依赖关系**:无
**影响范围**:系统监控页面
---
## 📊 实施路线图
### 第一阶段:基础组件与UI统一(2-3周)
1. ✅ 创建统一弹窗组件(1.1)- 已完成基础组件,待替换原生弹窗
2. ⏳ 素材预览面板统一(1.2)
3. ⏳ 首页素材展示响应式优化(1.3)
4. ⏳ 首页UI统一到三层架构(1.4)
5. ⏳ 统一UI三层架构通用组件库(1.5)
6. ⏳ 深浅色主题适配优化(1.6)
7. ⏳ 低饱和度炫彩元素设计(1.7)
### 第二阶段:数据一致性与核心功能(2-3周)
8. ⏳ 用户信息修改后同步素材信息(2.1)
9. ⏳ 模板管理整合(2.2)- 需要严格测试
10. ⏳ 首页类型筛选TAG系统(2.3)
### 第三阶段:用户体验优化(2-3周)
11. ⏳ 上传素材进度优化(3.1)
12. ⏳ 素材卡片信息优化(3.2)
13. ⏳ 素材卡片大小设置和瀑布流(3.4)
14. ⏳ 素材卡片标签系统(3.3)
15. ⏳ 多预览图上传(3.5)
### 第四阶段:功能增强(1-2周)
16. ⏳ 用户头像设置(4.1)
17. ⏳ 个人中心功能(4.2)
18. ⏳ 菜单调整(4.3)
19. ⏳ 排行榜缩略按钮拖动(4.4)
20. ⏳ 性能监控导出CSV(4.5)
21. ⏳ 系统监控数据点击跳转(4.6)
22. ⏳ 系统监控当月上传趋势(4.7)
---
## 🎨 UI设计原则
### 统一UI三层架构
所有UI改动必须遵循:
1. **基础层**(`lib/ui-styles-base.ts`):布局、大小、动画
2. **颜色层**(`lib/ui-theme-colors-light.ts` / `dark.ts`):仅颜色配置
3. **组合层**(`lib/ui-styles-composer.ts`):自动组合
### 使用规范
```tsx
// ✅ 正确:使用 useThemeStyles()
import { useThemeStyles } from '@/lib/ui-styles'
export default function MyComponent() {
const styles = useThemeStyles()
return <div className={styles.cardStyles.base}>...</div>
}
// ❌ 错误:直接使用 dark: 前缀
<div className="bg-white dark:bg-slate-800">...</div>
```
### 苹果设计语言参考
- 圆角:`rounded-2xl`、`rounded-3xl`
- 阴影:柔和阴影,使用 `shadow-lg`
- 动画:流畅过渡,使用 `transition-all duration-300`
- 间距:统一使用 `spacingStyles`
- 但必须遵循**统一视觉方案**,不能偏离
---
## ⚠️ 注意事项
1. **性能优先**:所有改动必须考虑低性能NAS机器
- 使用动态加载
- 图片懒加载
- 避免一次性加载大量数据
2. **稳定性优先**:
- 所有数据操作使用事务
- 完善的错误处理
- 数据一致性检查
3. **易于部署**:
- 不增加外部依赖(如必须,使用国内镜像)
- 配置集中化
- 向后兼容
4. **代码质量**:
- 遵循现有代码规范
- 使用 TypeScript 类型
- 提取可复用逻辑到 Hook/工具函数
---
## 📝 更新记录
- 2025-01-XX:创建优化计划文档,重新规划优先级
- 2025-01-XX:添加新需求(17-23),包括深浅色主题优化、低饱和度炫彩元素、首页TAG系统、系统监控功能增强等

354
优化记录.md Normal file
View File

@ -0,0 +1,354 @@
# Admin 页面优化建议和问题修复记录
## ✅ 已完成的修复
### 1. 类型定义统一化
- ✅ 创建了 `types.ts` 统一管理所有类型定义
- ✅ 移除了 `page.tsx` 中的重复类型定义(User, Material, Category, Scene, Platform, Invitation, UserTreeNode, DepartmentNode, DepartmentUserGrouping)
- ✅ 移除了 `TemplateManager.tsx` 中的重复 `ToastFn` 定义
- ✅ 添加了 `AdminTab` 类型统一管理 Tab 类型
- ✅ 添加了 `DashboardData` 接口用于监控仪表板数据
- ✅ 修复了 `TemplateManager.tsx` 中 `DragHoverState` 的类型错误(fallback 对象的 `position` 属性需要明确类型为 `'before' | 'after'`)
- ✅ 修复了 `TemplateFieldsEditor.tsx` 中 `onFieldDragOver` 回调签名,添加缺失的 `surface` 参数
- ✅ 修复了 `UsersTab.tsx` 中 `showTransferButton` 在使用前未定义的问题,将其改为 `useMemo` 并移到使用前定义
### 2. UI 样式统一化 - 三层架构重构
- ✅ 创建了**三层架构**的样式系统:
- **基础层**:`lib/ui-styles-base.ts` - 包含布局、大小、动画等非颜色样式
- **颜色层**:`lib/ui-theme-colors-light.ts` / `dark.ts` - 仅包含颜色配置
- **组合层**:`lib/ui-styles-composer.ts` - 自动组合基础样式和颜色主题
- **导出层**:`lib/ui-styles-light.ts` / `dark.ts` - 最终导出的样式(由组合器生成)
- ✅ 创建了 `lib/hooks/useThemeStyles.ts` - 主题感知的样式 Hook
- `useThemeStyles()` - 根据当前主题自动返回对应的样式配置
- `useTheme()` - 获取当前主题状态
- ✅ 保持了向后兼容性:
- 原有的 `lib/ui-styles.ts` 仍然保留(包含 `dark:` 前缀的样式)
- 新组件推荐使用 `useThemeStyles()` Hook
- 旧组件可以继续使用原有导入,逐步迁移
**核心优势:**
- ✅ **统一视觉设计**:修改布局、大小、动画时,只需修改 `ui-styles-base.ts` 一处,浅色和深色模式自动同步
- ✅ **独立颜色适配**:颜色配置完全分离,可以独立调整浅色或深色模式的颜色
- ✅ **易于维护**:清晰的职责分离,修改时不会遗漏或出错
- ✅ **类型安全**:TypeScript 类型检查确保配置正确
**修改指南:**
- 修改布局/大小/动画 → 修改 `lib/ui-styles-base.ts`(一处修改,两套主题自动同步)
- 修改浅色模式颜色 → 修改 `lib/ui-theme-colors-light.ts`
- 修改深色模式颜色 → 修改 `lib/ui-theme-colors-dark.ts`
**架构文档:** 详见 `docs/guides/UI_STYLES_ARCHITECTURE.md`
**使用示例:**
```tsx
'use client'
import { useThemeStyles } from '@/lib/ui-styles'
export default function MyComponent() {
const styles = useThemeStyles()
return (
<div className={styles.cardStyles.base}>
<button className={styles.buttonStyles.primary}>点击</button>
</div>
)
}
```
**迁移进度:**
- ✅ 基础 UI 组件已迁移:
- `LoadingState.tsx` - 使用 `useThemeStyles()` 和 `useTheme()`
- `ErrorState.tsx` - 使用 `useThemeStyles()` 和 `useTheme()`
- `EmptyState.tsx` - 使用 `useTheme()` 根据主题选择文字颜色
- ✅ Tab 组件迁移(全部完成):
- `MaterialsTab.tsx` - 完全迁移到新样式系统,移除了所有 `dark:` 前缀
- `CategoriesTab.tsx` - 完全迁移到新样式系统,移除了所有 `dark:` 前缀
- `InvitationsTab.tsx` - 完全迁移到新样式系统,移除了所有 `dark:` 前缀
- `DepartmentsTab.tsx` - 完全迁移到新样式系统,移除了所有 `dark:` 前缀
- `UsersTab.tsx` - 完全迁移到新样式系统,移除了所有 `dark:` 前缀
- `MonitorTab.tsx` - 完全迁移到新样式系统,移除了所有 `dark:` 前缀
- ✅ 其他组件迁移(已完成):
- `TemplateManager.tsx` - 完全迁移到新样式系统,移除了所有 `dark:` 前缀
- `page.tsx` - 完全迁移到新样式系统,移除了所有 `dark:` 前缀
- `AppearanceSettingsPanel.tsx` - 完全迁移到新样式系统,移除了所有 `dark:` 前缀
### 3. UI 样式统一化(历史)
- ✅ 修复了 `card-apple` 类名,改为使用 `cardStyles.base`
- ✅ 统一了边框颜色:`border-gray-*` → `border-slate-*`(与设计系统一致)
- ✅ Admin 目录中的 `text-gray-* / bg-gray-* / border-gray-*` 类已全部替换为 `slate` 系列,浅色与深色模式的视觉基调保持一致
## 🔍 发现的问题和建议
### 1. 代码结构问题
#### 问题:`page.tsx` 文件过大(3795 行)
**影响:**
- 难以维护和调试
- 代码复用性差
- 性能可能受影响(组件过大)
**建议:**
- ✅ 已创建 `MonitorTab.tsx` 组件
- ✅ 已拆分 `UsersTab.tsx`(用户管理)
- ✅ 已拆分 `DepartmentsTab.tsx`(架构管理)
- ⏳ 继续拆分其他 Tab 组件:
- ✅ `CategoriesTab.tsx` - 分类管理
- ✅ `InvitationsTab.tsx` - 邀请管理
- ✅ `MaterialsTab.tsx` - 素材管理
- ✅ `TemplateManager.tsx`:已提取 Header / Built-in Grid / Custom Templates 等 UI 子组件,为后续拆出编辑器面板铺路
- ✅ TemplateManager 编辑弹窗:基础信息、预设选择、JSON 高级配置抽象为 `TemplateBasicsForm` + `TemplateAdvancedConfigPanel`,编辑逻辑逐步下沉
- ✅ TemplateManager 字段区、底部操作:`TemplateFieldsEditor` 承载字段配置 + 拖拽 UI,`TemplateEditorFooter` 统一处理提示与按钮状态
- ✅ TemplateManager 预览面板:`TemplatePreviewPanel` 独立封装上传预览 UI 与 DnD 状态,主组件仅处理回调
#### 问题:状态管理分散
**当前状态:**
- 每个 Tab 的状态都在主组件中管理
- 状态之间可能存在不必要的依赖
**建议:**
- ✅ `UsersTab` / `DepartmentsTab` 已将本地 UI 状态与交互逻辑下沉到各自组件
- 只保留共享状态在主组件(如 `activeTab`, `toast`)
### 4. UI 统一性问题
#### 问题:部分地方仍使用旧的样式类
**发现:**
- ✅ 基础文字/背景/边框色已统一为 `slate` 系列(含浅色与深色)
- ✅ 深浅色样式已分离为两套独立配置,解决适配问题
- ⏳ 部分按钮样式未使用 `buttonStyles.*`(建议逐步迁移到 `useThemeStyles()`)
- ⏳ 部分输入框仍未使用 `inputStyles.*`(建议逐步迁移到 `useThemeStyles()`)
**建议:**
- 全局搜索替换 `gray-` → `slate-`(在 admin 目录中)
- ⏳ 确保所有按钮都使用 `buttonStyles.*`(InvitationsTab、CategoriesTab、MaterialsTab、UsersTab、DepartmentsTab ✅;其它 Tab 持续中)
- ⏳ 确保所有输入框都使用 `inputStyles.*`(DepartmentsTab 重命名输入已调整)
- ✅ 补充 `buttonStyles.success` 以覆盖绿色状态按钮场景
- ✅ 新增 `buttonModifiers`(compact / compactXs / icon / block)辅助类,方便统一尺寸与布局
- ✅ MaterialsTab 搜索操作、卡片操作按钮已统一到 `buttonStyles + buttonModifiers`,并将 `<img>` 替换为 `next/image`
- ✅ UsersTab 搜索清空与用户操作按钮统一引用 `buttonStyles + buttonModifiers`
- ✅ DepartmentsTab 搜索清空、操作按钮接入 `buttonStyles + buttonModifiers`; 新增 `inputModifiers` 并应用到重命名输入
- ✅ TemplateManager 顶部搜索、预制/自定义模板动作按钮接入设计系统;新增 `buttonModifiers.pill`
- ✅ TemplateManager 基础信息 / JSON 配置表单统一使用 `inputStyles + inputModifiers`,并抽离复用组件
- ✅ TemplateManager 字段配置 / 底部操作按钮接入设计系统(字段编辑区统一控件样式,弹窗底部按钮使用 `buttonStyles.secondary/primary` + `buttonModifiers.compact`)
- ✅ TemplateManager 预览面板 UI 与拖拽交互抽象为 `TemplatePreviewPanel`,复用设计系统控件
- ✅ 清理 TemplatePreviewPanel 与 `page.tsx` 残留的 `gray-*` 渐变/分隔线/焦点样式,统一替换为 `slate-*` 调色
- ✅ `page.tsx` 移除历史 Invitations UI、状态与数据请求,统一由 `InvitationsTab` 自主管理
- ✅ `page.tsx` 移除重复的监控仪表板代码,统一使用 `MonitorTab` 组件
### 3. 逻辑优化建议
#### 问题:数据获取逻辑重复
**当前:**
- ✅ `useDataFetch` Hook 已抽离 `loadUsersData` / `fetchDepartments` / `fetchTabData`,主页面只关心状态驱动
- ⏳ 更进一步的缓存(React Query / SWR)尚未落地
**建议:**
- 后续可引入 React Query 或 SWR 进行跨 Tab 缓存与自动刷新
- 或者在各 Tab 内使用独立数据源,减少主页面耦合
#### 问题:权限检查分散
**当前:**
- 权限检查在多个地方重复
- `canManageUser` 等函数在主组件中定义
**建议:**
- ✅ 已创建 `usePermissions` Hook,统一 `canManageUser` / 超管判定等权限逻辑
- 后续可将其他权限相关逻辑继续提取到工具函数或 Hook 中
### 4. 性能优化建议
#### 问题:大量 useMemo 和 useCallback
**当前:**
- 主组件中有很多 `useMemo` 和 `useCallback`
- 某些计算可能不需要缓存
**建议:**
- ✅ 审查了每个 `useMemo` 和 `useCallback` 的必要性
- `departmentCollator` - 使用 `useMemo` 合理(创建 Intl.Collator 对象)
- `userLookup` - 使用 `useMemo` 合理(从数组创建 Map,避免重复计算)
- `fetchData`, `renameDepartment`, `createDepartment`, `deleteDepartment`, `findDepartmentManagers` - 使用 `useCallback` 合理(作为 props 传递给子组件)
- ✅ 创建了 `_utils/api.ts` 统一处理 API 错误,减少了重复的错误处理代码
- ⏳ 将复杂的计算逻辑移到子组件中
- ✅ 使用 React.memo 优化子组件渲染(`UserTree` 已实现)
#### 问题:模态框状态管理
**当前:**
- ✅ 分类/场景/平台新增、素材编辑相关模态已移交给各自 Tab 组件,`page.tsx` 清理全部遗留状态与 UI
- ✅ 新增 `useModalState` Hook(`app/admin/_hooks/useModalState.ts`),统一管理用户编辑/转移最高权限模态的开合与状态,移除散落的 `showPasswordModal`、`editingUser` 布尔控制
- ⏳ 用户信息编辑、转移最高权限 UI 仍托管在 `page.tsx`,可进一步下放到 `UsersTab`
**建议:**
- 为剩余模态编写统一的模态管理 hook
- 或者继续将模态迁移到对应的 Tab/组件内部
### 5. 代码质量建议
#### 建议:添加错误边界
- ✅ 新增 `TabErrorBoundary` 组件(`app/admin/_components/TabErrorBoundary.tsx`),并在 `page.tsx` 为各个 Tab 包裹,提供统一的 fallback + 重试
- ⏳ 可进一步将 `TabErrorBoundary` 提炼到全局组件库,或为特定 Tab 定制更详细的错误提示
#### 建议:添加加载状态统一管理
- ✅ 创建统一的加载状态组件(`LoadingState`、`ErrorState`、`EmptyState`)
- ✅ 统一错误提示样式,替换所有重复的加载/错误 UI
- ✅ `MonitorTab`、`MaterialsTab`、`page.tsx` 已统一使用新的状态组件
#### 建议:提取公共逻辑
- ✅ 创建 `hooks/useToast.ts` 统一管理 Toast(`page.tsx` 已接入)
- ✅ 创建 `hooks/useDataFetch.ts` 统一管理数据获取
- ✅ 创建 `_utils/api.ts` 统一处理 API 错误响应和类型安全
- `parseErrorResponse()` - 安全解析错误响应
- `parseSuccessResponse()` - 安全解析成功响应
- `apiRequest()` - 统一的 API 请求函数
- `getErrorMessage()` - 获取错误消息的辅助函数
- ✅ 提取表单验证逻辑到工具函数
- 创建了 `_utils/validators.ts` 统一管理表单验证
- `validateNotEmpty()` - 验证非空字符串
- `validateDepartmentName()` - 验证部门名称
- `validateEmail()` - 验证邮箱格式
- `validateFieldKey()` - 验证字段键名(标识符规则)
- `validateUniqueKeys()` - 验证字段键名唯一性
- `validateTemplateName()` - 验证模板名称
- `validatePassword()` - 验证密码
- `combineValidationResults()` - 组合多个验证结果
- `page.tsx` 和 `TemplateManager.tsx` 已使用新的验证函数
- ✅ 创建格式化工具函数
- 创建了 `_utils/formatters.ts` 统一管理格式化逻辑
- `formatDateTime()` - 格式化日期时间(完整格式)
- `formatDate()` - 格式化日期(仅日期部分)
- `formatTime()` - 格式化时间(仅时间部分)
- `formatDateTimeShort()` - 格式化日期时间(简短格式)
- `formatDateTimeLocale()` - 使用本地化格式格式化日期时间
- `formatRelativeTime()` - 格式化相对时间
- `formatFileSize()` - 格式化文件大小
- `formatNumber()` - 格式化数字(添加千位分隔符)
- `formatPercent()` - 格式化百分比
- `MonitorTab.tsx`、`InvitationsTab.tsx`、`UserEditModal.tsx`、`TemplateManager.tsx` 已使用新的格式化函数
- ✅ 提取用户操作逻辑到 Hook
- 创建了 `_hooks/useUserOperations.ts` 统一管理用户相关操作
- `updateUser()` - 更新用户信息
- `deleteUser()` - 删除用户
- `toggleDownload()` - 切换下载权限
- `toggleActive()` - 切换激活状态
- `setRole()` - 设置用户角色
- `transferSuperAdmin()` - 转移最高权限
- `page.tsx` 已使用新的 Hook,代码更简洁
- ✅ 提取部门操作逻辑到 Hook
- 创建了 `_hooks/useDepartmentOperations.ts` 统一管理部门相关操作
- `createDepartment()` - 创建部门
- `deleteDepartment()` - 删除部门
- `renameDepartment()` - 重命名部门
- `page.tsx` 已使用新的 Hook,移除了重复的部门操作代码,代码更简洁
- 创建了 `_utils/validators.ts` 统一管理表单验证
- `validateNotEmpty()` - 验证非空字符串
- `validateDepartmentName()` - 验证部门名称
- `validateEmail()` - 验证邮箱格式
- `validateFieldKey()` - 验证字段键名(标识符规则)
- `validateUniqueKeys()` - 验证字段键名唯一性
- `validateTemplateName()` - 验证模板名称
- `validatePassword()` - 验证密码
- `combineValidationResults()` - 组合多个验证结果
- `page.tsx` 和 `TemplateManager.tsx` 已使用新的验证函数
### 6. 类型安全建议
#### 问题:使用 `any` 类型
**发现:**
- `session?.user as any` 多处使用
- `dashboardData` 使用 `any` 类型
**建议:**
- ✅ 定义完整的 Session 类型(`SessionUser` 接口)
- ✅ 使用 `DashboardData` 类型替代 `any`(已在 types.ts 中定义)
- ✅ 添加类型断言函数(`getSessionUser`)
- ✅ 定义 API 响应类型(`ApiErrorResponse`, `ApiSuccessResponse`)
- ✅ 移除 `page.tsx` 中所有 `as any` 使用
- ✅ 更新 `AppearanceSettingsPanel` 使用 `SessionUser` 类型
### 7. 可访问性建议
#### 建议:添加 ARIA 标签
- ✅ 为 Tab 导航添加了完整的 ARIA 标签(`role="tablist"`, `role="tab"`, `role="tabpanel"`)
- ✅ 为所有 Tab 按钮添加了 `aria-selected`, `aria-controls`, `id` 属性
- ✅ 为返回主页按钮和关闭 Toast 按钮添加了 `aria-label`
- ✅ 为 Toast 通知添加了 `role="status"` 和 `aria-live="polite"`
- ✅ 添加了键盘导航支持(Enter 和 Space 键)
- ✅ 添加了焦点样式(`focus:outline-none focus:ring-2`)
- ⏳ 继续为其他交互元素添加 ARIA 标签和键盘支持
### 8. 测试建议
#### 建议:添加单元测试
- 为工具函数添加测试
- 为权限检查逻辑添加测试
- 为数据转换逻辑添加测试
## 📋 下一步行动计划
> **注意**:详细的优化计划和优先级已移至 `优化计划与优先级.md`,请参考该文档进行后续开发。
### 当前阶段:基础组件与UI统一
**优先级 1:基础组件与UI统一(核心基础设施)**
1. ✅ 创建统一弹窗组件(`components/ui/Modal.tsx`)
- ✅ 使用三层架构设计(基础层、颜色层、组合层)
- ✅ 提供 `useModal` Hook(`hooks/useModal.ts`)
- ✅ 支持多种尺寸(small, medium, large, xlarge, fullscreen)
- ✅ 支持ESC键关闭、点击遮罩关闭
- ✅ 完整的ARIA标签和键盘导航支持
- ⏳ 替换所有浏览器原生弹窗(confirm/alert)
2. ⏳ 素材预览面板统一
- 使用统一弹窗组件
- 统一退出逻辑
3. ⏳ 首页素材展示响应式优化
- 修复卡片比例问题
- 优化边界间距
**优先级 2:数据一致性与核心功能**
4. ⏳ 用户信息修改后同步素材信息
- 在 `useUserOperations` 中扩展
5. ⏳ 模板管理整合到上传和分类
- 严格检查模板管理面板逻辑
- 将模板系统串起前后端
**详细计划**:请查看 `优化计划与优先级.md`
## 📝 代码规范建议
### 命名规范
- 组件文件:PascalCase(如 `UsersTab.tsx`)
- Hook 文件:camelCase with `use` prefix(如 `usePermissions.ts`)
- 工具函数:camelCase(如 `formatDate.ts`)
- 类型文件:`types.ts` 或 `types/index.ts`
### 文件组织
```
app/admin/
├── _components/
│ ├── types.ts # 类型定义
│ ├── MonitorTab.tsx # 监控仪表板
│ ├── UsersTab.tsx # 用户管理
│ ├── DepartmentsTab.tsx # 架构管理
│ ├── CategoriesTab.tsx # 分类管理
│ ├── InvitationsTab.tsx # 邀请管理
│ ├── MaterialsTab.tsx # 素材管理
│ ├── TemplateManager.tsx # 模板管理
│ └── AppearanceSettingsPanel.tsx
├── _hooks/ # 自定义 hooks
│ ├── useToast.ts
│ ├── usePermissions.ts
│ └── useDataFetch.ts
├── _utils/ # 工具函数
│ ├── validators.ts
│ └── formatters.ts
└── page.tsx # 主页面(简化后)
```
## 🎯 最终目标
1. **代码可维护性**:每个组件职责单一,易于理解和修改
2. **代码复用性**:公共逻辑提取到 hooks 和工具函数
3. **类型安全**:完整的 TypeScript 类型定义,减少运行时错误
4. **UI 一致性**:统一使用设计系统,确保视觉一致性
5. **性能优化**:减少不必要的渲染,优化数据获取
6. **用户体验**:流畅的交互,清晰的错误提示

28
后续修改.txt Normal file
View File

@ -0,0 +1,28 @@
以下修改,你先规划一下先后顺序
1.管理员将用户信息修改后,对应该用户上传的素材信息也相应的做出修改
2.问题:上传素材进度到100后,会缓好一会才会关闭弹窗
3.一个素材可以上传多张预览图,且自动设置第一张为预览图
4.性能监控版增加导出scv表单
5.点击头像弹出的菜单,增加个人中心功能
6.用户管理增加用户头像的设置,用户可以自己设置自己的头像。对于钉钉登陆的用户,可以直接用钉钉头像,抓不到钉钉头像的话,就不抓取。对于没有头像的用户,策略和LOGO的策略一样
7.首页上的排行榜关掉后的那个缩略按钮,也设置为可以拖动,且出现的位置是根据排行榜悬浮窗关闭前的位置
8.首页的素材展示要改一下,现在是有个问题,就是当我拖动浏览器侧边调整浏览器大小的时候,调整浏览器界面大小,小到一个阈值时,展示素材的卡片会从4个变成3个,这个时候素材卡片的比例就会变宽,这是不对的。要保持素材卡片比例是固定的。还有就是当浏览器变窄之后素材卡片会贴到浏览器的边界,很不美观,根据统一UI原则视觉改一下
9.首页的素材展示可以设置素材展示卡片的大小,可以设置一个横向的滑块。素材卡片的展示形式可以设置为瀑布流,为低性能nas机器考虑,可以动态加载卡片数量,具体加载多少可以根据你的经验判断。或者你有更好的方式也可以提出建议
10.首页素材卡片中预览图下方的素材资料有点多,占用空间太大。可以优化一下,其中作者中的“作者:”可以不显示,直接显示作者的名字。可以在前面显示头像。文件名和文件大小也可以不显示。上传时间可以叠在预览图的右上角,默认隐藏,鼠标移动到卡片上亮起,要确保亮起状态的上传时间可以看清
11.素材卡片可以在左上角的素材名称前面加几个标签,如点赞榜,精品榜,下载榜,收藏榜等,你可以自由发挥,样式可以参考最高权限的显示ui,更主要的是视觉要好看,偏向苹果UI设计风格,但是要注意大方向一定是统一视觉的方案。
12.点击头像弹出的菜单,将系统设置改名为管理,图标同样换一下。
13.将外观设置,移动到点击头像弹出的菜单里.同样前面设置一个图标
14.现在模板管理已经设计好了首先就是首页的上传素材面板,其中的素材类型就可以改成素材模板了,列表直接拉取模板管理中的模板名称。素材模板选择后就切换到对应的素材模板界面,该显示什么字段就是什么字段。然后再系统管理中的分类管理,就不使用现有的“店铺分类”和“场景类别”了,而是使用模板管理中的下拉元素中的文字做分类。这样就把前端后端都通过上传模板串起来了。你有什么更好的建议是符合项目宗旨的都可以提出来。模板管理的面板逻辑,你可以再检查一遍,要严格一些,因为这个关系到很多东西。
15.点击素材后的素材预览面板也要统一,包括退出逻辑也是一样要统一
16.创建统一弹窗,不要用浏览器自带的弹窗,有点丑。
✅ 已完成:已创建统一弹窗组件 components/ui/Modal.tsx 和 useModal Hook
⏳ 待完成:替换所有浏览器原生弹窗(confirm/alert)
17.优化一下深浅色主题的适配
18.设计语言遵循苹果语言,重点用低饱和度的炫彩元素
19.首页将筛选内的类型分页功能,放到首页,放到首页后分TAG显示,第一个TAG是全部。这个类型分页的tag也是根据上传模板设置,所以之前说模板管理很重要。TAG的样式要设计的好看大气符合整体的风格
20.系统监控中的用户总数,活跃用户,总素材数,本月新用户,本月新素材,增加点击跳转到对应的数据显示
21.系统监控中的素材上传趋势卡片前面增加当月上传趋势
22.首页的UI也更绝统一UI的三层架构统一一下,
23.统一UI的三层架构内,新增一些通用组件用于不同的需求

233
更新日志.md Normal file
View File

@ -0,0 +1,233 @@
## 🧾 2025-01-XX · 「Admin页面重构 & UI架构优化」版本 🎨
本次更新对Admin管理页面进行了全面重构,建立了统一UI三层架构系统,大幅提升了代码可维护性和用户体验。
---
### 🎯 UI三层架构系统
- ✨ **三层架构设计**
- **基础层**:`lib/ui-styles-base.ts` - 布局、大小、动画等非颜色样式
- **颜色层**:`lib/ui-theme-colors-light.ts` / `dark.ts` - 仅包含颜色配置
- **组合层**:`lib/ui-styles-composer.ts` - 自动组合基础样式和颜色主题
- **导出层**:`lib/ui-styles-light.ts` / `dark.ts` - 最终导出的样式
- **Hook层**:`lib/hooks/useThemeStyles.ts` - 主题感知的样式 Hook
- 🎨 **核心优势**
- 修改布局/大小/动画时,只需修改基础层一处,浅色和深色模式自动同步
- 颜色配置完全分离,可以独立调整浅色或深色模式的颜色
- 清晰的职责分离,修改时不会遗漏或出错
- TypeScript 类型检查确保配置正确
- 📦 **组件迁移**
- ✅ 所有Tab组件已完全迁移到新样式系统(MaterialsTab、CategoriesTab、InvitationsTab、DepartmentsTab、UsersTab、MonitorTab)
- ✅ TemplateManager 完全迁移
- ✅ 基础UI组件迁移(LoadingState、ErrorState、EmptyState)
- ✅ Admin主页面完全迁移
---
### 🔧 代码结构优化
- ✨ **组件拆分**
- 将 `page.tsx`(原3795行)拆分为多个独立Tab组件
- ✅ `MonitorTab.tsx` - 监控仪表板
- ✅ `UsersTab.tsx` - 用户管理
- ✅ `DepartmentsTab.tsx` - 架构管理
- ✅ `CategoriesTab.tsx` - 分类管理
- ✅ `InvitationsTab.tsx` - 邀请管理
- ✅ `MaterialsTab.tsx` - 素材管理
- ✅ `TemplateManager.tsx` - 模板管理(已拆分为多个子组件)
- 🎯 **Hook提取**
- ✅ `useToast.ts` - 统一Toast通知管理
- ✅ `useDataFetch.ts` - 统一数据获取逻辑
- ✅ `useUserOperations.ts` - 用户相关操作(更新、删除、权限切换等)
- ✅ `useDepartmentOperations.ts` - 部门相关操作(创建、删除、重命名)
- ✅ `usePermissions.ts` - 权限检查逻辑
- ✅ `useModalState.ts` - 模态框状态管理
- ✅ `useDashboardData.ts` - 仪表板数据获取
- 🛠️ **工具函数提取**
- ✅ `validators.ts` - 表单验证逻辑(验证邮箱、部门名、密码等)
- ✅ `formatters.ts` - 格式化函数(日期时间、文件大小、数字等)
- ✅ `api.ts` - API错误处理和类型安全
- ✅ `departmentTree.ts` - 部门树结构处理
- 🎨 **模板管理器组件化**
- ✅ `TemplateManagerHeader.tsx` - 头部搜索和操作
- ✅ `BuiltInTemplateGrid.tsx` - 内置模板网格
- ✅ `CustomTemplateSection.tsx` - 自定义模板区域
- ✅ `TemplateBasicsForm.tsx` - 基础信息表单
- ✅ `TemplateAdvancedConfigPanel.tsx` - 高级配置面板
- ✅ `TemplateFieldsEditor.tsx` - 字段编辑器
- ✅ `TemplateEditorFooter.tsx` - 编辑器底部操作
- ✅ `TemplatePreviewPanel.tsx` - 预览面板
---
### 🎯 统一弹窗组件系统
- ✨ **创建统一弹窗组件**
- 新增 `components/ui/Modal.tsx` 统一弹窗组件
- 使用三层架构设计
- 支持多种尺寸(small, medium, large, xlarge, fullscreen)
- 支持ESC键关闭、点击遮罩关闭
- 完整的ARIA标签和键盘导航支持
- 🔧 **Hook支持**
- 新增 `hooks/useModal.ts` Hook,统一管理弹窗状态
- 提供 `open`、`close`、`toggle` 方法
**待完成**:
- ⏳ 替换所有浏览器原生弹窗(confirm/alert)
- ⏳ 素材预览面板统一使用新弹窗组件
---
### 🛡️ 错误处理与用户体验
- ✨ **错误边界**
- 新增 `TabErrorBoundary.tsx` 组件
- 为各个Tab包裹错误边界,提供统一的fallback和重试机制
- 📊 **统一状态组件**
- ✅ `LoadingState.tsx` - 加载状态
- ✅ `ErrorState.tsx` - 错误状态
- ✅ `EmptyState.tsx` - 空状态
- 统一错误提示样式,替换所有重复的加载/错误UI
- ♿ **可访问性增强**
- 为Tab导航添加完整的ARIA标签
- 添加键盘导航支持(Enter和Space键)
- 添加焦点样式
- 为所有交互元素添加 `aria-label`
---
### 📝 类型安全优化
- ✨ **类型定义统一化**
- 创建 `app/admin/_components/types.ts` 统一管理所有类型定义
- 移除了重复的类型定义
- 添加 `AdminTab` 类型统一管理Tab类型
- 添加 `DashboardData` 接口用于监控仪表板数据
- 定义完整的Session类型(`SessionUser` 接口)
- 定义API响应类型(`ApiErrorResponse`, `ApiSuccessResponse`)
- 移除所有 `as any` 使用
---
### 📚 文档完善
- 📝 **优化文档**
- 创建 `优化记录.md` - 记录所有优化和修复
- 创建 `优化计划与优先级.md` - 详细规划后续优化任务
- 更新 `后续修改.txt` - 记录需求进度
- 创建 `更新日志.md`(原CHANGELOG.md) - 版本更新记录
---
### 🎨 UI样式统一
- ✨ **颜色系统统一**
- Admin目录中的 `text-gray-* / bg-gray-* / border-gray-*` 类已全部替换为 `slate` 系列
- 浅色与深色模式的视觉基调保持一致
- 统一使用 `buttonStyles` 和 `inputStyles`
- 新增 `buttonModifiers` 和 `inputModifiers` 辅助类
---
## 🧾 2025-11-20 · 「拖拽体验优化」版本 🎨
本次更新主要优化了模板管理器的拖拽交互体验,让字段排序操作更加流畅和直观。
---
### 🎯 拖拽交互优化
- ✨ **移除高亮卡片效果**
- 移除了拖拽悬停时的高亮卡片显示,避免与虚位卡片(placeholder)视觉重复。
- 现在只保留虚位卡片来显示落点位置,界面更简洁清晰。
- ⚡ **立即更新布局**
- 松开鼠标后布局立即更新,不再等待动画完成。
- 直接根据拖拽后的顺序更新字段数据,响应更快,体验更流畅。
- 🎨 **动画效果优化**
- 拖拽结束动画时长从 400ms 增加到 500ms,使用弹性缓动函数 `cubic-bezier(0.34, 1.56, 0.64, 1)`。
- 拖拽覆盖层增加旋转和缩放效果,视觉反馈更明显。
- 占位符卡片使用渐变背景和脉冲动画,更清晰地指示落点位置。
---
## 🧾 2025-11-18 · 「权限进化 & 电商加成」版本 (๑•̀ㅂ•́)و✧
这次更新主要围绕 **权限体验、电商字段扩展、模板/分类管理、部署体验** 做了一轮集中升级,让「由壹点 3D 素材平台」更像一款真正能落地到团队里的内部产品,而不是一个玩具 Demo。下面是本次改动的「人话版」说明👇
---
### 🛡 权限与用户管理
- ✨ **部门管理员权限升级**
- 非最高权限账号,只要有「管理的部门」或「自己的部门」,就可以管理 **该部门 + 所有子部门** 的用户。
- 没有部门范围时,就老老实实只能改自己,避免误操作全公司 ( ̄▽ ̄)/
- 🧍‍♀️ **按钮展示更聪明**
- 对当前登录用户 **没有管理权限** 的成员:
- 不再显示一整排「编辑 / 禁用 / 下载权限 / 管理员」按钮。
- 只保留一行「无权限修改」的小提示,界面更干净,权限边界更清晰。
---
### 📦 模板与分类管理
- 🧩 **内置电商模板上线**
- 在模板管理中新增几套常用电商模板(如「通用电商」「简易基础」「渲染维度」等)。
- 字段命名与素材表最新标准对齐,开箱即用,不用每次从 0 造轮子。
- 📂 **分类下拉选项更好配**
- 使用店铺 / 场景类别 / 平台分类,全部支持配置「下拉选项」。
- 规则超简单:**一行一个选项**,不用再写 JSON 数组。
- 管理端统一提供输入框 + 示例,方便以后快速按业务维护分类选项。
---
### 🛍 素材模型与电商字段
- 🏷 **商品 / 品牌维度补齐**
- 为 `Material` 增加:`brandName`(品牌)、`seriesName`(系列)、`skuCode`(SKU 编码)、`materialTexture`(材质纹理)等字段。
- 方便日后按品牌、系列、SKU 做统计和检索,更贴合电商团队日常用语。
- 🎨 **渲染 / 制作维度补充**
- 新增:`renderTime`(渲染耗时)、`imageResolution`(输出分辨率)、`colorMode`(颜色模式)等字段。
- 从上传 → 编辑 → 展示 → 搜索,全链路打通,真正做到「不只是传个压缩包」。
- 🔍 **前台页面同步升级**
- 首页列表、分享页、素材编辑页均已接入以上新字段。
- 搜索支持按这些维度模糊匹配,找素材更像「搜商品」而不是「翻文件夹」。
---
### 🚀 部署与运维体验
- 📜 **`deploy-new-fixed.ps1` 阶段化部署脚本**
- 拆分为:检查阶段 → 构建阶段 → 镜像推送 → 远程部署,并在每一步给出清晰进度条和提示。
- 附带一点小彩蛋文案,部署的时候不那么无聊 (´・ω・`)
- 🔐 **SSH & Registry 流程更稳**
- 强制优先使用 OpenSSH,避免 plink 等工具弹交互对话框导致脚本卡死。
- 自动打 Tag 并推送镜像到私有 Registry,然后在远程服务器上拉取并通过 Docker Compose 滚动更新。
- ❤️ **健康检查 `/api/health`**
- 部署完成后,脚本会主动轮询 `/api/health`,确认服务真正「跑起来」再宣告成功。
- 避免刚部署完就打开页面,结果服务还在启动中的尴尬。
---
### 🎛 其他细节优化
- 🧭 系统管理页默认打开 **「监控仪表板」** 标签,而不是素材管理,让管理者一眼看到系统状态。
- 🎨 调整架构管理 & 用户管理的卡片、阴影、悬浮效果与按钮样式,使整个后台的观感更统一、更接近苹果风格。
> 小结:这一版可以理解为「**权限体验 + 电商字段 + 可迁移部署脚本**」的整合升级版,为后续更多客户侧部署和定制做好了「打基础」工作。✨

202
设计系统.md Normal file
View File

@ -0,0 +1,202 @@
# 设计系统规范
## 概述
本文档定义了项目的统一视觉设计系统,确保所有界面元素保持一致的外观和交互体验。
## 设计原则
1. **一致性**:所有组件使用统一的圆角、阴影、间距
2. **层次感**:通过阴影和颜色建立清晰的视觉层次
3. **响应式**:适配各种屏幕尺寸
4. **可访问性**:确保足够的对比度和可读性
## 视觉规范
### 1. 圆角 (Border Radius)
| 用途 | 类名 | 值 | 示例 |
|------|------|-----|------|
| 小元素(按钮、标签) | `rounded-lg` | 8px | 小按钮、徽章 |
| 标准卡片 | `rounded-xl` | 12px | 标准卡片、输入框 |
| 大卡片 | `rounded-2xl` | 16px | 主要内容卡片 |
| 超大容器 | `rounded-3xl` | 24px | 特殊展示区域 |
**统一规则:**
- 按钮、输入框:`rounded-xl` (12px)
- 卡片:`rounded-2xl` (16px)
- 导航项:`rounded-xl` (12px)
- 特殊容器:`rounded-3xl` (24px)
### 2. 阴影 (Box Shadow)
| 层级 | 类名 | 值 | 用途 |
|------|------|-----|------|
| 无阴影 | `shadow-none` | - | 扁平元素 |
| 轻微阴影 | `shadow-sm` | 0 1px 2px rgba(0,0,0,0.05) | 输入框、小卡片 |
| 标准阴影 | `shadow-md` | 0 4px 6px rgba(0,0,0,0.07) | 悬停状态 |
| 明显阴影 | `shadow-lg` | 0 10px 15px rgba(0,0,0,0.1) | 主要卡片 |
| 强烈阴影 | `shadow-xl` | 0 20px 25px rgba(0,0,0,0.1) | 模态框、重要卡片 |
| 极强阴影 | `shadow-2xl` | 0 25px 50px rgba(0,0,0,0.15) | 浮动面板 |
**统一规则:**
- 默认卡片:`shadow-lg`
- 悬停状态:`hover:shadow-xl`
- 模态框/弹窗:`shadow-2xl`
- 输入框:`shadow-sm` 或 `shadow-none`
### 3. 间距 (Spacing)
使用 Tailwind 标准间距系统:
| 用途 | 类名 | 值 | 示例 |
|------|------|-----|------|
| 紧密间距 | `gap-2` / `space-y-2` | 8px | 相关元素组 |
| 标准间距 | `gap-4` / `space-y-4` | 16px | 卡片内元素 |
| 宽松间距 | `gap-6` / `space-y-6` | 24px | 卡片之间 |
| 大间距 | `gap-8` / `space-y-8` | 32px | 区块之间 |
**统一规则:**
- 卡片内边距:`p-6` (24px)
- 卡片间距:`gap-6` (24px)
- 区块间距:`space-y-8` (32px)
- 表单元素间距:`space-y-4` (16px)
### 4. 边框 (Border)
| 用途 | 类名 | 值 | 示例 |
|------|------|-----|------|
| 标准边框 | `border` | 1px | 卡片、输入框 |
| 强调边框 | `border-2` | 2px | 选中状态 |
| 左侧强调 | `border-l-4` | 4px | 导航项、列表项 |
**统一规则:**
- 默认边框:`border border-slate-200 dark:border-slate-700`
- 输入框边框:`border border-slate-300 dark:border-slate-600`
- 选中状态:`border-2 border-indigo-500`
### 5. 颜色系统
#### 主色调
- **主色**:Indigo (`indigo-500` = #6366f1)
- **辅助色**:Purple (`purple-500` = #a855f7)
- **成功色**:Green (`green-500` = #22c55e)
- **警告色**:Yellow (`yellow-500` = #eab308)
- **错误色**:Red (`red-500` = #ef4444)
#### 中性色
- **背景色**:
- 浅色模式:`bg-white` / `bg-gray-50`
- 深色模式:`bg-slate-900` / `bg-slate-800`
- **文本色**:
- 浅色模式:`text-gray-900` / `text-gray-600`
- 深色模式:`text-white` / `text-slate-300`
### 6. 过渡动画
| 属性 | 类名 | 值 | 用途 |
|------|------|-----|------|
| 标准过渡 | `transition-all` | all 200ms ease | 通用交互 |
| 快速过渡 | `transition-all duration-150` | all 150ms | 悬停反馈 |
| 慢速过渡 | `transition-all duration-300` | all 300ms | 状态变化 |
**统一规则:**
- 所有交互元素:`transition-all duration-200`
- 悬停效果:`hover:scale-[1.02]` 或 `hover:shadow-xl`
- 点击效果:`active:scale-[0.98]`
## 组件样式规范
### 卡片 (Card)
```tsx
// 标准卡片
className="rounded-2xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 shadow-lg p-6"
// 悬停卡片
className="rounded-2xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 shadow-lg hover:shadow-xl transition-all duration-200 p-6"
```
### 按钮 (Button)
```tsx
// 主要按钮
className="px-6 py-3 rounded-xl bg-indigo-600 text-white font-medium shadow-lg shadow-indigo-500/30 hover:bg-indigo-700 hover:shadow-xl transition-all duration-200"
// 次要按钮
className="px-6 py-3 rounded-xl border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200 font-medium hover:bg-slate-50 dark:hover:bg-slate-700 transition-all duration-200"
```
### 输入框 (Input)
```tsx
className="w-full px-4 py-3 rounded-xl border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 text-slate-900 dark:text-white focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200 dark:focus:ring-indigo-800 outline-none transition-all"
```
### 导航项 (Nav Item)
```tsx
className="px-4 py-3 rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 hover:border-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/20 transition-all duration-200"
```
## 实施指南
### 1. 使用 Tailwind 工具类
优先使用 Tailwind 预设的工具类,避免自定义样式:
```tsx
// ✅ 正确
<div className="rounded-2xl shadow-lg p-6">
// ❌ 错误
<div className="rounded-[16px] shadow-[0_10px_15px_rgba(0,0,0,0.1)] p-[24px]">
```
### 2. 创建可复用组件
将常用样式封装为组件:
```tsx
// components/ui/Card.tsx
export function Card({ children, className = '' }) {
return (
<div className={`rounded-2xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 shadow-lg p-6 ${className}`}>
{children}
</div>
)
}
```
### 3. 使用 CSS 变量(可选)
对于需要动态调整的值,使用 CSS 变量:
```css
:root {
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 24px;
}
```
## 检查清单
在开发新组件时,请确保:
- [ ] 使用统一的圆角值(`rounded-xl` 或 `rounded-2xl`)
- [ ] 使用统一的阴影层级(`shadow-lg` 或 `shadow-xl`)
- [ ] 使用统一的间距系统(`p-6`, `gap-6`, `space-y-6`)
- [ ] 使用统一的颜色系统(Indigo 主色)
- [ ] 添加过渡动画(`transition-all duration-200`)
- [ ] 支持深色模式(`dark:` 前缀)
- [ ] 确保足够的对比度
## 迁移计划
1. **阶段一**:更新 Tailwind 配置,添加统一的设计令牌
2. **阶段二**:创建可复用的 UI 组件库
3. **阶段三**:逐步迁移现有组件到新规范
4. **阶段四**:建立代码审查流程,确保新代码遵循规范

253
设计系统使用指南.md Normal file
View File

@ -0,0 +1,253 @@
# 设计系统使用指南
## 快速开始
### 1. 导入样式工具
```tsx
import { cardStyles, buttonStyles, inputStyles, navItemStyles, cn } from '@/lib/ui-styles'
```
### 2. 使用统一样式
#### 卡片 (Card)
```tsx
// 标准卡片
<div className={cardStyles.base}>
<h2>标题</h2>
<p>内容</p>
</div>
// 带悬停效果的卡片
<div className={cardStyles.hover}>
<h2>标题</h2>
<p>内容</p>
</div>
// 大卡片
<div className={cardStyles.large}>
<h2>标题</h2>
<p>内容</p>
</div>
```
#### 按钮 (Button)
```tsx
// 主要按钮
<button className={buttonStyles.primary}>
保存
</button>
// 次要按钮
<button className={buttonStyles.secondary}>
取消
</button>
// 危险操作按钮
<button className={buttonStyles.danger}>
删除
</button>
// 幽灵按钮(无背景)
<button className={buttonStyles.ghost}>
更多
</button>
```
#### 输入框 (Input)
```tsx
// 标准输入框
<input
type="text"
className={inputStyles.base}
placeholder="请输入..."
/>
// 错误状态输入框
<input
type="text"
className={inputStyles.error}
placeholder="请输入..."
/>
```
#### 导航项 (Nav Item)
```tsx
// 基础导航项
<button className={navItemStyles.base}>
导航项
</button>
// 带悬停效果
<button className={navItemStyles.hover}>
导航项
</button>
// 激活状态
<button className={navItemStyles.active}>
导航项
</button>
```
#### 组合样式
```tsx
import { cn } from '@/lib/ui-styles'
// 组合多个样式类
<div className={cn(
cardStyles.base,
'mt-4', // 额外的 Tailwind 类
isActive && 'ring-2 ring-indigo-500' // 条件样式
)}>
内容
</div>
```
## 迁移现有代码
### 迁移前
```tsx
// ❌ 不统一的样式
<div className="rounded-lg border border-gray-200 bg-white shadow-md p-4">
<button className="px-4 py-2 rounded-md bg-blue-500 text-white">
按钮
</button>
</div>
```
### 迁移后
```tsx
// ✅ 使用统一样式
<div className={cardStyles.base}>
<button className={buttonStyles.primary}>
按钮
</button>
</div>
```
## 最佳实践
### 1. 优先使用样式工具
```tsx
// ✅ 正确
import { cardStyles } from '@/lib/ui-styles'
<div className={cardStyles.base}>
// ❌ 错误(除非有特殊需求)
<div className="rounded-2xl border border-slate-200 bg-white shadow-lg p-6">
```
### 2. 使用 cn() 函数组合样式
```tsx
// ✅ 正确
import { cn, cardStyles } from '@/lib/ui-styles'
<div className={cn(cardStyles.base, 'mt-4', isActive && 'ring-2')}>
// ❌ 错误
<div className={`${cardStyles.base} mt-4 ${isActive ? 'ring-2' : ''}`}>
```
### 3. 保持一致性
```tsx
// ✅ 正确:所有卡片使用相同的样式
<div className={cardStyles.base}>卡片 1</div>
<div className={cardStyles.base}>卡片 2</div>
<div className={cardStyles.base}>卡片 3</div>
// ❌ 错误:每个卡片样式都不同
<div className="rounded-lg shadow-md p-4">卡片 1</div>
<div className="rounded-xl shadow-lg p-6">卡片 2</div>
<div className="rounded-2xl shadow-xl p-8">卡片 3</div>
```
## 常见场景
### 表单
```tsx
import { cardStyles, inputStyles, buttonStyles, spacingStyles } from '@/lib/ui-styles'
<form className={cn(cardStyles.base, spacingStyles.form)}>
<div>
<label className="block text-sm font-medium mb-2">用户名</label>
<input type="text" className={inputStyles.base} />
</div>
<div>
<label className="block text-sm font-medium mb-2">密码</label>
<input type="password" className={inputStyles.base} />
</div>
<div className="flex gap-4">
<button type="submit" className={buttonStyles.primary}>
提交
</button>
<button type="button" className={buttonStyles.secondary}>
取消
</button>
</div>
</form>
```
### 导航菜单
```tsx
import { navItemStyles } from '@/lib/ui-styles'
<nav className="space-y-2">
{items.map((item) => (
<button
key={item.id}
className={item.active ? navItemStyles.active : navItemStyles.hover}
>
{item.label}
</button>
))}
</nav>
```
### 卡片列表
```tsx
import { cardStyles, spacingStyles } from '@/lib/ui-styles'
<div className={cn('grid gap-6', spacingStyles.section)}>
{items.map((item) => (
<div key={item.id} className={cardStyles.hover}>
<h3>{item.title}</h3>
<p>{item.description}</p>
</div>
))}
</div>
```
## 扩展样式
如果需要添加新的样式,请在 `lib/ui-styles.ts` 中添加:
```tsx
// lib/ui-styles.ts
export const customStyles = {
// 你的自定义样式
special: 'rounded-2xl bg-gradient-to-r from-indigo-500 to-purple-500 ...',
} as const
```
## 检查清单
在提交代码前,请检查:
- [ ] 使用了统一的样式工具类
- [ ] 圆角、阴影、间距符合规范
- [ ] 支持深色模式
- [ ] 添加了过渡动画
- [ ] 响应式设计正确

146
设计系统迁移示例.md Normal file
View File

@ -0,0 +1,146 @@
# 设计系统迁移示例
## 概述
本文档展示了如何将现有组件迁移到统一的设计系统。以 `AppearanceSettingsPanel.tsx` 为例。
## 迁移步骤
### 1. 导入样式工具
```tsx
// 迁移前
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
// 迁移后
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { cardStyles, buttonStyles, inputStyles, navItemStyles, cn } from '@/lib/ui-styles'
```
### 2. 替换卡片样式
```tsx
// 迁移前
<div className="rounded-2xl border border-slate-200/60 dark:border-slate-700/60 bg-white/80 dark:bg-slate-900/60 p-8">
// 迁移后
<div className={cn(cardStyles.base, 'flex flex-col items-center gap-4')}>
```
### 3. 替换按钮样式
```tsx
// 迁移前
<button className="px-6 py-3 rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700 transition-all">
// 迁移后
<button className={buttonStyles.secondary}>
```
### 4. 替换输入框样式
```tsx
// 迁移前
<input className="w-full px-4 py-3 rounded-2xl border border-slate-200 dark:border-slate-700 bg-slate-50/80 dark:bg-slate-900 text-slate-900 dark:text-white focus:border-indigo-400 focus:ring-2 focus:ring-indigo-200 dark:focus:ring-indigo-600 outline-none transition-all" />
// 迁移后
<input className={inputStyles.base} />
```
### 5. 替换导航项样式
```tsx
// 迁移前
const navButtonClass = (key: SectionKey) =>
`w-full px-4 py-3 rounded-2xl border text-left group transition-all duration-500 ${
activeNav === key
? 'border-indigo-400/70 bg-white dark:bg-slate-900 shadow-lg shadow-indigo-500/30'
: 'border-slate-200/70 dark:border-slate-700/70 hover:border-indigo-400/50 hover:bg-indigo-50/70 dark:hover:bg-indigo-900/20'
}`
// 迁移后
const navButtonClass = (key: SectionKey) =>
cn(
'w-full text-left group',
activeNav === key ? navItemStyles.active : navItemStyles.hover
)
```
### 6. 使用 cn() 组合样式
```tsx
// 迁移前
<div className={`rounded-3xl border border-slate-200/70 dark:border-slate-700/40 bg-white dark:bg-[#141e32] shadow-xl p-6 space-y-6 ${sectionRevealClass('brand')} ${staggerDelays.brand}`}>
// 迁移后
<div className={cn(
cardStyles.large,
'space-y-6',
sectionRevealClass('brand'),
staggerDelays.brand
)}>
```
## 迁移统计
在 `AppearanceSettingsPanel.tsx` 中:
- ✅ 替换了 6 个卡片样式
- ✅ 替换了 8 个按钮样式
- ✅ 替换了 8 个输入框样式
- ✅ 替换了导航项样式函数
- ✅ 使用 `cn()` 函数统一组合样式
## 注意事项
### 1. 保留特殊样式
对于有特殊需求的组件(如渐变背景),可以保留部分自定义样式:
```tsx
// 保留渐变背景的特殊按钮
<button 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'
)}>
```
### 2. 条件样式
使用 `cn()` 函数处理条件样式:
```tsx
<div className={cn(
cardStyles.base,
isActive && 'ring-2 ring-indigo-500',
isDisabled && 'opacity-50'
)}>
```
### 3. 额外类名
在统一样式基础上添加额外的 Tailwind 类:
```tsx
<input className={cn(inputStyles.base, 'text-sm font-mono')} />
```
## 验证清单
迁移完成后,请检查:
- [ ] 所有卡片使用统一的圆角(`rounded-2xl` 或 `rounded-3xl`)
- [ ] 所有按钮使用统一的样式工具类
- [ ] 所有输入框使用统一的样式工具类
- [ ] 导航项使用统一的样式工具类
- [ ] 深色模式正常工作
- [ ] 悬停和交互效果正常
- [ ] 响应式布局正常
- [ ] 没有 lint 错误
## 下一步
1. 继续迁移其他组件
2. 在代码审查中检查是否遵循设计系统
3. 更新组件文档,说明使用的样式工具类