From e4dcb6260ad55fc3304093256c22bd15df39a86b Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Sat, 22 Nov 2025 15:17:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Admin=E9=A1=B5=E9=9D=A2=E9=87=8D?= =?UTF-8?q?=E6=9E=84=20&=20UI=E4=B8=89=E5=B1=82=E6=9E=B6=E6=9E=84=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 创建统一UI三层架构系统(基础层、颜色层、组合层) - 拆分Admin页面为独立Tab组件(MonitorTab、UsersTab、DepartmentsTab等) - 提取公共逻辑到Hooks(useToast、useDataFetch、useUserOperations等) - 提取工具函数(validators、formatters、api等) - 创建统一弹窗组件(Modal)和useModal Hook - 添加错误边界和统一状态组件 - 完善类型定义和可访问性支持 - 更新文档(优化记录、优化计划、更新日志) --- ; | 0 CHANGELOG.md | 93 - .../_components/AppearanceSettingsPanel.tsx | 858 ++++ app/admin/_components/CategoriesTab.tsx | 885 ++++ app/admin/_components/DepartmentsTab.tsx | 382 ++ app/admin/_components/InvitationsTab.tsx | 322 ++ app/admin/_components/MaterialsTab.tsx | 214 + app/admin/_components/MonitorTab.tsx | 355 ++ app/admin/_components/TabErrorBoundary.tsx | 72 + app/admin/_components/TemplateManager.tsx | 1949 ++------ app/admin/_components/UsersTab.tsx | 537 +++ .../template-manager/BuiltInTemplateGrid.tsx | 76 + .../CustomTemplateSection.tsx | 163 + .../TemplateManagerHeader.tsx | 58 + .../editor/TemplateAdvancedConfigPanel.tsx | 91 + .../editor/TemplateBasicsForm.tsx | 102 + .../editor/TemplateEditorFooter.tsx | 109 + .../editor/TemplateFieldsEditor.tsx | 388 ++ .../editor/TemplatePreviewPanel.tsx | 685 +++ .../_components/template-manager/types.ts | 78 + app/admin/_components/types.ts | 165 + app/admin/_components/ui/EmptyState.tsx | 20 + app/admin/_components/ui/ErrorState.tsx | 45 + app/admin/_components/ui/LoadingState.tsx | 38 + .../_components/users/DepartmentHierarchy.tsx | 264 ++ .../users/TransferSuperAdminModal.tsx | 64 + app/admin/_components/users/UserEditModal.tsx | 203 + app/admin/_components/users/UserTree.tsx | 84 + app/admin/_hooks/useDashboardData.ts | 116 + app/admin/_hooks/useDataFetch.ts | 81 + app/admin/_hooks/useDepartmentOperations.ts | 139 + app/admin/_hooks/useModalState.ts | 29 + app/admin/_hooks/usePermissions.ts | 92 + app/admin/_hooks/useToast.ts | 65 + app/admin/_hooks/useUserOperations.ts | 264 ++ app/admin/_utils/api.ts | 94 + app/admin/_utils/departmentTree.ts | 79 + app/admin/_utils/formatters.ts | 177 + app/admin/_utils/validators.ts | 170 + app/admin/monitor/page.tsx | 189 +- app/admin/page.tsx | 3989 ++--------------- app/admin/settings/page.tsx | 821 +--- app/api/admin/upload-templates/route.ts | 32 +- app/layout.tsx | 24 +- components/Header.tsx | 103 +- components/ui/Modal.tsx | 129 + deploy-new-fixed.ps1 | 5 +- docs/guides/THEME_STYLES_MIGRATION.md | 203 + docs/guides/UI_STYLES_ARCHITECTURE.md | 242 + hooks/useModal.ts | 31 + lib/hooks/useThemeStyles.ts | 110 + lib/ui-animations.ts | 168 + lib/ui-styles-base.ts | 147 + lib/ui-styles-composer.ts | 189 + lib/ui-styles-dark.ts | 20 + lib/ui-styles-light.ts | 20 + lib/ui-styles-theme-exports.ts | 8 + lib/ui-styles.ts | 151 + lib/ui-theme-colors-dark.ts | 128 + lib/ui-theme-colors-light.ts | 128 + tailwind.config.ts | 37 +- 优化计划与优先级.md | 540 +++ 优化记录.md | 354 ++ 后续修改.txt | 28 + 更新日志.md | 233 + 设计系统.md | 202 + 设计系统使用指南.md | 253 ++ 设计系统迁移示例.md | 146 + 68 files changed, 12139 insertions(+), 6097 deletions(-) create mode 100644 ; delete mode 100644 CHANGELOG.md create mode 100644 app/admin/_components/AppearanceSettingsPanel.tsx create mode 100644 app/admin/_components/CategoriesTab.tsx create mode 100644 app/admin/_components/DepartmentsTab.tsx create mode 100644 app/admin/_components/InvitationsTab.tsx create mode 100644 app/admin/_components/MaterialsTab.tsx create mode 100644 app/admin/_components/MonitorTab.tsx create mode 100644 app/admin/_components/TabErrorBoundary.tsx create mode 100644 app/admin/_components/UsersTab.tsx create mode 100644 app/admin/_components/template-manager/BuiltInTemplateGrid.tsx create mode 100644 app/admin/_components/template-manager/CustomTemplateSection.tsx create mode 100644 app/admin/_components/template-manager/TemplateManagerHeader.tsx create mode 100644 app/admin/_components/template-manager/editor/TemplateAdvancedConfigPanel.tsx create mode 100644 app/admin/_components/template-manager/editor/TemplateBasicsForm.tsx create mode 100644 app/admin/_components/template-manager/editor/TemplateEditorFooter.tsx create mode 100644 app/admin/_components/template-manager/editor/TemplateFieldsEditor.tsx create mode 100644 app/admin/_components/template-manager/editor/TemplatePreviewPanel.tsx create mode 100644 app/admin/_components/template-manager/types.ts create mode 100644 app/admin/_components/types.ts create mode 100644 app/admin/_components/ui/EmptyState.tsx create mode 100644 app/admin/_components/ui/ErrorState.tsx create mode 100644 app/admin/_components/ui/LoadingState.tsx create mode 100644 app/admin/_components/users/DepartmentHierarchy.tsx create mode 100644 app/admin/_components/users/TransferSuperAdminModal.tsx create mode 100644 app/admin/_components/users/UserEditModal.tsx create mode 100644 app/admin/_components/users/UserTree.tsx create mode 100644 app/admin/_hooks/useDashboardData.ts create mode 100644 app/admin/_hooks/useDataFetch.ts create mode 100644 app/admin/_hooks/useDepartmentOperations.ts create mode 100644 app/admin/_hooks/useModalState.ts create mode 100644 app/admin/_hooks/usePermissions.ts create mode 100644 app/admin/_hooks/useToast.ts create mode 100644 app/admin/_hooks/useUserOperations.ts create mode 100644 app/admin/_utils/api.ts create mode 100644 app/admin/_utils/departmentTree.ts create mode 100644 app/admin/_utils/formatters.ts create mode 100644 app/admin/_utils/validators.ts create mode 100644 components/ui/Modal.tsx create mode 100644 docs/guides/THEME_STYLES_MIGRATION.md create mode 100644 docs/guides/UI_STYLES_ARCHITECTURE.md create mode 100644 hooks/useModal.ts create mode 100644 lib/hooks/useThemeStyles.ts create mode 100644 lib/ui-animations.ts create mode 100644 lib/ui-styles-base.ts create mode 100644 lib/ui-styles-composer.ts create mode 100644 lib/ui-styles-dark.ts create mode 100644 lib/ui-styles-light.ts create mode 100644 lib/ui-styles-theme-exports.ts create mode 100644 lib/ui-styles.ts create mode 100644 lib/ui-theme-colors-dark.ts create mode 100644 lib/ui-theme-colors-light.ts create mode 100644 优化计划与优先级.md create mode 100644 优化记录.md create mode 100644 后续修改.txt create mode 100644 更新日志.md create mode 100644 设计系统.md create mode 100644 设计系统使用指南.md create mode 100644 设计系统迁移示例.md diff --git a/; b/; new file mode 100644 index 0000000..e69de29 diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index f6ac3fb..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -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`,确认服务真正「跑起来」再宣告成功。 - - 避免刚部署完就打开页面,结果服务还在启动中的尴尬。 - ---- - -### 🎛 其他细节优化 - -- 🧭 系统管理页默认打开 **「监控仪表板」** 标签,而不是素材管理,让管理者一眼看到系统状态。 -- 🎨 调整架构管理 & 用户管理的卡片、阴影、悬浮效果与按钮样式,使整个后台的观感更统一、更接近苹果风格。 - -> 小结:这一版可以理解为「**权限体验 + 电商字段 + 可迁移部署脚本**」的整合升级版,为后续更多客户侧部署和定制做好了「打基础」工作。✨ diff --git a/app/admin/_components/AppearanceSettingsPanel.tsx b/app/admin/_components/AppearanceSettingsPanel.tsx new file mode 100644 index 0000000..4caaa69 --- /dev/null +++ b/app/admin/_components/AppearanceSettingsPanel.tsx @@ -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(null) + const [lastSavedAt, setLastSavedAt] = useState(null) + const [heroGlowPos, setHeroGlowPos] = useState({ x: 50, y: 50 }) + const [activeNav, setActiveNav] = useState('brand') + const [visibleSections, setVisibleSections] = useState>({ + brand: true, + logos: true, + toggles: true, + storage: true, + }) + const [isDarkMode, setIsDarkMode] = useState(false) + + const brandRef = useRef(null) + const logoRef = useRef(null) + const toggleRef = useRef(null) + const storageRef = useRef(null) + + const sectionRefs: Record<(typeof sectionConfig)[number]['key'], RefObject> = 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) => { + 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 = ( + <> + {/* 精致光球 - 右上角 - 使用相近的蓝色系 */} +
+ + {/* 精致光球 - 左下角 - 使用相近的紫色系 */} +
+ + ) + + const staggerDelays: Record = { + brand: 'delay-[120ms]', + logos: 'delay-[220ms]', + toggles: 'delay-[320ms]', + storage: 'delay-[420ms]', + } + + if (!sessionUser || sessionUser.role !== 'ADMIN') { + return ( +
+ 当前账号无权查看外观设置。 +
+ ) + } + + if (loading) { + return ( +
+
+

正在加载外观设置...

+
+ ) + } + + return ( +
+
+ {floatingOrbs} +
+
+

Appearance Studio

+

外观设置工作台

+

+ 沿用模板管理的分区式结构,这里集中处理品牌主题、LOGO、功能开关与对象存储。每次保存都会即时同步到后台界面与素材上传入口。 +

+ {lastSavedAt && ( +

+ 最近保存时间:{lastSavedAt.toLocaleString()} +

+ )} +
+
+ {variant === 'standalone' && ( + + )} + +
+
+
+ +
+
+
+

+ 导航 +

+
+ {sectionConfig.map((section) => ( + + ))} +
+
+ +
+
+

状态

+

当前主题色

+
+
+
+
+

{siteName}

+

{headerBgColor}

+
+ 导航示例 +
+
+ +
+
+ +
+
+
+

Section 01

+

品牌与主题

+

同步修改站点名称以及顶部导航栏主色。

+
+
+
+ + setSiteName(e.target.value)} + placeholder="由壹点3D素材" + className={styles.inputStyles.base} + /> +

将同步至后台导航、登录页和浏览器标题。

+
+
+ +
+ + setHeaderBgColor(e.target.value)} + className={cn(styles.inputStyles.base, 'flex-1 text-sm font-mono')} + /> +
+
+ {['#1f2937', '#4f46e5', '#1e40af', '#7c3aed', '#0891b2', '#059669', '#dc2626', '#d97706'].map((color) => ( +
+
+
+
+ +
+
+

Section 02

+

LOGO 管理

+

为深色导航与浅色登录界面分别上传 LOGO。

+
+
+ {[ + { + 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) => ( +
+
+

{item.title}

+

{item.tip}

+
+
+ {item.preview ? ( + {item.title} + ) : ( + + + + )} +
+
+ + +
+
+ ))} +
+
+ +
+
+

Section 03

+

功能开关

+

敏感功能仅限超级管理员调整,其他开关即时生效。

+
+
+ {[ + { + 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) => ( +
+
+

{item.title}

+

{item.desc}

+ {item.disabled && ( +

仅超级管理员可修改

+ )} +
+ +
+ ))} +
+
+ +
+
+

Section 04

+

对象存储配置

+

用于托管上传素材与 LOGO,可对接 S3 / MinIO。

+
+
+
+ + setS3Endpoint(e.target.value)} + placeholder="s3.example.com" + className={cn(styles.inputStyles.base, 'text-sm')} + /> +
+
+ + setS3PublicUrl(e.target.value)} + placeholder="https://cdn.example.com" + className={cn(styles.inputStyles.base, 'text-sm')} + /> +
+
+
+
+ + setS3AccessKey(e.target.value)} + placeholder="访问密钥" + className={cn(styles.inputStyles.base, 'text-sm font-mono')} + /> +
+
+ + setS3SecretKey(e.target.value)} + placeholder="安全密钥" + className={cn(styles.inputStyles.base, 'text-sm font-mono')} + /> +
+
+
+
+ + setS3Bucket(e.target.value)} + placeholder="uploads-bucket" + className={cn(styles.inputStyles.base, 'text-sm')} + /> +
+
+ + setS3Region(e.target.value)} + placeholder="us-east-1" + className={cn(styles.inputStyles.base, 'text-sm')} + /> +
+
+
+

配置提示

+
    +
  • S3 端点通常为对象存储服务器地址
  • +
  • Access/Secret Key 需保证与存储桶权限一致
  • +
  • 如使用 CDN,请将加速域名填入公网 URL
  • +
  • 修改后建议重启后台服务确保配置加载
  • +
+
+
+ +
+ {toast && ( + + {toast.message} + + )} + + +
+
+
+
+ ) +} + diff --git a/app/admin/_components/CategoriesTab.tsx b/app/admin/_components/CategoriesTab.tsx new file mode 100644 index 0000000..b81a93a --- /dev/null +++ b/app/admin/_components/CategoriesTab.tsx @@ -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([]) + const [scenes, setScenes] = useState([]) + const [platforms, setPlatforms] = useState([]) + const [editingCategory, setEditingCategory] = useState(null) + const [editingScene, setEditingScene] = useState(null) + const [editingPlatform, setEditingPlatform] = useState(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) => ( +
+ {editingCategory?.id === category.id ? ( +
+
+ + { + if (e.key === 'Enter') { + const editor = e.currentTarget.closest('[data-category-editor]') as HTMLElement | null + const optionsInput = editor?.querySelector('textarea[name="category-options"]') + handleUpdateCategory(category.id, e.currentTarget.value, optionsInput?.value || null) + } else if (e.key === 'Escape') { + setEditingCategory(null) + } + }} + autoFocus + /> +
+
+ +