- 创建统一UI三层架构系统(基础层、颜色层、组合层) - 拆分Admin页面为独立Tab组件(MonitorTab、UsersTab、DepartmentsTab等) - 提取公共逻辑到Hooks(useToast、useDataFetch、useUserOperations等) - 提取工具函数(validators、formatters、api等) - 创建统一弹窗组件(Modal)和useModal Hook - 添加错误边界和统一状态组件 - 完善类型定义和可访问性支持 - 更新文档(优化记录、优化计划、更新日志)
This commit is contained in:
parent
66bd011c85
commit
e4dcb6260a
93
CHANGELOG.md
93
CHANGELOG.md
@ -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`,确认服务真正「跑起来」再宣告成功。
|
||||
- 避免刚部署完就打开页面,结果服务还在启动中的尴尬。
|
||||
|
||||
---
|
||||
|
||||
### 🎛 其他细节优化
|
||||
|
||||
- 🧭 系统管理页默认打开 **「监控仪表板」** 标签,而不是素材管理,让管理者一眼看到系统状态。
|
||||
- 🎨 调整架构管理 & 用户管理的卡片、阴影、悬浮效果与按钮样式,使整个后台的观感更统一、更接近苹果风格。
|
||||
|
||||
> 小结:这一版可以理解为「**权限体验 + 电商字段 + 可迁移部署脚本**」的整合升级版,为后续更多客户侧部署和定制做好了「打基础」工作。✨
|
||||
858
app/admin/_components/AppearanceSettingsPanel.tsx
Normal file
858
app/admin/_components/AppearanceSettingsPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
885
app/admin/_components/CategoriesTab.tsx
Normal file
885
app/admin/_components/CategoriesTab.tsx
Normal 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='直接输入选项,每行一个,例如: 选项1 选项2 选项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='直接输入选项,每行一个,例如: 选项1 选项2 选项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='直接输入选项,每行一个,例如: 选项1 选项2 选项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>
|
||||
)
|
||||
}
|
||||
|
||||
382
app/admin/_components/DepartmentsTab.tsx
Normal file
382
app/admin/_components/DepartmentsTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
322
app/admin/_components/InvitationsTab.tsx
Normal file
322
app/admin/_components/InvitationsTab.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
214
app/admin/_components/MaterialsTab.tsx
Normal file
214
app/admin/_components/MaterialsTab.tsx
Normal 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('修改成功')
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
355
app/admin/_components/MonitorTab.tsx
Normal file
355
app/admin/_components/MonitorTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
72
app/admin/_components/TabErrorBoundary.tsx
Normal file
72
app/admin/_components/TabErrorBoundary.tsx
Normal 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
537
app/admin/_components/UsersTab.tsx
Normal file
537
app/admin/_components/UsersTab.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
163
app/admin/_components/template-manager/CustomTemplateSection.tsx
Normal file
163
app/admin/_components/template-manager/CustomTemplateSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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="直接输入选项,每行一个,例如: 3DMAX PSD AI CAD 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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
78
app/admin/_components/template-manager/types.ts
Normal file
78
app/admin/_components/template-manager/types.ts
Normal 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'
|
||||
}
|
||||
|
||||
|
||||
165
app/admin/_components/types.ts
Normal file
165
app/admin/_components/types.ts
Normal 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
|
||||
}
|
||||
}
|
||||
20
app/admin/_components/ui/EmptyState.tsx
Normal file
20
app/admin/_components/ui/EmptyState.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
45
app/admin/_components/ui/ErrorState.tsx
Normal file
45
app/admin/_components/ui/ErrorState.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
38
app/admin/_components/ui/LoadingState.tsx
Normal file
38
app/admin/_components/ui/LoadingState.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
264
app/admin/_components/users/DepartmentHierarchy.tsx
Normal file
264
app/admin/_components/users/DepartmentHierarchy.tsx
Normal 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
|
||||
|
||||
64
app/admin/_components/users/TransferSuperAdminModal.tsx
Normal file
64
app/admin/_components/users/TransferSuperAdminModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
203
app/admin/_components/users/UserEditModal.tsx
Normal file
203
app/admin/_components/users/UserEditModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
84
app/admin/_components/users/UserTree.tsx
Normal file
84
app/admin/_components/users/UserTree.tsx
Normal 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
|
||||
|
||||
116
app/admin/_hooks/useDashboardData.ts
Normal file
116
app/admin/_hooks/useDashboardData.ts
Normal 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>
|
||||
|
||||
81
app/admin/_hooks/useDataFetch.ts
Normal file
81
app/admin/_hooks/useDataFetch.ts
Normal 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>
|
||||
|
||||
139
app/admin/_hooks/useDepartmentOperations.ts
Normal file
139
app/admin/_hooks/useDepartmentOperations.ts
Normal 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>
|
||||
|
||||
29
app/admin/_hooks/useModalState.ts
Normal file
29
app/admin/_hooks/useModalState.ts
Normal 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>>
|
||||
|
||||
92
app/admin/_hooks/usePermissions.ts
Normal file
92
app/admin/_hooks/usePermissions.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
65
app/admin/_hooks/useToast.ts
Normal file
65
app/admin/_hooks/useToast.ts
Normal 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>
|
||||
|
||||
264
app/admin/_hooks/useUserOperations.ts
Normal file
264
app/admin/_hooks/useUserOperations.ts
Normal 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
94
app/admin/_utils/api.ts
Normal 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
|
||||
}
|
||||
|
||||
79
app/admin/_utils/departmentTree.ts
Normal file
79
app/admin/_utils/departmentTree.ts
Normal 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)
|
||||
}
|
||||
|
||||
|
||||
177
app/admin/_utils/formatters.ts
Normal file
177
app/admin/_utils/formatters.ts
Normal 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%'
|
||||
}
|
||||
}
|
||||
|
||||
170
app/admin/_utils/validators.ts
Normal file
170
app/admin/_utils/validators.ts
Normal 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 }
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
3989
app/admin/page.tsx
3989
app/admin/page.tsx
File diff suppressed because it is too large
Load Diff
@ -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>修改设置后点击“保存设置”按钮,页面会自动刷新以应用新的设置</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>
|
||||
)
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
129
components/ui/Modal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
203
docs/guides/THEME_STYLES_MIGRATION.md
Normal file
203
docs/guides/THEME_STYLES_MIGRATION.md
Normal 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
|
||||
|
||||
242
docs/guides/UI_STYLES_ARCHITECTURE.md
Normal file
242
docs/guides/UI_STYLES_ARCHITECTURE.md
Normal 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
31
hooks/useModal.ts
Normal 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
110
lib/hooks/useThemeStyles.ts
Normal 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
168
lib/ui-animations.ts
Normal 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
147
lib/ui-styles-base.ts
Normal 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
189
lib/ui-styles-composer.ts
Normal 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
20
lib/ui-styles-dark.ts
Normal 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
20
lib/ui-styles-light.ts
Normal 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
|
||||
8
lib/ui-styles-theme-exports.ts
Normal file
8
lib/ui-styles-theme-exports.ts
Normal 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
151
lib/ui-styles.ts
Normal 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
128
lib/ui-theme-colors-dark.ts
Normal 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
|
||||
|
||||
128
lib/ui-theme-colors-light.ts
Normal file
128
lib/ui-theme-colors-light.ts
Normal 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
|
||||
|
||||
@ -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
540
优化计划与优先级.md
Normal 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
354
优化记录.md
Normal 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
28
后续修改.txt
Normal 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
233
更新日志.md
Normal 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
202
设计系统.md
Normal 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
253
设计系统使用指南.md
Normal 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
146
设计系统迁移示例.md
Normal 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. 更新组件文档,说明使用的样式工具类
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user