feat: 统一弹窗样式系统与分类管理优化
Some checks failed
CI Check / check (push) Has been cancelled

- 创建统一的Modal组件,采用Apple风格设计
- 实现毛玻璃效果、圆角、动画和细边框描边
- 修复弹窗打开时背景滚动问题
- 修复分类管理中重复卡片和数组字符串显示问题
- 优化选项解析逻辑,添加去重和过滤机制
This commit is contained in:
WuYongJie 2025-11-25 09:37:00 +08:00
parent 492b97213e
commit 8705b9846a
72 changed files with 6438 additions and 2383 deletions

5
.gitignore vendored
View File

@ -81,6 +81,11 @@ next-env.d.ts
*.zip
*.rpm
# Docker images (large files, use Git LFS if needed)
/docker-images/**/*.tar
/docker-images/**/*.tar.gz
!docker-images/.gitkeep
# Temporary files
deploy-commands.txt
构建速查表.txt

View File

@ -243,6 +243,8 @@ COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/.prisma ./node_modules/.prisma
# 复制 Prisma 包,确保运行时存在 @prisma/client 及其生成器
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/@prisma ./node_modules/@prisma
# 复制 Prisma CLI(用于运行时执行 migrate deploy)
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/prisma ./node_modules/prisma
# 复制 Canvas 模块(可选依赖)
# 如果 canvas 未安装,builder 阶段已创建占位符,所以这个 COPY 不会失败

View File

@ -3,7 +3,7 @@
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 { animations, gradientBackgrounds, glowEffects, textAnimations, scrollToElementWithSpring } from '@/lib/ui-animations'
import type { SessionUser } from './types'
type AppearanceSettingsPanelProps = {
@ -67,6 +67,8 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
const logoRef = useRef<HTMLDivElement | null>(null)
const toggleRef = useRef<HTMLDivElement | null>(null)
const storageRef = useRef<HTMLDivElement | null>(null)
const navCardRef = useRef<HTMLDivElement | null>(null)
const [navCardHeight, setNavCardHeight] = useState(0)
const sectionRefs: Record<(typeof sectionConfig)[number]['key'], RefObject<HTMLDivElement>> = useMemo(
() => ({
@ -114,6 +116,33 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
return () => observer.disconnect()
}, [])
// 动态计算导航卡片高度,用于设置状态卡片位置
useEffect(() => {
const updateNavCardHeight = () => {
if (navCardRef.current) {
// 使用 getBoundingClientRect 获取更准确的高度
const rect = navCardRef.current.getBoundingClientRect()
const height = rect.height
setNavCardHeight(height)
document.documentElement.style.setProperty('--nav-card-height', `${height}px`)
}
}
// 初始计算
updateNavCardHeight()
// 延迟再次计算,确保 DOM 完全渲染
const timeoutId = setTimeout(updateNavCardHeight, 100)
const resizeObserver = new ResizeObserver(updateNavCardHeight)
if (navCardRef.current) {
resizeObserver.observe(navCardRef.current)
}
window.addEventListener('resize', updateNavCardHeight)
return () => {
clearTimeout(timeoutId)
resizeObserver.disconnect()
window.removeEventListener('resize', updateNavCardHeight)
}
}, [])
const fetchSettings = useCallback(async () => {
setLoading(true)
try {
@ -247,7 +276,14 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
const scrollToSection = (key: SectionKey) => {
const ref = sectionRefs[key]
ref?.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
if (!ref?.current) return
// 使用苹果风格的缓入缓出滚动(页面级滚动,和分类管理保持一致)
scrollToElementWithSpring(ref.current, null, {
offset: 96, // 考虑 sticky top-24 (6rem) 的高度,和左侧小卡片上面对齐
duration: 600,
easing: 'ease-in-out',
})
setActiveNav(key)
}
@ -365,10 +401,10 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
<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',
'inline-flex items-center gap-2 rounded-xl border px-5 py-3 text-sm font-medium transition-all',
theme === 'dark'
? 'border-white/20 bg-white/5 text-white/80 hover:bg-white/10'
: 'border-white/40 bg-white/10 text-white/90'
? 'border-white/20 bg-white/5 text-white/80 hover:bg-white/10 hover:bg-white/20'
: 'border-slate-300/50 bg-slate-50/80 text-slate-700 hover:bg-slate-100/80'
)}
>
快速定位
@ -408,12 +444,15 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
</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')}>
<div className="flex flex-col gap-6">
<div
id="nav-card"
ref={navCardRef}
className={cn(styles.cardStyles.base, 'p-5 backdrop-blur-sm flex flex-col sticky top-24 max-h-[calc(100vh-8rem)]')}>
<p className={cn('text-xs font-semibold tracking-[0.3em] uppercase mb-4 flex-shrink-0', theme === 'dark' ? 'text-slate-200' : 'text-slate-500')}>
导航
</p>
<div className="space-y-3">
<div className="space-y-3 flex-1 min-h-0 overflow-y-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
{sectionConfig.map((section) => (
<button
key={section.key}
@ -441,13 +480,20 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
</div>
</div>
<div className={cn(
<div
id="status-card"
className={cn(
styles.cardStyles.base,
'p-5 space-y-4 backdrop-blur-sm',
'p-5 space-y-4 backdrop-blur-sm flex flex-col',
'sticky',
theme === 'dark'
? 'bg-gradient-to-br from-slate-900 to-slate-800'
: 'bg-gradient-to-br from-white to-slate-50'
)}>
)}
style={{
top: navCardHeight > 0 ? `calc(6rem + ${navCardHeight}px + 1.5rem)` : 'calc(6rem + 1.5rem)'
}}
>
<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>
@ -597,7 +643,7 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
{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">
<svg className={cn('w-12 h-12', theme === 'dark' ? 'text-slate-400' : 'text-slate-400')} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
)}

File diff suppressed because it is too large Load Diff

View File

@ -210,7 +210,7 @@ export default function DepartmentsTab({
className={cn(
styles.inputStyles.base,
styles.inputModifiers.compact,
'flex-1',
'flex-1 max-w-xs', // 限制最大宽度,避免输入框太宽
borderInputIndigo,
bgInput,
)}
@ -226,7 +226,11 @@ export default function DepartmentsTab({
<button
type="button"
onClick={() => void handleRenameSubmit()}
className={cn(styles.buttonStyles.primary, styles.buttonModifiers.compact)}
className={cn(
styles.buttonStyles.primary,
styles.buttonModifiers.compact,
'bg-indigo-600 hover:bg-indigo-700 text-white' // 显式添加背景色和文字色
)}
>
保存
</button>
@ -236,7 +240,11 @@ export default function DepartmentsTab({
setRenamingDepartmentId(null)
setRenamingDepartmentName('')
}}
className={cn(styles.buttonStyles.secondary, styles.buttonModifiers.compact)}
className={cn(
styles.buttonStyles.secondary,
styles.buttonModifiers.compact,
'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-200' // 显式添加背景色和文字色
)}
>
取消
</button>
@ -249,7 +257,11 @@ export default function DepartmentsTab({
e.stopPropagation()
void handleAddSubDepartment(node.fullName)
}}
className={cn(styles.buttonStyles.primary, styles.buttonModifiers.compact)}
className={cn(
styles.buttonStyles.primary,
styles.buttonModifiers.compact,
'bg-indigo-600 hover:bg-indigo-700 text-white' // 显式添加背景色和文字色
)}
>
添加子分支
</button>
@ -259,7 +271,11 @@ export default function DepartmentsTab({
e.stopPropagation()
handleStartRename(node.id, node.fullName)
}}
className={cn(styles.buttonStyles.secondary, styles.buttonModifiers.compact)}
className={cn(
styles.buttonStyles.secondary,
styles.buttonModifiers.compact,
'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-200' // 显式添加背景色和文字色
)}
>
重命名
</button>
@ -269,7 +285,11 @@ export default function DepartmentsTab({
e.stopPropagation()
void handleDelete(node.id, node.fullName)
}}
className={cn(styles.buttonStyles.danger, styles.buttonModifiers.compact)}
className={cn(
styles.buttonStyles.danger,
styles.buttonModifiers.compact,
// 统一UI样式已包含颜色,不需要额外添加
)}
>
删除
</button>
@ -290,14 +310,20 @@ export default function DepartmentsTab({
<div className="space-y-6">
<section
className={cn(
styles.cardStyles.large,
`bg-gradient-to-br ${bgGradient}`,
'relative overflow-hidden rounded-3xl border mb-6 flex-shrink-0',
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',
)}
>
<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)}>
<div className="relative z-10 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-3">
<p className={cn('text-sm font-semibold tracking-[0.2em] uppercase', theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600')}>
Architecture Management
</p>
<h2 className={cn('text-2xl font-semibold', textTitle)}>组织脉络</h2>
<p className={cn('text-sm leading-relaxed', textDesc)}>
树形结构展示上下级,配合搜索框即可迅速过滤任意分支或定位负责人。
</p>
</div>

View File

@ -257,7 +257,11 @@ export default function InvitationsTab({ showToast }: InvitationsTabProps) {
</button>
<button
onClick={() => handleDeleteInvitation(invitation.id)}
className={cn(styles.buttonStyles.danger, styles.buttonModifiers.compactXs)}
className={cn(
styles.buttonStyles.danger,
styles.buttonModifiers.compactXs,
'bg-red-500 hover:bg-red-600 text-white' // 显式添加背景色和文字色
)}
>
删除
</button>

View File

@ -87,35 +87,47 @@ export default function MonitorTab({ showToast }: MonitorTabProps) {
return (
<div className="space-y-8">
<div
<section
className={cn(
styles.cardStyles.base,
cn('flex flex-wrap items-center justify-between gap-4 border', borderColor),
'relative overflow-hidden rounded-3xl border mb-6 flex-shrink-0',
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',
)}
>
<div>
<p className={cn('text-sm font-semibold', textTitle)}>系统监控概览</p>
<p className={cn('text-xs', textDesc)}>最近更新:{formattedLastUpdated}</p>
<div className="relative z-10 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-3">
<p className={cn('text-sm font-semibold tracking-[0.2em] uppercase', theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600')}>
System Monitor
</p>
<h2 className={cn('text-2xl font-semibold', textTitle)}>系统监控概览</h2>
<p className={cn('text-sm leading-relaxed', 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',
'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-200' // 显式添加背景色、边框和文字色
)}
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="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>
</section>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6">
{OVERVIEW_CARDS.map((card) => (

View File

@ -4,6 +4,7 @@ import { DragEvent, useCallback, useEffect, useMemo, useState } from 'react'
import { formatDateTimeShort } from '../_utils/formatters'
import { useThemeStyles, cn } from '@/lib/ui-styles'
import { useDialog } from '@/components/ui/DialogProvider'
import Modal from '@/components/ui/Modal'
import { animations } from '@/lib/ui-animations'
import TemplateManagerHeader from './template-manager/TemplateManagerHeader'
import BuiltInTemplateGrid from './template-manager/BuiltInTemplateGrid'
@ -30,6 +31,22 @@ const STATUS_OPTIONS = [
{ value: 'archived', label: '归档' },
]
const MATERIAL_TYPE_OPTIONS = ['3DMAX', 'PSD', 'AI 工作流', '视频', '模特', '其他']
// 默认需要进入分类管理的字段集合,可被后台实时配置覆盖
const DEFAULT_CATEGORY_FIELDS = [
'categoryName',
'sceneName',
'platformName',
'renderer',
'brandName',
'seriesName',
'materialTexture',
] as const
// 全局集合对象会在运行时被最新配置覆盖,保持与后台分类管理同步
const GLOBAL_CATEGORY_FIELDS = new Set<string>(DEFAULT_CATEGORY_FIELDS)
const FIELD_TYPES = ['text', 'textarea', 'number', 'select', 'checkbox', 'radio', 'image', 'file', 'date', 'uppercase']
const LAYOUT_OPTIONS = [
{ value: 'full', label: '整行' },
@ -45,6 +62,7 @@ const defaultField = (): FieldForm => ({
visibleOnHomeFilter: false,
isSearchable: false,
isReadOnly: false,
addToCategory: false,
validations: '',
optionsStatic: '',
allowedExtensions: '',
@ -52,32 +70,18 @@ const defaultField = (): FieldForm => ({
autoLabel: true,
})
// 标准字段配置:键名 + 推荐展示名称 + 默认FieldForm
// 标准字段配置:键名 + 推荐展示名称 + 基础FieldForm(属性不写死,由预制模板动态设置)
const STANDARD_FIELD_TEMPLATES: Record<string, FieldForm> = {
materialType: {
key: 'materialType',
label: '素材类型',
type: 'select',
required: true,
layoutWidth: 'full',
visibleOnHomeFilter: true,
isSearchable: true,
isReadOnly: false,
validations: '',
optionsStatic: JSON.stringify(['3DMAX', 'PSD', 'AI', 'CAD', 'C4D', '视频', '材质', '模特', '标准饰品', '其他']),
allowedExtensions: '',
conditions: '',
autoLabel: false,
},
productModel: {
key: 'productModel',
label: '型号',
label: 'SPU型号',
type: 'text',
required: true,
required: false, // 默认不勾选,由预制模板动态设置
layoutWidth: 'full',
visibleOnHomeFilter: false,
isSearchable: true,
isSearchable: false, // 默认不勾选,由预制模板动态设置
isReadOnly: false,
addToCategory: false,
validations: '',
optionsStatic: '',
allowedExtensions: '',
@ -87,14 +91,15 @@ const STANDARD_FIELD_TEMPLATES: Record<string, FieldForm> = {
categoryName: {
key: 'categoryName',
label: '使用店铺',
type: 'text',
required: false,
type: 'select',
required: false, // 默认不勾选,由预制模板动态设置
layoutWidth: 'full',
visibleOnHomeFilter: true,
isSearchable: true,
visibleOnHomeFilter: false, // 默认不勾选,由预制模板动态设置
isSearchable: false, // 默认不勾选,由预制模板动态设置
isReadOnly: false,
addToCategory: true,
validations: '',
optionsStatic: '',
optionsStatic: JSON.stringify(['默认店铺']),
allowedExtensions: '',
conditions: '',
autoLabel: false,
@ -103,13 +108,14 @@ const STANDARD_FIELD_TEMPLATES: Record<string, FieldForm> = {
key: 'sceneName',
label: '场景类别',
type: 'select',
required: false,
required: false, // 默认不勾选,由预制模板动态设置
layoutWidth: 'full',
visibleOnHomeFilter: true,
isSearchable: true,
visibleOnHomeFilter: false, // 默认不勾选,由预制模板动态设置
isSearchable: false, // 默认不勾选,由预制模板动态设置
isReadOnly: false,
addToCategory: true,
validations: '',
optionsStatic: '',
optionsStatic: JSON.stringify(['默认场景']),
allowedExtensions: '',
conditions: '',
autoLabel: false,
@ -117,14 +123,15 @@ const STANDARD_FIELD_TEMPLATES: Record<string, FieldForm> = {
platformName: {
key: 'platformName',
label: '平台分类',
type: 'text',
type: 'text', // 根据迁移文档,是text类型
required: false,
layoutWidth: 'half',
visibleOnHomeFilter: false,
isSearchable: true,
isSearchable: false, // 默认不勾选,由预制模板动态设置
isReadOnly: false,
addToCategory: true,
validations: '',
optionsStatic: '',
optionsStatic: '', // text类型不需要optionsStatic
allowedExtensions: '',
conditions: '',
autoLabel: false,
@ -132,14 +139,15 @@ const STANDARD_FIELD_TEMPLATES: Record<string, FieldForm> = {
renderer: {
key: 'renderer',
label: '渲染器',
type: 'text',
required: false,
layoutWidth: 'half',
type: 'select',
required: false, // 默认不勾选,由预制模板动态设置
layoutWidth: 'full',
visibleOnHomeFilter: false,
isSearchable: true,
isSearchable: false, // 默认不勾选,由预制模板动态设置
isReadOnly: false,
addToCategory: true,
validations: '',
optionsStatic: '',
optionsStatic: JSON.stringify(['CR', 'VRay', 'Corona', 'Arnold', 'Octane', '其他']),
allowedExtensions: '',
conditions: '',
autoLabel: false,
@ -147,14 +155,15 @@ const STANDARD_FIELD_TEMPLATES: Record<string, FieldForm> = {
brandName: {
key: 'brandName',
label: '品牌名称',
type: 'text',
type: 'text', // 根据迁移文档,是text类型
required: false,
layoutWidth: 'half',
visibleOnHomeFilter: false,
isSearchable: true,
isSearchable: false, // 默认不勾选,由预制模板动态设置
isReadOnly: false,
addToCategory: true,
validations: '',
optionsStatic: '',
optionsStatic: '', // text类型不需要optionsStatic
allowedExtensions: '',
conditions: '',
autoLabel: false,
@ -162,14 +171,15 @@ const STANDARD_FIELD_TEMPLATES: Record<string, FieldForm> = {
seriesName: {
key: 'seriesName',
label: '系列 / 产品线',
type: 'text',
type: 'text', // 根据迁移文档,是text类型
required: false,
layoutWidth: 'half',
visibleOnHomeFilter: false,
isSearchable: true,
isSearchable: false, // 默认不勾选,由预制模板动态设置
isReadOnly: false,
addToCategory: true,
validations: '',
optionsStatic: '',
optionsStatic: '', // text类型不需要optionsStatic
allowedExtensions: '',
conditions: '',
autoLabel: false,
@ -181,8 +191,9 @@ const STANDARD_FIELD_TEMPLATES: Record<string, FieldForm> = {
required: false,
layoutWidth: 'half',
visibleOnHomeFilter: false,
isSearchable: true,
isSearchable: false, // 默认不勾选,由预制模板动态设置
isReadOnly: false,
addToCategory: false,
validations: '',
optionsStatic: '',
allowedExtensions: '',
@ -192,14 +203,15 @@ const STANDARD_FIELD_TEMPLATES: Record<string, FieldForm> = {
materialTexture: {
key: 'materialTexture',
label: '材质 / 工艺',
type: 'text',
type: 'text', // 根据迁移文档,是text类型
required: false,
layoutWidth: 'half',
visibleOnHomeFilter: false,
isSearchable: true,
isSearchable: false, // 默认不勾选,由预制模板动态设置
isReadOnly: false,
addToCategory: true,
validations: '',
optionsStatic: '',
optionsStatic: '', // text类型不需要optionsStatic
allowedExtensions: '',
conditions: '',
autoLabel: false,
@ -213,6 +225,7 @@ const STANDARD_FIELD_TEMPLATES: Record<string, FieldForm> = {
visibleOnHomeFilter: false,
isSearchable: false,
isReadOnly: false,
addToCategory: false,
validations: '',
optionsStatic: '',
allowedExtensions: '',
@ -228,6 +241,7 @@ const STANDARD_FIELD_TEMPLATES: Record<string, FieldForm> = {
visibleOnHomeFilter: false,
isSearchable: false,
isReadOnly: false,
addToCategory: false,
validations: '',
optionsStatic: '',
allowedExtensions: '',
@ -243,6 +257,7 @@ const STANDARD_FIELD_TEMPLATES: Record<string, FieldForm> = {
visibleOnHomeFilter: false,
isSearchable: false,
isReadOnly: false,
addToCategory: false,
validations: '',
optionsStatic: '',
allowedExtensions: '',
@ -256,8 +271,9 @@ const STANDARD_FIELD_TEMPLATES: Record<string, FieldForm> = {
required: false,
layoutWidth: 'half',
visibleOnHomeFilter: false,
isSearchable: true,
isSearchable: false, // 默认不勾选,由预制模板动态设置
isReadOnly: false,
addToCategory: false,
validations: '',
optionsStatic: '',
allowedExtensions: '',
@ -280,7 +296,6 @@ const cloneFieldTemplate = (key: string): FieldForm | null => {
// 默认模板字段
const getDefaultTemplateFields = (): FieldForm[] => {
const defaultKeys = [
'materialType',
'productModel',
'sceneName',
'brandName',
@ -295,60 +310,96 @@ const getDefaultTemplateFields = (): FieldForm[] => {
}
// 内置常用模板(使用新的字段标准)
const applySimplifiedFieldFlags = (
fields: FieldForm[],
options: { requireRenderer?: boolean } = {}
) => {
const { requireRenderer = false } = options
fields.forEach((field) => {
if (field.key === 'productModel') {
field.required = true
field.isSearchable = true
} else if (field.key === 'categoryName' || field.key === 'sceneName') {
field.required = true
field.visibleOnHomeFilter = true
field.isSearchable = true
} else if (field.key === 'renderer') {
field.required = requireRenderer
field.isSearchable = true
}
})
return fields
}
// 内置常用模板(使用老版本简化字段)
// 全新版本仅保留 3DMAX、PSD、AI 工作流、视频、模特、其他 六种预制模板
const BUILTIN_TEMPLATES: BuiltinTemplateConfig[] = [
{
key: '3dmax-material',
name: '3DMAX 素材模板',
description: '适用于 3D 模型类素材,覆盖渲染器、SKU 与品牌等核心字段。',
materialType: '3DMAX',
getFields: () => {
const keys = ['materialType', 'productModel', 'renderer', 'sceneName', 'brandName', 'seriesName', 'skuCode']
return keys.map((key) => cloneFieldTemplate(key)).filter(Boolean) as FieldForm[]
const keys = ['productModel', 'categoryName', 'sceneName', 'renderer']
const fields = keys.map((key) => cloneFieldTemplate(key)).filter(Boolean) as FieldForm[]
return applySimplifiedFieldFlags(fields, { requireRenderer: true })
},
},
{
key: 'psd-material',
name: 'PS 素材模板',
name: 'PSD 素材模板',
description: '针对平面设计 PSD 素材,包含品牌、颜色模式和输出分辨率。',
materialType: 'PSD',
getFields: () => {
const keys = ['materialType', 'productModel', 'categoryName', 'sceneName', 'brandName', 'colorMode', 'imageResolution']
return keys.map((key) => cloneFieldTemplate(key)).filter(Boolean) as FieldForm[]
const keys = ['productModel', 'categoryName', 'sceneName']
const fields = keys.map((key) => cloneFieldTemplate(key)).filter(Boolean) as FieldForm[]
return applySimplifiedFieldFlags(fields)
},
},
{
key: 'ai-material',
name: 'AI 工作流',
description: '面向 AI 生成/矢量工作流,仅保留必要的基础字段。',
materialType: 'AI 工作流',
getFields: () => {
const keys = ['productModel', 'categoryName', 'sceneName']
const fields = keys.map((key) => cloneFieldTemplate(key)).filter(Boolean) as FieldForm[]
return applySimplifiedFieldFlags(fields)
},
},
{
key: 'video-material',
name: '视频素材模板',
description: '适用于视频文件素材。',
materialType: '视频',
getFields: () => {
const keys = ['productModel', 'categoryName', 'sceneName']
const fields = keys.map((key) => cloneFieldTemplate(key)).filter(Boolean) as FieldForm[]
return applySimplifiedFieldFlags(fields)
},
},
{
key: 'model-material',
name: '模特素材模板',
description: '用于模特/人像素材,关注拍摄场景与负责作者。',
materialType: '模特',
getFields: () => {
const keys = ['materialType', 'productModel', 'sceneName', 'categoryName', 'authorName', 'brandName']
return keys.map((key) => cloneFieldTemplate(key)).filter(Boolean) as FieldForm[]
const keys = ['productModel', 'categoryName', 'sceneName']
const fields = keys.map((key) => cloneFieldTemplate(key)).filter(Boolean) as FieldForm[]
return applySimplifiedFieldFlags(fields)
},
},
{
key: 'texture-material',
name: '材质素材模板',
description: '聚焦材质/工艺与输出参数,便于材质库管理。',
key: 'other-material',
name: '其他素材模板',
description: '适用于其他类型的素材。',
materialType: '其他',
getFields: () => {
const keys = ['materialType', 'materialTexture', 'sceneName', 'imageResolution', 'colorMode']
return keys.map((key) => cloneFieldTemplate(key)).filter(Boolean) as FieldForm[]
},
},
{
key: 'photo-material',
name: '实拍素材模板',
description: '适用于实拍图/场景图,包含店铺、场景和作者信息。',
getFields: () => {
const keys = ['materialType', 'categoryName', 'sceneName', 'productModel', 'authorName']
return keys.map((key) => cloneFieldTemplate(key)).filter(Boolean) as FieldForm[]
},
},
{
key: 'video-material',
name: '视频素材模板',
description: '面向短视频/实景动画,配置渲染时间与分辨率。',
getFields: () => {
const keys = ['materialType', 'productModel', 'sceneName', 'renderTime', 'imageResolution', 'colorMode']
return keys.map((key) => cloneFieldTemplate(key)).filter(Boolean) as FieldForm[]
const keys = ['productModel', 'categoryName', 'sceneName']
const fields = keys.map((key) => cloneFieldTemplate(key)).filter(Boolean) as FieldForm[]
return applySimplifiedFieldFlags(fields)
},
},
]
@ -403,9 +454,9 @@ const requestJson = async <T,>(input: RequestInfo, init?: RequestInit): Promise<
try {
const text = await res.text()
message = text || message
} catch {
// ignore
}
} catch {
// ignore
}
}
// 记录详细的错误信息
@ -443,6 +494,180 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
const [isPreviewMode, setIsPreviewMode] = useState(false)
const [previewTemplate, setPreviewTemplate] = useState<UploadTemplateDto | null>(null)
const [templateSearchTerm, setTemplateSearchTerm] = useState('')
const [enabledBuiltinTemplates, setEnabledBuiltinTemplates] = useState<Set<string>>(new Set())
// 分类字段集合同步自后台配置,默认使用常量兜底
const [categoryFieldKeys, setCategoryFieldKeys] = useState<string[]>(Array.from(GLOBAL_CATEGORY_FIELDS))
const ALL_BUILTIN_KEYS = useMemo(() => BUILTIN_TEMPLATES.map(t => t.key), []) // 所有预制模板key
const syncCategoryFieldSet = useCallback((keys: string[]) => {
GLOBAL_CATEGORY_FIELDS.clear()
keys.forEach((key) => GLOBAL_CATEGORY_FIELDS.add(key))
setCategoryFieldKeys(Array.from(GLOBAL_CATEGORY_FIELDS))
}, [])
useEffect(() => {
const loadCategoryFieldConfig = async () => {
try {
const response = await fetch('/api/category-fields', {
cache: 'no-store',
headers: {
'Cache-Control': 'no-cache',
},
})
if (response.ok) {
const data = await response.json()
if (Array.isArray(data?.managedFields) && data.managedFields.length > 0) {
syncCategoryFieldSet(data.managedFields)
return
}
}
syncCategoryFieldSet([...DEFAULT_CATEGORY_FIELDS])
} catch (error) {
console.warn('Failed to load category field settings, fallback to defaults.', error)
syncCategoryFieldSet([...DEFAULT_CATEGORY_FIELDS])
}
}
loadCategoryFieldConfig()
}, [syncCategoryFieldSet])
// 加载启用的预制模板(从数据库)
useEffect(() => {
const loadEnabledTemplates = async () => {
try {
const response = await fetch('/api/system-settings', {
cache: 'no-store',
headers: {
'Cache-Control': 'no-cache',
},
})
if (response.ok) {
const settings = await response.json()
if (settings.enabledBuiltinTemplates) {
const enabled = JSON.parse(settings.enabledBuiltinTemplates)
// 如果为空数组或null,表示全部启用
if (Array.isArray(enabled) && enabled.length > 0) {
setEnabledBuiltinTemplates(new Set(enabled))
} else {
// 全部启用,设置为所有key
setEnabledBuiltinTemplates(new Set(ALL_BUILTIN_KEYS))
}
} else {
// 字段不存在,表示全部启用
setEnabledBuiltinTemplates(new Set(ALL_BUILTIN_KEYS))
}
}
} catch (e) {
console.error('Failed to load enabled builtin templates:', e)
// 出错时默认全部启用
setEnabledBuiltinTemplates(new Set(ALL_BUILTIN_KEYS))
}
}
loadEnabledTemplates()
}, [ALL_BUILTIN_KEYS])
// 通知首页刷新模板列表的辅助函数
const notifyTemplatesUpdate = useCallback(() => {
try {
// 使用 BroadcastChannel 通知同源的其他标签页
const channel = new BroadcastChannel('templates-channel')
channel.postMessage({ type: 'templates-updated' })
channel.close()
// 使用 localStorage 触发 storage 事件(跨标签页)
localStorage.setItem('templates-updated', Date.now().toString())
setTimeout(() => {
localStorage.removeItem('templates-updated')
}, 100)
} catch (e) {
console.error('Failed to notify templates update:', e)
}
}, [])
// 保存启用的预制模板(到数据库)
const saveEnabledBuiltinTemplates = useCallback(async (enabled: Set<string>) => {
try {
const enabledArray = Array.from(enabled)
// 如果全部启用,保存空数组(表示全部启用)
const valueToSave = enabledArray.length === ALL_BUILTIN_KEYS.length ? [] : enabledArray
const response = await fetch('/api/system-settings', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-cache',
},
body: JSON.stringify({
enabledBuiltinTemplates: JSON.stringify(valueToSave),
}),
})
if (response.ok) {
// 先更新本地状态
setEnabledBuiltinTemplates(enabled)
showToast('启用状态已更新', 'success')
// 通知首页刷新模板列表
notifyTemplatesUpdate()
// 重新加载一次确保同步
const reloadResponse = await fetch('/api/system-settings', {
cache: 'no-store',
headers: {
'Cache-Control': 'no-cache',
},
})
if (reloadResponse.ok) {
const settings = await reloadResponse.json()
if (settings.enabledBuiltinTemplates) {
const enabledReload = JSON.parse(settings.enabledBuiltinTemplates)
if (Array.isArray(enabledReload) && enabledReload.length > 0) {
setEnabledBuiltinTemplates(new Set(enabledReload))
} else {
setEnabledBuiltinTemplates(new Set(ALL_BUILTIN_KEYS))
}
}
}
} else {
throw new Error('保存失败')
}
} catch (e) {
console.error('Failed to save enabled builtin templates:', e)
showToast('保存启用状态失败', 'error')
throw e // 抛出错误以便调用者处理
}
}, [showToast, ALL_BUILTIN_KEYS, notifyTemplatesUpdate])
// 切换预制模板的启用/禁用
const toggleBuiltinTemplateEnabled = useCallback(async (presetKey: string) => {
const prev = enabledBuiltinTemplates
const newSet = new Set(prev)
if (newSet.has(presetKey)) {
newSet.delete(presetKey) // 禁用
} else {
newSet.add(presetKey) // 启用
}
setEnabledBuiltinTemplates(newSet)
try {
await saveEnabledBuiltinTemplates(newSet)
} catch (err) {
console.error('Failed to save enabled templates:', err)
setEnabledBuiltinTemplates(prev)
showToast('保存失败,已恢复', 'error')
}
}, [enabledBuiltinTemplates, saveEnabledBuiltinTemplates, showToast])
const enableAllBuiltinTemplates = useCallback(async () => {
if (enabledBuiltinTemplates.size === ALL_BUILTIN_KEYS.length) {
return
}
const prev = enabledBuiltinTemplates
const newSet = new Set(ALL_BUILTIN_KEYS)
setEnabledBuiltinTemplates(newSet)
try {
await saveEnabledBuiltinTemplates(newSet)
showToast('已启用所有预制模板', 'success')
} catch (err) {
console.error('Failed to enable all templates:', err)
setEnabledBuiltinTemplates(prev)
showToast('启用全部失败,已恢复', 'error')
}
}, [ALL_BUILTIN_KEYS, enabledBuiltinTemplates, saveEnabledBuiltinTemplates, showToast])
const updateForm = useCallback((patch: Partial<TemplateForm>) => {
setForm((prev) => (prev ? { ...prev, ...patch } : prev))
@ -518,6 +743,7 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
name: defaultName,
description: preset ? preset.description : '',
status: 'draft',
materialType: preset?.materialType || MATERIAL_TYPE_OPTIONS[0],
fieldGroups: '',
displayConfig: '',
fields: initialFields,
@ -540,6 +766,7 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
name: tpl.name,
description: tpl.description || '',
status: tpl.status || 'draft',
materialType: tpl.materialType || MATERIAL_TYPE_OPTIONS[0],
fieldGroups: safeJsonStringify(tpl.fieldGroups),
displayConfig: safeJsonStringify(tpl.displayConfig),
fields: tpl.fields.map((field) => ({
@ -552,6 +779,7 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
visibleOnHomeFilter: field.visibleOnHomeFilter,
isSearchable: field.isSearchable,
isReadOnly: field.isReadOnly,
addToCategory: GLOBAL_CATEGORY_FIELDS.has(field.key) ? true : field.addToCategory ?? false,
validations: safeJsonStringify(field.validations),
optionsStatic: safeJsonStringify(field.optionsStatic),
allowedExtensions: safeJsonStringify(field.allowedExtensions),
@ -575,6 +803,7 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
name: `${tpl.name} - 副本`,
description: tpl.description || '',
status: 'draft',
materialType: tpl.materialType || MATERIAL_TYPE_OPTIONS[0],
fieldGroups: safeJsonStringify(tpl.fieldGroups),
displayConfig: safeJsonStringify(tpl.displayConfig),
fields: tpl.fields.map((field) => ({
@ -586,6 +815,7 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
visibleOnHomeFilter: field.visibleOnHomeFilter,
isSearchable: field.isSearchable,
isReadOnly: field.isReadOnly,
addToCategory: GLOBAL_CATEGORY_FIELDS.has(field.key) ? true : field.addToCategory ?? false,
validations: safeJsonStringify(field.validations),
optionsStatic: safeJsonStringify(field.optionsStatic),
allowedExtensions: safeJsonStringify(field.allowedExtensions),
@ -627,6 +857,7 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
name: preset.name,
description: preset.description,
status: 'draft',
materialType: preset.materialType,
fieldGroups: '',
displayConfig: '',
fields: initialFields,
@ -649,6 +880,7 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
name: tpl.name,
description: tpl.description || '',
status: tpl.status || 'draft',
materialType: tpl.materialType || MATERIAL_TYPE_OPTIONS[0],
fieldGroups: safeJsonStringify(tpl.fieldGroups),
displayConfig: safeJsonStringify(tpl.displayConfig),
fields: tpl.fields.map((field) => ({
@ -661,6 +893,7 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
visibleOnHomeFilter: field.visibleOnHomeFilter,
isSearchable: field.isSearchable,
isReadOnly: field.isReadOnly,
addToCategory: field.addToCategory ?? false,
validations: safeJsonStringify(field.validations),
optionsStatic: safeJsonStringify(field.optionsStatic),
allowedExtensions: safeJsonStringify(field.allowedExtensions),
@ -713,6 +946,7 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
...prev,
name: defaultName,
description: preset.description, // 总是更新描述
materialType: preset.materialType,
fields: preset.getFields(),
}
})
@ -726,17 +960,25 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
const currentField = prev.fields[index]
if (!currentField) return prev
// 如果字段类型改为 select,确保 addToCategory 存在
let finalPatch = { ...patch }
if (patch.type === 'select' && !('addToCategory' in patch)) {
if (!('addToCategory' in currentField) || currentField.addToCategory === undefined) {
finalPatch.addToCategory = currentField.addToCategory ?? false
}
}
// 检查是否有实际变化,避免不必要的更新
const hasChanges = Object.keys(patch).some(key => {
const hasChanges = Object.keys(finalPatch).some(key => {
const patchKey = key as keyof FieldForm
return currentField[patchKey] !== patch[patchKey]
return currentField[patchKey] !== finalPatch[patchKey]
})
if (!hasChanges) return prev
const nextFields = prev.fields.map((field, i) => {
if (i === index) {
return { ...field, ...patch }
return { ...field, ...finalPatch }
}
return field
})
@ -880,6 +1122,7 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
visibleOnHomeFilter: !!field.visibleOnHomeFilter,
isSearchable: !!field.isSearchable,
isReadOnly: !!field.isReadOnly,
addToCategory: field.addToCategory ?? false,
conditions,
}
}),
@ -1015,7 +1258,9 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
}
const handleSave = async () => {
if (!form) return
if (!form) {
return
}
setSaveMessage(null)
setFieldGroupsError(null)
setDisplayConfigError(null)
@ -1059,6 +1304,7 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
name: form.name.trim(),
description: form.description.trim() || null,
status: form.status,
materialType: form.materialType || MATERIAL_TYPE_OPTIONS[0],
}
try {
@ -1121,6 +1367,13 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
throw new Error(`字段「${field.label || field.key || `字段${index + 1}`}」联动配置错误:${error.message}`)
}
// 确保 addToCategory 始终存在,即使是 undefined 也转换为 false
const addToCategoryValue = GLOBAL_CATEGORY_FIELDS.has(trimmedKey)
? true
: field.addToCategory !== undefined
? Boolean(field.addToCategory)
: false
return {
id: field.id,
key: trimmedKey,
@ -1132,14 +1385,24 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
visibleOnHomeFilter: field.visibleOnHomeFilter,
isSearchable: field.isSearchable,
isReadOnly: field.isReadOnly,
addToCategory: addToCategoryValue,
validations,
optionsSource: null, // 添加 optionsSource 字段
optionsSource: null,
optionsStatic,
allowedExtensions,
conditions,
}
})
// 确保所有 select 类型字段都有 addToCategory
payload.fields = payload.fields.map((field: any) => {
if (field.type === 'select') {
const addToCategoryValue = 'addToCategory' in field ? Boolean(field.addToCategory) : false
return { ...field, addToCategory: addToCategoryValue }
}
return field
})
setSaving(true)
if (form.id) {
payload.version = form.version
@ -1154,8 +1417,16 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
closeEditor()
}, 1500)
} else {
// 在发送请求前记录 payload,便于调试
console.log('创建模板 - 发送的 payload:', JSON.stringify(payload, null, 2))
// 确保所有 select 类型字段都有 addToCategory
payload.fields = payload.fields.map((field: any) => {
if (field.type === 'select') {
const addToCategoryValue = 'addToCategory' in field && field.addToCategory !== undefined && field.addToCategory !== null
? Boolean(field.addToCategory)
: false
return { ...field, addToCategory: addToCategoryValue }
}
return field
})
const created = await requestJson<UploadTemplateDto>('/api/admin/upload-templates', {
method: 'POST',
@ -1227,6 +1498,7 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
visibleOnHomeFilter: field.visibleOnHomeFilter,
isSearchable: field.isSearchable,
isReadOnly: field.isReadOnly,
addToCategory: field.addToCategory ?? false,
validations: field.validations,
optionsStatic: field.optionsStatic,
allowedExtensions: field.allowedExtensions,
@ -1240,11 +1512,59 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
body: JSON.stringify(payload),
})
setTemplates((prev) => prev.map((item) => (item.id === updated.id ? updated : item)))
showToast('模板已启用', 'success')
// 通知首页刷新模板列表
notifyTemplatesUpdate()
} catch (error: any) {
showToast(error.message || '启用失败', 'error')
}
}
const handleDeactivate = async (templateId: string) => {
try {
const template = templates.find(t => t.id === templateId)
if (!template) return
const payload: any = {
name: template.name,
description: template.description,
status: 'draft',
version: template.version,
fieldGroups: template.fieldGroups,
displayConfig: template.displayConfig,
fields: template.fields.map((field, index) => ({
id: field.id,
key: field.key,
label: field.label,
type: field.type,
required: field.required,
orderIndex: index,
layoutWidth: field.layoutWidth,
visibleOnHomeFilter: field.visibleOnHomeFilter,
isSearchable: field.isSearchable,
isReadOnly: field.isReadOnly,
addToCategory: field.addToCategory ?? false,
validations: field.validations,
optionsStatic: field.optionsStatic,
allowedExtensions: field.allowedExtensions,
conditions: field.conditions,
})),
}
const updated = await requestJson<UploadTemplateDto>(`/api/admin/upload-templates/${templateId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
setTemplates((prev) => prev.map((item) => (item.id === updated.id ? updated : item)))
showToast('模板已取消启用', 'success')
// 通知首页刷新模板列表
notifyTemplatesUpdate()
} catch (error: any) {
showToast(error.message || '取消启用失败', 'error')
}
}
const handleDelete = async (templateId: string) => {
if (deletingId) return
const confirmed = await confirm({
@ -1258,6 +1578,8 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
await requestJson(`/api/admin/upload-templates/${templateId}`, { method: 'DELETE' })
setTemplates((prev) => prev.filter((item) => item.id !== templateId))
showToast('模板已删除')
// 通知首页刷新模板列表
notifyTemplatesUpdate()
} catch (error: any) {
showToast(error.message || '删除失败', 'error')
} finally {
@ -1288,9 +1610,13 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
<div className={cn(styles.cardStyles.base, 'overflow-hidden')}>
<BuiltInTemplateGrid
presets={BUILTIN_TEMPLATES}
presets={BUILTIN_TEMPLATES.filter(p => enabledBuiltinTemplates.has(p.key))}
onPreview={openPresetPreview}
onQuickCreate={(presetKey) => createTemplateFromPreset(presetKey, 'active')}
onQuickCreate={(presetKey) => createTemplateFromPreset(presetKey, 'draft')}
onToggleEnabled={toggleBuiltinTemplateEnabled}
onEnableAll={enableAllBuiltinTemplates}
enabledTemplates={enabledBuiltinTemplates}
allPresets={BUILTIN_TEMPLATES}
/>
<CustomTemplateSection
@ -1302,19 +1628,29 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
onCreate={openCreate}
onPreview={openCustomPreview}
onActivate={handleActivate}
onDeactivate={handleDeactivate}
onEdit={openEdit}
onDuplicate={openDuplicate}
onDelete={handleDelete}
/>
</div>
</div>
{editorOpen && form && (
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50 flex items-center justify-center p-4 animate-in fade-in duration-200">
<div className={`bg-white dark:bg-slate-900 rounded-2xl shadow-2xl max-h-[92vh] overflow-hidden flex flex-col transition-all duration-500 ease-in-out ${
isPreviewMode
? 'w-full max-w-2xl animate-in zoom-in-95'
: 'w-full max-w-7xl animate-in zoom-in-95'
}`}>
<Modal
isOpen={editorOpen}
onClose={closeEditor}
size={isPreviewMode ? 'large' : 'xxlarge'}
showCloseButton={false}
closeOnOverlayClick={false}
closeOnEscape={false}
>
<div
className={`bg-white dark:bg-slate-900 rounded-2xl shadow-2xl max-h-[92vh] overflow-hidden flex flex-col transition-all duration-500 ease-in-out ${
isPreviewMode
? 'w-full max-w-2xl animate-in zoom-in-95'
: 'w-full max-w-7xl animate-in zoom-in-95'
}`}
>
<div className="px-6 py-5 border-b border-slate-200/60 dark:border-slate-800/60 flex items-center justify-between bg-slate-50/50 dark:bg-slate-800/30">
<div>
<h3 className="text-xl font-semibold text-slate-900 dark:text-white tracking-tight">
@ -1344,10 +1680,11 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
{/* 左侧:字段配置面板 */}
{!isPreviewMode && (
<div className="w-1/2 border-r border-slate-200/60 dark:border-slate-800/60 overflow-y-auto transition-all duration-500 ease-in-out animate-in slide-in-from-left">
<div className="p-6 space-y-6">
<div className="p-6 space-y-6">
<TemplateBasicsForm
form={form}
statusOptions={STATUS_OPTIONS}
materialTypeOptions={MATERIAL_TYPE_OPTIONS}
showPresetSelector={!form.id}
selectedPreset={selectedPreset}
presets={BUILTIN_TEMPLATES}
@ -1373,8 +1710,10 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
dragHover={dragHover}
editingFieldIndex={editingFieldIndex}
standardFields={STANDARD_FIELDS}
getStandardFieldTemplate={cloneFieldTemplate}
fieldTypes={FIELD_TYPES}
layoutOptions={LAYOUT_OPTIONS}
lockedCategoryFields={new Set(categoryFieldKeys)}
onEditingFieldChange={setEditingFieldIndex}
onFieldDragStart={startFieldDrag}
onFieldDragOver={handleFieldDragOver}
@ -1388,9 +1727,9 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
onAddField={addField}
renderDropPlaceholder={renderEditorDropPlaceholder}
onUpdateField={updateField}
/>
</div>
</div>
/>
</div>
</div>
)}
<TemplatePreviewPanel
@ -1403,7 +1742,7 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
onFieldsReordered={handlePreviewReorder}
/>
</div>
</div>
<TemplateEditorFooter
isPreviewMode={isPreviewMode}
@ -1416,7 +1755,7 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
onCreateFromPreview={createFromPreview}
/>
</div>
</div>
</Modal>
)}
</div>
)

View File

@ -401,7 +401,7 @@ export default function UsersTab({
)}
{actionButton(
user.role === 'ADMIN' ? '取消管理员' : '设为管理员',
styles.buttonStyles.rainbow,
styles.buttonStyles.secondary, // 改为使用 secondary 样式,与"禁止下载"按钮一致
() => onSetRole(user.id, user.role),
options?.showRoleToggle !== false && !user.isSuperAdmin,
)}
@ -445,20 +445,27 @@ export default function UsersTab({
)
const showSearchResults = Boolean(searchTerm.trim())
const textTitle = theme === 'dark' ? 'text-white' : 'text-slate-900'
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'}`,
'relative overflow-hidden rounded-3xl border mb-6 flex-shrink-0',
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',
)}
>
<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">
<div className="relative z-10 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-3">
<p className={cn('text-sm font-semibold tracking-[0.2em] uppercase', theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600')}>
User Management
</p>
<h2 className={cn('text-2xl font-semibold', textTitle)}>成员目录</h2>
<p className={cn('text-sm leading-relaxed', textDesc)}>
结合组织树与搜索框,可按姓名、邮箱或部门快速定位管理员与成员。
</p>
</div>

View File

@ -1,19 +1,30 @@
'use client'
import { cardStyles, buttonStyles, buttonModifiers, cn } from '@/lib/ui-styles'
import { useThemeStyles, cn } from '@/lib/ui-styles'
import type { BuiltinTemplateConfig } from './types'
interface BuiltInTemplateGridProps {
presets: BuiltinTemplateConfig[]
onPreview: (presetKey: string) => void
onQuickCreate: (presetKey: string) => void
onToggleEnabled?: (presetKey: string) => void
onEnableAll?: () => void
enabledTemplates?: Set<string>
allPresets?: BuiltinTemplateConfig[]
}
export default function BuiltInTemplateGrid({
presets,
onPreview,
onQuickCreate,
onToggleEnabled,
onEnableAll,
enabledTemplates = new Set(),
allPresets = [],
}: BuiltInTemplateGridProps) {
const styles = useThemeStyles()
const hasDisabledTemplates = allPresets.length > 0 && enabledTemplates.size < allPresets.length
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">
@ -24,6 +35,19 @@ export default function BuiltInTemplateGrid({
点击卡片仅预览示例,内容不可直接编辑,需复制为自定义模板后才可修改。
</p>
</div>
{hasDisabledTemplates && onEnableAll && (
<button
onClick={() => onEnableAll()}
className={cn(
styles.buttonStyles.secondary,
styles.buttonModifiers.compactXs,
'text-xs',
'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-200' // 显式添加背景色和文字色
)}
>
启用所有模板 ({allPresets.length - enabledTemplates.size} 个已禁用)
</button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{presets.map((preset) => {
@ -31,7 +55,7 @@ export default function BuiltInTemplateGrid({
return (
<div
key={preset.key}
className={cn(cardStyles.hover, 'group relative overflow-hidden cursor-pointer')}
className={cn(styles.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" />
@ -51,19 +75,47 @@ export default function BuiltInTemplateGrid({
</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 className="flex items-center gap-2">
{onToggleEnabled && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onToggleEnabled(preset.key)
}}
className={cn(
enabledTemplates.has(preset.key)
? styles.buttonStyles.secondary
: styles.buttonStyles.secondary,
styles.buttonModifiers.compactXs,
styles.buttonModifiers.pill,
'text-xs'
)}
title={enabledTemplates.has(preset.key) ? '禁用此模板' : '启用此模板'}
>
{enabledTemplates.has(preset.key) ? '禁用' : '启用'}
</button>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onQuickCreate(preset.key)
}}
className={cn(
styles.buttonStyles.success,
styles.buttonModifiers.compactXs,
styles.buttonModifiers.pill,
'inline-flex items-center gap-1.5 shadow-none',
'bg-emerald-600 hover:bg-emerald-700 text-white' // 显式添加背景色和文字色
)}
>
<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>
)

View File

@ -1,6 +1,6 @@
'use client'
import { cardStyles, buttonStyles, buttonModifiers, cn } from '@/lib/ui-styles'
import { useThemeStyles, cn } from '@/lib/ui-styles'
import type { DisplayTemplate } from './types'
interface CustomTemplateSectionProps {
@ -12,6 +12,7 @@ interface CustomTemplateSectionProps {
onCreate: () => void
onPreview: (template: DisplayTemplate) => void
onActivate: (id: string) => void
onDeactivate: (id: string) => void
onEdit: (template: DisplayTemplate) => void
onDuplicate: (template: DisplayTemplate) => void
onDelete: (id: string) => void
@ -26,10 +27,12 @@ export default function CustomTemplateSection({
onCreate,
onPreview,
onActivate,
onDeactivate,
onEdit,
onDuplicate,
onDelete,
}: CustomTemplateSectionProps) {
const styles = useThemeStyles()
return (
<div className="px-6 pt-5 pb-4">
<div className="flex items-center justify-between gap-4 mb-3">
@ -49,7 +52,7 @@ export default function CustomTemplateSection({
<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')}
className={cn(styles.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">
@ -71,7 +74,7 @@ export default function CustomTemplateSection({
<div
key={tpl.id}
onClick={() => onPreview(tpl)}
className={cn(cardStyles.hover, 'group relative overflow-hidden cursor-pointer')}
className={cn(styles.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">
@ -99,14 +102,25 @@ export default function CustomTemplateSection({
</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' && (
{tpl.status === 'active' ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onDeactivate(tpl.id)
}}
className={cn(styles.buttonStyles.secondary, styles.buttonModifiers.compactXs, styles.buttonModifiers.pill)}
>
取消启用
</button>
) : (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onActivate(tpl.id)
}}
className={cn(buttonStyles.success, buttonModifiers.compactXs, buttonModifiers.pill)}
className={cn(styles.buttonStyles.success, styles.buttonModifiers.compactXs, styles.buttonModifiers.pill)}
>
启用
</button>
@ -117,7 +131,7 @@ export default function CustomTemplateSection({
e.stopPropagation()
onEdit(tpl)
}}
className={cn(buttonStyles.secondary, buttonModifiers.compactXs, buttonModifiers.pill)}
className={cn(styles.buttonStyles.secondary, styles.buttonModifiers.compactXs, styles.buttonModifiers.pill)}
>
编辑
</button>
@ -127,7 +141,12 @@ export default function CustomTemplateSection({
e.stopPropagation()
onDuplicate(tpl)
}}
className={cn(buttonStyles.ghost, buttonModifiers.compactXs, buttonModifiers.pill)}
className={cn(
styles.buttonStyles.secondary,
styles.buttonModifiers.compactXs,
styles.buttonModifiers.pill,
'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-200' // 显式添加背景色、边框和文字色
)}
>
复制
</button>
@ -139,9 +158,10 @@ export default function CustomTemplateSection({
}}
disabled={deletingId === tpl.id}
className={cn(
buttonStyles.danger,
buttonModifiers.compactXs,
buttonModifiers.pill,
styles.buttonStyles.danger,
styles.buttonModifiers.compactXs,
styles.buttonModifiers.pill,
'bg-red-500 hover:bg-red-600 text-white', // 显式添加背景色和文字色,确保在浅色模式下可见
deletingId === tpl.id && 'opacity-70 cursor-wait',
)}
>

View File

@ -1,6 +1,6 @@
'use client'
import { buttonStyles, buttonModifiers, inputStyles, cn } from '@/lib/ui-styles'
import { useThemeStyles, useTheme, cn } from '@/lib/ui-styles'
interface TemplateManagerHeaderProps {
searchTerm: string
@ -11,16 +11,30 @@ export default function TemplateManagerHeader({
searchTerm,
onSearchChange,
}: TemplateManagerHeaderProps) {
const styles = useThemeStyles()
const theme = useTheme()
const textTitle = theme === 'dark' ? 'text-white' : 'text-slate-900'
const textDesc = theme === 'dark' ? 'text-slate-400' : 'text-slate-500'
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>
<section
className={cn(
'relative overflow-hidden rounded-3xl border mb-6 flex-shrink-0',
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',
)}
>
<div className="relative z-10 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-3">
<p className={cn('text-sm font-semibold tracking-[0.2em] uppercase', theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600')}>
Template Management
</p>
<h2 className={cn('text-2xl font-semibold', textTitle)}>模板工作台</h2>
<p className={cn('text-sm leading-relaxed', textDesc)}>
预制模板仅供参考和复制,自定义模板启用后才会对上传面板生效。
</p>
</div>
<div className="relative w-full md:w-[320px] lg:w-[360px]">
<input
@ -28,7 +42,7 @@ export default function TemplateManagerHeader({
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="搜索模板名称..."
className={cn(inputStyles.base, 'pl-11 pr-10')}
className={cn(styles.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" />
@ -38,8 +52,8 @@ export default function TemplateManagerHeader({
type="button"
onClick={() => onSearchChange('')}
className={cn(
buttonStyles.ghost,
buttonModifiers.icon,
styles.buttonStyles.ghost,
styles.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="清空模板搜索"

View File

@ -6,6 +6,7 @@ import type { TemplateForm, BuiltinTemplateConfig } from '../types'
interface TemplateBasicsFormProps {
form: TemplateForm
statusOptions: Array<{ value: string; label: string }>
materialTypeOptions: string[]
showPresetSelector: boolean
selectedPreset: string | null
presets: BuiltinTemplateConfig[]
@ -16,6 +17,7 @@ interface TemplateBasicsFormProps {
export default function TemplateBasicsForm({
form,
statusOptions,
materialTypeOptions,
showPresetSelector,
selectedPreset,
presets,
@ -65,6 +67,24 @@ export default function TemplateBasicsForm({
<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">
素材类型 <span className="text-red-500">*</span>
</label>
<select
value={form.materialType}
onChange={(e) => onChange({ materialType: e.target.value })}
className={cn(inputStyles.base)}
>
{materialTypeOptions.map((option) => (
<option key={option} value={option}>
{option}
</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>

View File

@ -1,6 +1,6 @@
'use client'
import { buttonStyles, buttonModifiers, cn } from '@/lib/ui-styles'
import { useThemeStyles, cn } from '@/lib/ui-styles'
import type { UploadTemplateDto } from '../../template-manager/types'
interface SaveMessage {
@ -29,6 +29,7 @@ export default function TemplateEditorFooter({
onExitPreview,
onCreateFromPreview,
}: TemplateEditorFooterProps) {
const styles = useThemeStyles()
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 && (
@ -58,13 +59,21 @@ export default function TemplateEditorFooter({
<>
<button
onClick={onClose}
className={cn(buttonStyles.secondary, buttonModifiers.compact)}
className={cn(
styles.buttonStyles.secondary,
styles.buttonModifiers.compact,
'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-200' // 显式添加背景色和文字色
)}
>
关闭
</button>
<button
onClick={previewTemplate ? onExitPreview : onCreateFromPreview}
className={cn(buttonStyles.primary, buttonModifiers.compact)}
className={cn(
styles.buttonStyles.primary,
styles.buttonModifiers.compact,
'bg-indigo-600 hover:bg-indigo-700 text-white' // 显式添加背景色和文字色
)}
>
{previewTemplate ? '编辑此模板' : '从此模板新建'}
</button>
@ -75,8 +84,9 @@ export default function TemplateEditorFooter({
onClick={onClose}
disabled={saving}
className={cn(
buttonStyles.secondary,
buttonModifiers.compact,
styles.buttonStyles.secondary,
styles.buttonModifiers.compact,
'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-200', // 显式添加背景色和文字色
saving && 'opacity-60 cursor-not-allowed',
)}
>
@ -85,7 +95,12 @@ export default function TemplateEditorFooter({
<button
onClick={onSave}
disabled={saving}
className={cn(buttonStyles.primary, buttonModifiers.compact, 'min-w-[120px]')}
className={cn(
styles.buttonStyles.primary,
styles.buttonModifiers.compact,
'min-w-[120px]',
'bg-indigo-600 hover:bg-indigo-700 text-white' // 显式添加背景色和文字色
)}
>
{saving ? (
<span className="flex items-center justify-center gap-2">

View File

@ -20,8 +20,10 @@ interface TemplateFieldsEditorProps {
dragHover: DragHoverState | null
editingFieldIndex: number | null
standardFields: StandardFieldOption[]
getStandardFieldTemplate?: (key: string) => FieldForm | null
fieldTypes: string[]
layoutOptions: LayoutOption[]
lockedCategoryFields?: Set<string>
onEditingFieldChange: (index: number | null) => void
onFieldDragStart: (event: DragEvent<HTMLElement>, index: number) => void
onFieldDragOver: (event: DragEvent<HTMLDivElement>, index: number, surface: 'editor') => void
@ -43,8 +45,10 @@ export default function TemplateFieldsEditor({
dragHover,
editingFieldIndex,
standardFields,
getStandardFieldTemplate,
fieldTypes,
layoutOptions,
lockedCategoryFields,
onEditingFieldChange,
onFieldDragStart,
onFieldDragOver,
@ -90,6 +94,11 @@ export default function TemplateFieldsEditor({
const standardOption = standardFields.find((opt) => opt.key === field.key)
const selectValue = standardOption ? standardOption.key : '__custom'
// 判断是否为下拉选择类型 - 使用多种方式确保准确性
const fieldTypeStr = String(field.type || '').trim()
const isSelectType = fieldTypeStr === 'select'
const isLockedCategoryField = Boolean(lockedCategoryFields?.has(field.key))
return (
<Fragment key={`editor-field-${index}`}>
@ -181,6 +190,26 @@ export default function TemplateFieldsEditor({
}
const option = standardFields.find((item) => item.key === value)
if (!option) return
// 如果提供了获取标准字段模板的函数,应用完整的模板
if (getStandardFieldTemplate) {
const template = getStandardFieldTemplate(option.key)
if (template) {
// 应用完整模板,但保留用户可能已修改的 autoLabel
const shouldAutoLabel = field.autoLabel !== false
const patch: Partial<FieldForm> = {
...template,
autoLabel: shouldAutoLabel ? true : field.autoLabel,
}
if (!shouldAutoLabel) {
patch.label = field.label || template.label
}
onUpdateField(index, patch)
return
}
}
// 如果没有模板函数,只更新 key 和 label(向后兼容)
const shouldAutoLabel = field.autoLabel !== false
const patch: Partial<FieldForm> = {
key: option.key,
@ -246,7 +275,9 @@ export default function TemplateFieldsEditor({
<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 })}
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"
>
@ -293,42 +324,13 @@ export default function TemplateFieldsEditor({
</div>
</div>
{field.type === 'select' && (
<div className="mt-3">
<label className="block text-xs font-medium text-slate-700 dark:text-slate-300 mb-1.5">
下拉选项 <span className="text-slate-500 font-normal">(必填)</span>
</label>
<textarea
rows={6}
value={field.optionsStatic || ''}
onChange={(e) => onUpdateField(index, { optionsStatic: e.target.value })}
onClick={(e) => e.stopPropagation()}
className="w-full px-3 py-2 text-sm border border-slate-300 dark:border-slate-600 dark:bg-slate-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200 resize-none font-mono"
placeholder="直接输入选项,每行一个,例如:&#10;3DMAX&#10;PSD&#10;AI&#10;CAD&#10;C4D"
/>
<div className="mt-2 p-2.5 bg-blue-50/50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<p className="text-[10px] font-semibold text-blue-700 dark:text-blue-300 mb-1.5">填写说明:</p>
<div className="text-[10px] text-blue-600 dark:text-blue-400">
<p className="mb-1.5">每行输入一个选项,例如:</p>
<div className="ml-3 font-mono bg-white dark:bg-slate-800 px-2 py-1 rounded border border-blue-200 dark:border-blue-700">
3DMAX
<br />
PSD
<br />
AI
<br />
CAD
</div>
</div>
</div>
</div>
)}
{/* 下拉选项已移除,选项在分类管理中管理 */}
<div className="flex flex-wrap gap-3 mt-3 pt-3 border-t border-slate-200/60 dark:border-slate-700/60">
<label className="flex items-center gap-1.5 text-xs text-slate-700 dark:text-slate-300 cursor-pointer">
<input
type="checkbox"
checked={field.required}
checked={field.required ?? false}
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"
@ -338,7 +340,7 @@ export default function TemplateFieldsEditor({
<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}
checked={field.visibleOnHomeFilter ?? false}
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"
@ -348,13 +350,47 @@ export default function TemplateFieldsEditor({
<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}
checked={field.isSearchable ?? false}
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>
{/* 添加到分类勾选框 - 仅在下拉选择类型时显示 */}
{isSelectType ? (
<label
className={cn(
'flex items-center gap-1.5 text-xs text-slate-700 dark:text-slate-300',
isLockedCategoryField ? 'cursor-not-allowed opacity-70' : 'cursor-pointer',
)}
title={isLockedCategoryField ? '已全局启用,统一由分类管理控制' : undefined}
>
<input
type="checkbox"
checked={isLockedCategoryField ? true : Boolean(field.addToCategory)}
disabled={isLockedCategoryField}
onChange={(e) => {
onUpdateField(index, { addToCategory: 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>
添加到分类
{isLockedCategoryField && (
<span className="ml-1 text-[10px] text-slate-400">(全局同步)</span>
)}
</span>
</label>
) : (
// 调试信息:显示为什么不显示复选框
editingFieldIndex === index && field.type === 'select' && (
<div className="text-[10px] text-red-500 dark:text-red-400 font-mono">
调试: type=&quot;{field.type}&quot;, isSelectType={String(isSelectType)}
</div>
)
)}
</div>
</div>
{placeholderAfter && renderDropPlaceholder(`editor-after-${index}`)}

View File

@ -12,6 +12,7 @@ export interface UploadFieldDto {
visibleOnHomeFilter: boolean
isSearchable: boolean
isReadOnly: boolean
addToCategory?: boolean
conditions?: any
}
@ -20,6 +21,7 @@ export interface UploadTemplateDto {
name: string
description: string | null
status: string
materialType: string
version: number
fieldGroups: any
displayConfig: any
@ -38,6 +40,7 @@ export interface FieldForm {
visibleOnHomeFilter: boolean
isSearchable: boolean
isReadOnly: boolean
addToCategory: boolean
validations: string
optionsStatic: string
allowedExtensions: string
@ -51,6 +54,7 @@ export interface TemplateForm {
name: string
description: string
status: string
materialType: string
fieldGroups: string
displayConfig: string
fields: FieldForm[]
@ -60,6 +64,7 @@ export interface BuiltinTemplateConfig {
key: string
name: string
description: string
materialType: string
getFields: () => FieldForm[]
}

View File

@ -1,7 +1,7 @@
'use client'
import type { Department, User } from '../types'
import { buttonModifiers, buttonStyles, cn, inputStyles } from '@/lib/ui-styles'
import { useThemeStyles, cn } from '@/lib/ui-styles'
import { formatDateTime } from '../../_utils/formatters'
interface UserEditModalProps {
@ -43,11 +43,12 @@ export default function UserEditModal({
onOpenTransferModal,
hasDepartments,
}: UserEditModalProps) {
const styles = useThemeStyles()
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="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 [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-slate-100 [&::-webkit-scrollbar-track]:dark:bg-slate-800 [&::-webkit-scrollbar-thumb]:bg-slate-300 [&::-webkit-scrollbar-thumb]:dark:bg-slate-600 [&::-webkit-scrollbar-thumb]:rounded-full">
<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>
@ -56,7 +57,7 @@ export default function UserEditModal({
</div>
<button
type="button"
className={cn(buttonStyles.ghost, buttonModifiers.icon)}
className={cn(styles.buttonStyles.ghost, styles.buttonModifiers.icon, 'text-slate-700 dark:text-slate-200')}
aria-label="关闭编辑用户"
onClick={onClose}
>
@ -89,7 +90,7 @@ export default function UserEditModal({
type="text"
value={editingUsername}
onChange={(e) => onChangeUsername(e.target.value)}
className={inputStyles.base}
className={styles.inputStyles.base}
placeholder="请输入用户名"
/>
</section>
@ -99,7 +100,7 @@ export default function UserEditModal({
<select
value={selectedDepartment}
onChange={(e) => onChangeDepartment(e.target.value)}
className={cn(inputStyles.base, 'pr-10')}
className={cn(styles.inputStyles.base, 'pr-10')}
>
<option value="">-- 未分配部门 --</option>
{departments.map((dept) => (
@ -119,9 +120,12 @@ export default function UserEditModal({
type="button"
onClick={onToggleRole}
className={cn(
buttonStyles.secondary,
'w-full justify-center',
user.role === 'ADMIN' && 'bg-purple-600 text-white hover:bg-purple-700',
styles.buttonStyles.secondary,
'w-full justify-center max-w-xs', // 限制最大宽度,避免按钮太长
// 基础状态:白色背景,深色文字
'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-900 dark:text-slate-200',
// 管理员状态:紫色背景,白色文字(使用 !important 确保覆盖基础文字颜色)
user.role === 'ADMIN' && '!bg-purple-600 !text-white hover:!bg-purple-700',
)}
>
{user.role === 'ADMIN' ? '管理员' : '设为管理员'}
@ -133,10 +137,11 @@ export default function UserEditModal({
type="button"
onClick={onToggleActive}
className={cn(
buttonStyles.secondary,
styles.buttonStyles.secondary,
'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-900 dark:text-slate-200', // 显式添加文字颜色,确保在浅色模式下清晰可见
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',
? 'bg-rose-100 dark:bg-rose-900/30 text-rose-800 dark:text-rose-300 hover:bg-rose-200 dark:hover:bg-rose-900/50' // 使用更深的文字颜色
: 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-800 dark:text-emerald-300 hover:bg-emerald-200 dark:hover:bg-emerald-900/50', // 使用更深的文字颜色
)}
>
{user.isActive ? '禁用用户' : '激活用户'}
@ -145,10 +150,11 @@ export default function UserEditModal({
type="button"
onClick={onToggleDownload}
className={cn(
buttonStyles.secondary,
styles.buttonStyles.secondary,
'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-900 dark:text-slate-200', // 显式添加文字颜色,确保在浅色模式下清晰可见
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',
? 'bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-300 hover:bg-amber-200 dark:hover:bg-amber-900/50' // 使用更深的文字颜色
: 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 hover:bg-blue-200 dark:hover:bg-blue-900/50', // 使用更深的文字颜色
)}
>
{user.canDownload ? '禁止下载' : '允许下载'}
@ -160,11 +166,12 @@ export default function UserEditModal({
<button
type="button"
onClick={onOpenTransferModal}
className={cn(buttonStyles.primary, 'w-full justify-center gap-2')}
className={cn(
styles.buttonStyles.primary,
'w-full justify-center',
// 统一UI样式已包含颜色,不需要额外添加
)}
>
<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>
@ -175,7 +182,11 @@ export default function UserEditModal({
<button
type="button"
onClick={onDelete}
className={cn(buttonStyles.danger, 'w-full justify-center')}
className={cn(
styles.buttonStyles.danger,
'w-full justify-center',
// 统一UI样式已包含颜色,不需要额外添加
)}
>
删除用户
</button>
@ -189,10 +200,24 @@ export default function UserEditModal({
</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
type="button"
onClick={onClose}
className={cn(
styles.buttonStyles.secondary,
'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-200' // 显式添加背景色、边框和文字色
)}
>
取消
</button>
<button type="button" onClick={onSave} className={cn(buttonStyles.primary)}>
<button
type="button"
onClick={onSave}
className={cn(
styles.buttonStyles.primary,
'bg-indigo-600 hover:bg-indigo-700 text-white' // 显式添加背景色和文字色
)}
>
保存
</button>
</div>

View File

@ -1,5 +1,12 @@
'use client'
// 强制设置全局标记,确保新代码已加载(在页面加载时立即执行)
if (typeof window !== 'undefined') {
;(window as any).__ADMIN_PAGE_LOADED__ = true
;(window as any).__ADMIN_PAGE_VERSION__ = '2025-11-23-v4'
console.log('[AdminPage] 🚀 页面已加载,版本:', (window as any).__ADMIN_PAGE_VERSION__)
}
import { useEffect, useState, useCallback, useMemo, useRef } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'

View File

@ -8,6 +8,7 @@ const selectTemplate = {
name: true,
description: true,
status: true,
materialType: true,
version: true,
fieldGroups: true,
displayConfig: true,
@ -30,6 +31,7 @@ const selectTemplate = {
visibleOnHomeFilter: true,
isSearchable: true,
isReadOnly: true,
addToCategory: true,
conditions: true,
createdAt: true,
updatedAt: true,
@ -75,6 +77,7 @@ export async function PUT(req: NextRequest, { params }: { params: { id: string }
name,
description,
status,
materialType,
fieldGroups,
displayConfig,
fields,
@ -97,6 +100,9 @@ export async function PUT(req: NextRequest, { params }: { params: { id: string }
if (status !== undefined) updateData.status = status
if (fieldGroups !== undefined) updateData.fieldGroups = fieldGroups
if (displayConfig !== undefined) updateData.displayConfig = displayConfig
if (materialType !== undefined && typeof materialType === 'string') {
updateData.materialType = materialType.trim() || '其他'
}
updateData.version = (existing.version || 1) + 1
const formattedFields = Array.isArray(fields)
@ -115,6 +121,7 @@ export async function PUT(req: NextRequest, { params }: { params: { id: string }
visibleOnHomeFilter: !!field.visibleOnHomeFilter,
isSearchable: !!field.isSearchable,
isReadOnly: !!field.isReadOnly,
addToCategory: !!field.addToCategory,
conditions: field.conditions ?? null,
}))
: null
@ -154,6 +161,7 @@ export async function PUT(req: NextRequest, { params }: { params: { id: string }
visibleOnHomeFilter: field.visibleOnHomeFilter,
isSearchable: field.isSearchable,
isReadOnly: field.isReadOnly,
addToCategory: field.addToCategory,
conditions: field.conditions,
},
})
@ -174,6 +182,7 @@ export async function PUT(req: NextRequest, { params }: { params: { id: string }
visibleOnHomeFilter: field.visibleOnHomeFilter,
isSearchable: field.isSearchable,
isReadOnly: field.isReadOnly,
addToCategory: field.addToCategory,
conditions: field.conditions,
},
})

View File

@ -2,12 +2,14 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { Prisma } from '@prisma/client'
const baseSelect = {
id: true,
name: true,
description: true,
status: true,
materialType: true,
version: true,
fieldGroups: true,
displayConfig: true,
@ -30,6 +32,7 @@ const baseSelect = {
visibleOnHomeFilter: true,
isSearchable: true,
isReadOnly: true,
addToCategory: true,
conditions: true,
createdAt: true,
updatedAt: true,
@ -69,6 +72,7 @@ export async function POST(req: NextRequest) {
name,
description,
status = 'draft',
materialType,
fieldGroups,
displayConfig,
fields = [],
@ -78,6 +82,8 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: '模板名称不能为空' }, { status: 400 })
}
const finalMaterialType = typeof materialType === 'string' && materialType.trim() ? materialType.trim() : '其他'
const formattedFields = Array.isArray(fields)
? fields.map((field: any, index: number) => ({
key: String(field.key || '').trim(),
@ -96,21 +102,25 @@ export async function POST(req: NextRequest) {
visibleOnHomeFilter: !!field.visibleOnHomeFilter,
isSearchable: !!field.isSearchable,
isReadOnly: !!field.isReadOnly,
addToCategory: !!field.addToCategory,
conditions: field.conditions ?? null,
}))
: []
const createData = {
name,
description: description ?? null,
status,
materialType: finalMaterialType,
fieldGroups: fieldGroups ?? null,
displayConfig: displayConfig ?? null,
fields: formattedFields.length
? { createMany: { data: formattedFields } }
: undefined,
} as Prisma.UploadTemplateCreateInput
const template = await prisma.uploadTemplate.create({
data: {
name,
description: description ?? null,
status,
fieldGroups: fieldGroups ?? null,
displayConfig: displayConfig ?? null,
fields: formattedFields.length
? { createMany: { data: formattedFields } }
: undefined,
},
data: createData,
select: baseSelect,
})

View File

@ -0,0 +1,88 @@
/**
* 分类字段配置 API
*
* 支持查询「哪些字段可用于分类管理」以及启用/停用指定字段
* 这样管理字段无需回到模板编辑器逐一勾选。
*/
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import {
getAvailableCategoryFields,
getManagedCategoryFields,
saveManagedCategoryFields,
} from '@/lib/category-fields'
export async function GET() {
const session = await getServerSession(authOptions)
if (!session || (session.user as any)?.role !== 'ADMIN') {
return NextResponse.json({ error: '权限不足' }, { status: 403 })
}
const [managedFields, availableFields] = await Promise.all([
getManagedCategoryFields(),
getAvailableCategoryFields(),
])
return NextResponse.json({
managedFields,
availableFields,
})
}
export async function PATCH(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session || (session.user as any)?.role !== 'ADMIN') {
return NextResponse.json({ error: '权限不足' }, { status: 403 })
}
const { fieldKey, enabled } = await req.json()
if (typeof fieldKey !== 'string' || typeof enabled !== 'boolean') {
return NextResponse.json(
{ error: '参数错误' },
{ status: 400 },
)
}
const availableFields = await getAvailableCategoryFields()
if (!availableFields.find((field) => field.key === fieldKey)) {
return NextResponse.json(
{ error: '未找到该字段' },
{ status: 404 },
)
}
const current = new Set(await getManagedCategoryFields())
if (enabled) {
current.add(fieldKey)
} else {
current.delete(fieldKey)
}
const updatedManagedFields = await saveManagedCategoryFields(Array.from(current))
await prisma.uploadField.updateMany({
where: {
key: fieldKey,
type: 'select',
},
data: {
addToCategory: enabled,
},
})
return NextResponse.json({
managedFields: updatedManagedFields,
})
} catch (error) {
console.error('Update category fields failed:', error)
return NextResponse.json(
{ error: '更新失败' },
{ status: 500 },
)
}
}

View File

@ -0,0 +1,74 @@
/**
* 素材类型 API
*
* 从模板元数据中提取素材类型选项(预制模板 + 自定义模板 materialType 字段)
*/
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
const BUILTIN_TEMPLATE_KEYS = [
'3dmax-material',
'psd-material',
'ai-material',
'video-material',
'model-material',
'other-material',
]
const BUILTIN_TEMPLATE_TYPES: Record<string, string> = {
'3dmax-material': '3DMAX',
'psd-material': 'PSD',
'ai-material': 'AI 工作流',
'video-material': '视频',
'model-material': '模特',
'other-material': '其他',
}
export async function GET() {
try {
let enabledBuiltinTemplates: string[] = []
try {
const settings = await prisma.$queryRaw<Array<{ enabledBuiltinTemplates?: string | null }>>`
SELECT "enabledBuiltinTemplates" FROM "system_settings" LIMIT 1
`
if (settings[0]?.enabledBuiltinTemplates) {
const parsed = JSON.parse(settings[0].enabledBuiltinTemplates)
if (Array.isArray(parsed) && parsed.length > 0) {
enabledBuiltinTemplates = parsed
}
}
} catch (error) {
console.error('Failed to parse enabledBuiltinTemplates:', error)
}
const typeSet = new Set<string>()
BUILTIN_TEMPLATE_KEYS
.filter((key) => enabledBuiltinTemplates.length === 0 || enabledBuiltinTemplates.includes(key))
.forEach((key) => {
const mapped = BUILTIN_TEMPLATE_TYPES[key]
if (mapped) {
typeSet.add(mapped)
}
})
const customTemplates = await prisma.uploadTemplate.findMany({
where: { status: 'active' },
select: { materialType: true },
})
customTemplates.forEach((template) => {
const value = template.materialType?.trim()
if (value) {
typeSet.add(value)
}
})
return NextResponse.json(Array.from(typeSet).sort())
} catch (error) {
console.error('Error fetching material types:', error)
return NextResponse.json(['3DMAX', 'PSD', 'AI 工作流', '视频', '模特', '其他'])
}
}

View File

@ -14,45 +14,49 @@ export async function GET(req: NextRequest) {
const materialType = searchParams.get('materialType') || ''
const platformName = searchParams.get('platform') || ''
const month = searchParams.get('month') || ''
const templateId = searchParams.get('templateId') || ''
const sortBy = searchParams.get('sortBy') || 'createdAt' // createdAt | downloadCount
const page = parseInt(searchParams.get('page') || '1')
const pageSize = parseInt(searchParams.get('pageSize') || '20')
// Build where clause
const where: any = {}
const andConditions: any[] = []
// Add search condition (search in SPU, shop, scene category, author)
if (search) {
where.OR = [
{ productModel: { contains: search, mode: 'insensitive' } }, // SPU
{ brandName: { contains: search, mode: 'insensitive' } }, // 品牌
{ seriesName: { contains: search, mode: 'insensitive' } }, // 系列
{ skuCode: { contains: search, mode: 'insensitive' } }, // SKU
{ materialTexture: { contains: search, mode: 'insensitive' } }, // 材质
{ categoryName: { contains: search, mode: 'insensitive' } }, // 使用店铺
{ sceneName: { contains: search, mode: 'insensitive' } }, // 场景类别
{ authorName: { contains: search, mode: 'insensitive' } }, // 作者
]
andConditions.push({
OR: [
{ productModel: { contains: search, mode: 'insensitive' } }, // SPU
{ brandName: { contains: search, mode: 'insensitive' } }, // 品牌
{ seriesName: { contains: search, mode: 'insensitive' } }, // 系列
{ skuCode: { contains: search, mode: 'insensitive' } }, // SKU
{ materialTexture: { contains: search, mode: 'insensitive' } }, // 材质
{ categoryName: { contains: search, mode: 'insensitive' } }, // 使用店铺
{ sceneName: { contains: search, mode: 'insensitive' } }, // 场景类别
{ authorName: { contains: search, mode: 'insensitive' } }, // 作者
]
})
}
// Add shop filter (使用店铺)
if (categoryName) {
where.categoryName = categoryName
andConditions.push({ categoryName })
}
// Add scene category filter (场景类别)
if (sceneName) {
where.sceneName = sceneName
andConditions.push({ sceneName })
}
// Add material type filter (素材类型)
if (materialType) {
where.materialType = materialType
andConditions.push({ materialType })
}
// Add platform filter (平台分类)
if (platformName) {
where.platformName = platformName
andConditions.push({ platformName })
}
// Add month filter (上传月份)
@ -60,12 +64,54 @@ export async function GET(req: NextRequest) {
const [year, monthNum] = month.split('-')
const startDate = new Date(parseInt(year), parseInt(monthNum) - 1, 1)
const endDate = new Date(parseInt(year), parseInt(monthNum), 1)
where.createdAt = {
gte: startDate,
lt: endDate
andConditions.push({
createdAt: {
gte: startDate,
lt: endDate
}
})
}
// Add template filter (模板筛选)
if (templateId) {
// 如果是预制模板,需要根据模板名称匹配
if (templateId.startsWith('builtin-')) {
const templateKey = templateId.replace('builtin-', '')
// 预制模板通过materialType匹配(因为预制模板的materialType是固定的)
const materialTypeMap: Record<string, string> = {
'3dmax-material': '3DMAX',
'psd-material': 'PSD',
'ai-material': 'AI 工作流',
'video-material': '视频',
'model-material': '模特',
'other-material': '其他',
}
const mappedMaterialType = materialTypeMap[templateKey]
if (mappedMaterialType) {
// 筛选使用该materialType的素材,且templateId为null或builtin-xxx
andConditions.push({
AND: [
{ materialType: mappedMaterialType },
{
OR: [
{ templateId: null },
{ templateId: templateId },
]
}
]
})
}
} else {
// 自定义模板,直接使用templateId
andConditions.push({ templateId })
}
}
// 合并所有条件
if (andConditions.length > 0) {
where.AND = andConditions
}
// 构建排序规则
let orderBy: any = { createdAt: 'desc' }
if (sortBy === 'downloadCount') {

View File

@ -4,6 +4,7 @@ import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { defaultLogos, featureFlags } from '@/lib/config'
import { cache } from '@/lib/cache'
import { ensureSystemSettingsOptionalColumns } from '@/lib/system-settings-columns'
const CACHE_KEY = 'system-settings'
const CACHE_TTL = 60000 // 1分钟缓存
@ -11,6 +12,7 @@ const CACHE_TTL = 60000 // 1分钟缓存
// GET - 获取系统设置(带缓存)
export async function GET() {
try {
await ensureSystemSettingsOptionalColumns()
// 先尝试从缓存获取
const cachedSettings = cache.get(CACHE_KEY)
if (cachedSettings) {
@ -18,27 +20,44 @@ export async function GET() {
}
// 获取或创建系统设置
// 使用 select 来避免查询不存在的字段
let settings = await prisma.systemSettings.findFirst({
select: {
id: true,
headerBgColor: true,
siteName: true,
updatedAt: true,
createdAt: true,
headerLogoUrl: true,
loginLogoUrl: true,
siteName: true,
enableUserActivation: true,
s3AccessKey: true,
s3Bucket: true,
s3Endpoint: true,
s3PublicUrl: true,
s3Region: true,
s3SecretKey: true,
enableRegistration: true,
enableUserActivation: true,
enableAiImageEdit: true,
enableTunnelService: true,
s3Endpoint: true,
s3AccessKey: true,
s3SecretKey: true,
s3Bucket: true,
s3Region: true,
s3PublicUrl: true,
createdAt: true,
updatedAt: true,
// enabledBuiltinTemplates 可能不存在,先不查询
},
}) as any
// 如果字段存在,尝试查询它
try {
const fullSettings = await prisma.$queryRaw<Array<{ enabledBuiltinTemplates?: string | null }>>`
SELECT "enabledBuiltinTemplates" FROM "system_settings" LIMIT 1
`
if (fullSettings[0] && settings) {
settings.enabledBuiltinTemplates = fullSettings[0].enabledBuiltinTemplates
}
})
} catch {
// 字段不存在,设置为 null(表示全部启用)
if (settings) {
settings.enabledBuiltinTemplates = null
}
}
if (!settings) {
// 如果不存在,创建默认设置
@ -53,25 +72,6 @@ export async function GET() {
enableAiImageEdit: featureFlags.aiEditEnabled,
enableTunnelService: featureFlags.tunnelEnabled,
},
select: {
id: true,
headerBgColor: true,
headerLogoUrl: true,
loginLogoUrl: true,
siteName: true,
enableUserActivation: true,
enableRegistration: true,
enableAiImageEdit: true,
enableTunnelService: true,
s3Endpoint: true,
s3AccessKey: true,
s3SecretKey: true,
s3Bucket: true,
s3Region: true,
s3PublicUrl: true,
createdAt: true,
updatedAt: true,
}
})
}
@ -91,6 +91,7 @@ export async function GET() {
// PUT - 更新系统设置(仅管理员)
export async function PUT(request: NextRequest) {
try {
await ensureSystemSettingsOptionalColumns()
const session = await getServerSession(authOptions)
const isSuperAdmin = (session?.user as any)?.isSuperAdmin
@ -117,6 +118,7 @@ export async function PUT(request: NextRequest) {
enableRegistration,
enableAiImageEdit,
enableTunnelService,
enabledBuiltinTemplates,
} = body
// 只有最高权限账号可以修改 enableUserActivation
@ -168,29 +170,11 @@ export async function PUT(request: NextRequest) {
if (enableRegistration !== undefined) updateData.enableRegistration = enableRegistration
if (enableAiImageEdit !== undefined) updateData.enableAiImageEdit = enableAiImageEdit
if (enableTunnelService !== undefined) updateData.enableTunnelService = enableTunnelService
if (enabledBuiltinTemplates !== undefined) updateData.enabledBuiltinTemplates = enabledBuiltinTemplates
settings = await prisma.systemSettings.update({
where: { id: settings.id },
data: updateData,
select: {
id: true,
headerBgColor: true,
headerLogoUrl: true,
loginLogoUrl: true,
siteName: true,
enableUserActivation: true,
enableRegistration: true,
enableAiImageEdit: true,
enableTunnelService: true,
s3Endpoint: true,
s3AccessKey: true,
s3SecretKey: true,
s3Bucket: true,
s3Region: true,
s3PublicUrl: true,
createdAt: true,
updatedAt: true,
}
})
}

View File

@ -0,0 +1,346 @@
/**
* 模板分类选项管理 API
*
* 管理特定 fieldKey 的分类选项
* 更新时会同步更新所有模板中该 fieldKey 的字段选项
*/
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
// 检查是否是数组字符串的辅助函数(与 route.ts 保持一致)
function isArrayString(str: string): boolean {
const trimmed = str.trim()
if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) {
return false
}
try {
const parsed = JSON.parse(trimmed)
return Array.isArray(parsed)
} catch {
return false
}
}
// 标准字段模板配置(与 template-categories/route.ts 保持一致)
const STANDARD_FIELD_TEMPLATES: Record<string, { key: string; label: string; type: string; addToCategory: boolean; optionsStatic: string | null }> = {
categoryName: {
key: 'categoryName',
label: '使用店铺',
type: 'select', // 老版本是下拉,必填
addToCategory: true,
optionsStatic: JSON.stringify(['默认店铺']), // select类型需要optionsStatic
},
sceneName: {
key: 'sceneName',
label: '场景类别',
type: 'select',
addToCategory: true,
optionsStatic: JSON.stringify(['默认场景']),
},
platformName: {
key: 'platformName',
label: '平台分类',
type: 'select',
addToCategory: true,
optionsStatic: JSON.stringify(['默认平台']),
},
renderer: {
key: 'renderer',
label: '渲染器',
type: 'select',
addToCategory: true,
optionsStatic: JSON.stringify(['默认渲染器']),
},
brandName: {
key: 'brandName',
label: '品牌名称',
type: 'select',
addToCategory: true,
optionsStatic: JSON.stringify(['默认品牌']),
},
seriesName: {
key: 'seriesName',
label: '系列 / 产品线',
type: 'select',
addToCategory: true,
optionsStatic: JSON.stringify(['默认产品线']),
},
materialTexture: {
key: 'materialTexture',
label: '材质 / 工艺',
type: 'select',
addToCategory: true,
optionsStatic: JSON.stringify(['默认材质']),
},
}
// 获取某个 fieldKey 的所有选项(合并所有模板中的该字段)
export async function GET(
_: NextRequest,
{ params }: { params: { fieldKey: string } }
) {
try {
const session = await getServerSession(authOptions)
if (!session || (session.user as any)?.role !== 'ADMIN') {
return NextResponse.json({ error: '权限不足' }, { status: 403 })
}
const { fieldKey } = params
// 查找所有启用的模板中包含该 fieldKey 且勾选了 addToCategory 的字段
const templates = await prisma.uploadTemplate.findMany({
where: {
status: 'active',
},
include: {
fields: {
where: {
key: fieldKey,
addToCategory: true,
type: 'select',
},
},
},
})
// 合并所有模板中该字段的选项
const allOptions = new Set<string>()
let fieldLabel = ''
templates.forEach(template => {
template.fields.forEach(field => {
if (!fieldLabel && field.label) {
fieldLabel = field.label
}
if (field.optionsStatic) {
try {
let options: string[] = []
if (typeof field.optionsStatic === 'string') {
try {
options = JSON.parse(field.optionsStatic)
} catch {
options = field.optionsStatic.split('\n').map(line => line.trim()).filter(Boolean)
}
} else if (Array.isArray(field.optionsStatic)) {
options = field.optionsStatic
}
if (Array.isArray(options)) {
options.forEach(opt => {
const optStr = String(opt).trim()
// 过滤掉数组字符串和空值
if (optStr && !isArrayString(optStr)) {
allOptions.add(optStr)
}
})
}
} catch (error) {
console.error(`Failed to parse optionsStatic for field ${field.key}:`, error)
}
}
})
})
return NextResponse.json({
fieldKey,
fieldLabel: fieldLabel || fieldKey,
options: Array.from(allOptions).sort(),
})
} catch (error) {
console.error('Error fetching template category options:', error)
return NextResponse.json(
{ error: '获取分类选项失败' },
{ status: 500 }
)
}
}
// 更新某个 fieldKey 的选项(同步更新所有模板中该字段)
export async function PUT(
req: NextRequest,
{ params }: { params: { fieldKey: string } }
) {
try {
const session = await getServerSession(authOptions)
if (!session || (session.user as any)?.role !== 'ADMIN') {
return NextResponse.json({ error: '权限不足' }, { status: 403 })
}
const { fieldKey } = params
const { options } = await req.json()
if (!Array.isArray(options)) {
return NextResponse.json(
{ error: '选项必须是数组格式' },
{ status: 400 }
)
}
// 过滤掉数组字符串和空值
const filteredOptions = options
.map(opt => String(opt).trim())
.filter(opt => opt && !isArrayString(opt))
// 检查是否是标准字段模板中的字段
const standardField = STANDARD_FIELD_TEMPLATES[fieldKey]
const isStandardField = standardField && standardField.addToCategory && standardField.type === 'select'
// 查找所有启用的模板中包含该 fieldKey 且勾选了 addToCategory 的字段
const templates = await prisma.uploadTemplate.findMany({
where: {
status: 'active',
},
include: {
fields: {
where: {
key: fieldKey,
addToCategory: true,
type: 'select',
},
},
},
})
// 如果既不是标准字段,也没有在数据库中找到对应的字段,则返回错误
if (!isStandardField && (templates.length === 0 || templates.every(t => t.fields.length === 0))) {
return NextResponse.json(
{ error: '未找到该字段' },
{ status: 404 }
)
}
// 如果是标准字段,但数据库中没有包含该字段的模板,需要创建一个默认模板或确保至少有一个模板包含该字段
let hasFieldsToUpdate = templates.some(t => t.fields.length > 0)
if (isStandardField && !hasFieldsToUpdate) {
// 查找是否有任何启用的模板(不限制字段)
const anyActiveTemplates = await prisma.uploadTemplate.findMany({
where: {
status: 'active',
},
take: 1,
})
if (anyActiveTemplates.length > 0) {
// 在第一个启用的模板中添加该字段(如果不存在)
const template = anyActiveTemplates[0]
const existingField = await prisma.uploadField.findFirst({
where: {
templateId: template.id,
key: fieldKey,
},
})
if (!existingField) {
// 创建该字段
await prisma.uploadField.create({
data: {
templateId: template.id,
key: fieldKey,
label: standardField.label,
type: 'select',
required: false,
orderIndex: 999, // 放在最后
addToCategory: true,
optionsStatic: JSON.stringify(filteredOptions),
},
})
hasFieldsToUpdate = true
} else {
// 如果字段存在但没有勾选 addToCategory,更新它
if (!existingField.addToCategory || existingField.type !== 'select') {
await prisma.uploadField.update({
where: { id: existingField.id },
data: {
addToCategory: true,
type: 'select',
optionsStatic: JSON.stringify(filteredOptions),
},
})
hasFieldsToUpdate = true
} else {
// 字段已存在且配置正确,直接更新选项
await prisma.uploadField.update({
where: { id: existingField.id },
data: {
optionsStatic: JSON.stringify(filteredOptions),
},
})
hasFieldsToUpdate = true
}
}
} else {
// 如果没有启用的模板,创建一个默认模板
await prisma.uploadTemplate.create({
data: {
name: '默认模板',
description: '系统自动创建的默认模板',
status: 'active',
fields: {
create: {
key: fieldKey,
label: standardField.label,
type: 'select',
required: false,
orderIndex: 0,
addToCategory: true,
optionsStatic: JSON.stringify(filteredOptions),
},
},
},
})
hasFieldsToUpdate = true
}
}
// 重新查询所有包含该字段的模板(包括刚创建的)
const allTemplates = await prisma.uploadTemplate.findMany({
where: {
status: 'active',
},
include: {
fields: {
where: {
key: fieldKey,
addToCategory: true,
type: 'select',
},
},
},
})
// 更新所有模板中该 fieldKey 的字段选项
const updatedFields: string[] = []
const optionsJson = JSON.stringify(filteredOptions)
await prisma.$transaction(async (tx) => {
for (const template of allTemplates) {
for (const field of template.fields) {
await tx.uploadField.update({
where: { id: field.id },
data: {
optionsStatic: optionsJson,
},
})
updatedFields.push(field.id)
}
}
})
return NextResponse.json({
success: true,
updatedFields: updatedFields.length,
options: filteredOptions,
isStandardField, // 返回是否是标准字段,供前端参考
})
} catch (error) {
console.error('Error updating template category options:', error)
return NextResponse.json(
{ error: '更新分类选项失败' },
{ status: 500 }
)
}
}

View File

@ -1,16 +1,156 @@
/**
* 模板分类 API
*
* 从模板管理中提取分类选项
* 返回所有 visibleOnHomeFilter=true 且 type='select' 的字段的选项
* 从所有模板中提取勾选了"添加到分类"的下拉字段
* 相同 fieldKey 的字段只显示一个卡片,多个模板共享
* 同时包含标准字段模板中设置了 addToCategory 的字段
*/
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import {
BUILTIN_TEMPLATE_KEYS,
STANDARD_FIELDS,
createBuiltinTemplate,
} from '@/lib/upload-template-presets'
import { getManagedCategoryFields } from '@/lib/category-fields'
// 检查是否是数组字符串的辅助函数
function isArrayString(str: string): boolean {
const trimmed = str.trim()
if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) {
return false
}
try {
const parsed = JSON.parse(trimmed)
return Array.isArray(parsed)
} catch {
return false
}
}
// 解析选项的辅助函数
function parseOptions(optionsStatic: string | null): string[] {
if (!optionsStatic) return []
// 先尝试直接解析为 JSON
try {
const parsed = JSON.parse(optionsStatic)
if (Array.isArray(parsed)) {
// 解析数组中的每个选项,如果选项本身是字符串化的数组,再次解析
const result: string[] = []
parsed.forEach(opt => {
const optStr = String(opt).trim()
if (!optStr) return
// 检查是否是字符串化的数组(如 "[\"默认店铺\"]")
if (isArrayString(optStr)) {
try {
const nestedParsed = JSON.parse(optStr)
if (Array.isArray(nestedParsed)) {
// 如果是数组,展开其中的元素,但排除数组字符串本身
nestedParsed.forEach(item => {
const itemStr = String(item).trim()
// 再次检查,避免嵌套的数组字符串
if (itemStr && !isArrayString(itemStr)) {
result.push(itemStr)
}
})
}
// 如果是数组字符串,不添加它本身
} catch {
// 解析失败,不添加
}
} else {
// 不是数组字符串,正常添加
result.push(optStr)
}
})
return result.filter(Boolean)
}
// 如果解析后不是数组,但原始字符串看起来像数组字符串,尝试再次解析
if (typeof parsed === 'string' && isArrayString(parsed)) {
try {
const doubleParsed = JSON.parse(parsed)
if (Array.isArray(doubleParsed)) {
return doubleParsed
.map(opt => String(opt).trim())
.filter(opt => opt && !isArrayString(opt))
}
} catch {
// 忽略
}
}
} catch {
// 如果不是 JSON,尝试按行分割
return optionsStatic
.split('\n')
.map(line => line.trim())
.filter(line => line && !isArrayString(line))
}
return []
}
export async function GET() {
try {
// 获取所有启用的模板
const managedFieldSet = new Set(await getManagedCategoryFields())
const includeField = (fieldKey: string) => managedFieldSet.has(fieldKey)
// 使用 Map 按 fieldKey 去重,相同 fieldKey 的字段合并到一个分类
const categoryMap = new Map<string, {
fieldKey: string
fieldLabel: string
options: Set<string>
}>()
const fieldLabelFallback = new Map<string, string>()
// 首先添加标准字段模板中设置了 addToCategory 的字段
Object.values(STANDARD_FIELDS).forEach(field => {
if (field.addToCategory === true && includeField(field.key)) {
const options = parseOptions(field.optionsStatic ?? null)
if (!categoryMap.has(field.key)) {
categoryMap.set(field.key, {
fieldKey: field.key,
fieldLabel: field.label,
options: new Set<string>(),
})
}
const entry = categoryMap.get(field.key)
options.forEach(opt => {
// 在添加到 Set 之前就过滤掉数组字符串
if (opt && !isArrayString(opt)) {
entry?.options.add(opt)
}
})
}
})
// 合并所有预制模板(新版本仅内存定义)
BUILTIN_TEMPLATE_KEYS.forEach(key => {
const template = createBuiltinTemplate(key)
template.fields.forEach(field => {
if (field.addToCategory && includeField(field.key)) {
const options = parseOptions(field.optionsStatic ?? null)
if (!categoryMap.has(field.key)) {
categoryMap.set(field.key, {
fieldKey: field.key,
fieldLabel: field.label,
options: new Set<string>(),
})
}
const entry = categoryMap.get(field.key)
options.forEach(opt => {
// 在添加到 Set 之前就过滤掉数组字符串
if (opt && !isArrayString(opt)) {
entry?.options.add(opt)
}
})
}
})
})
// 获取所有启用的模板及其字段
const templates = await prisma.uploadTemplate.findMany({
where: {
status: 'active',
@ -18,73 +158,81 @@ export async function GET() {
include: {
fields: {
where: {
visibleOnHomeFilter: true,
type: 'select',
addToCategory: true,
},
orderBy: { orderIndex: 'asc' },
},
},
})
// 提取所有分类选项
const categoriesMap = new Map<string, Set<string>>()
// 遍历所有模板的字段,合并到分类Map中
templates.forEach(template => {
template.fields.forEach(field => {
if (field.optionsStatic) {
try {
const options = typeof field.optionsStatic === 'string'
? JSON.parse(field.optionsStatic)
: field.optionsStatic
if (Array.isArray(options)) {
const fieldKey = field.key
if (!categoriesMap.has(fieldKey)) {
categoriesMap.set(fieldKey, new Set())
if (includeField(field.key)) {
fieldLabelFallback.set(field.key, field.label)
const options = parseOptions(field.optionsStatic ?? null)
const existing = categoryMap.get(field.key)
if (existing) {
options.forEach(opt => {
if (typeof opt === 'string' && opt.trim() && !isArrayString(opt.trim())) {
existing.options.add(opt.trim())
}
options.forEach((option: string) => {
categoriesMap.get(fieldKey)!.add(option)
})
}
} catch (error) {
console.error(`Failed to parse optionsStatic for field ${field.key}:`, error)
})
} else {
// 过滤掉数组字符串后再创建 Set
const filteredOptions = options.filter(opt => opt && !isArrayString(opt))
categoryMap.set(field.key, {
fieldKey: field.key,
fieldLabel: field.label,
options: new Set<string>(filteredOptions),
})
}
}
})
})
// 转换为数组格式
const categories: Array<{ fieldKey: string; fieldLabel: string; options: string[] }> = []
templates.forEach(template => {
template.fields.forEach(field => {
if (field.visibleOnHomeFilter && field.type === 'select' && field.optionsStatic) {
try {
const options = typeof field.optionsStatic === 'string'
? JSON.parse(field.optionsStatic)
: field.optionsStatic
if (Array.isArray(options)) {
// 检查是否已存在该字段的分类
const existing = categories.find(c => c.fieldKey === field.key)
if (existing) {
// 合并选项(去重)
const existingSet = new Set(existing.options)
options.forEach((opt: string) => existingSet.add(opt))
existing.options = Array.from(existingSet).sort()
} else {
categories.push({
fieldKey: field.key,
fieldLabel: field.label,
options: Array.from(new Set(options)).sort(),
})
const resolveLabel = (fieldKey: string) =>
STANDARD_FIELDS[fieldKey]?.label ||
fieldLabelFallback.get(fieldKey) ||
fieldKey
managedFieldSet.forEach(fieldKey => {
if (!categoryMap.has(fieldKey)) {
categoryMap.set(fieldKey, {
fieldKey,
fieldLabel: resolveLabel(fieldKey),
options: new Set<string>(),
})
}
})
// 转换为数组格式并排序,确保去重
const categories = Array.from(categoryMap.values()).map(cat => {
// 使用 Set 再次去重,确保没有重复选项
// 同时过滤掉看起来像数组字符串的选项(如 "[\"默认店铺\"]")
const uniqueOptions = Array.from(new Set(cat.options))
.filter(opt => {
const trimmed = opt.trim()
// 排除看起来像数组字符串的选项
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
try {
const parsed = JSON.parse(trimmed)
// 如果是数组,说明这是数组字符串,应该被过滤掉
if (Array.isArray(parsed)) {
return false
}
} catch {
// 如果解析失败,保留原选项
}
} catch (error) {
console.error(`Failed to parse optionsStatic for field ${field.key}:`, error)
}
}
})
return true
})
return {
fieldKey: cat.fieldKey,
fieldLabel: cat.fieldLabel,
options: uniqueOptions.sort(),
}
})
return NextResponse.json(categories)
@ -96,4 +244,3 @@ export async function GET() {
)
}
}

View File

@ -0,0 +1,91 @@
/**
* 模板列表 API
*
* 获取所有启用的模板列表(包括预制模板和自定义模板)
* 用于首页TAG筛选
*/
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
// 预制模板配置
const BUILTIN_TEMPLATE_KEYS = [
'3dmax-material',
'psd-material',
'ai-material',
'video-material',
'model-material',
'other-material',
]
const BUILTIN_TEMPLATE_NAMES: Record<string, string> = {
'3dmax-material': '3DMAX 素材模板',
'psd-material': 'PSD 素材模板',
'ai-material': 'AI 工作流',
'video-material': '视频素材模板',
'model-material': '模特素材模板',
'other-material': '其他素材模板',
}
function createBuiltinTemplate(key: string) {
return {
id: `builtin-${key}`,
name: BUILTIN_TEMPLATE_NAMES[key] || key,
isBuiltin: true,
}
}
export async function GET() {
try {
// 获取启用的预制模板列表
let enabledBuiltinTemplates: string[] = []
const ALL_BUILTIN_KEYS = BUILTIN_TEMPLATE_KEYS // 所有预制模板key
try {
// 使用原始 SQL 查询,避免字段不存在时的错误
const settings = await prisma.$queryRaw<Array<{ enabledBuiltinTemplates?: string | null }>>`
SELECT "enabledBuiltinTemplates" FROM "system_settings" LIMIT 1
`
if (settings[0]?.enabledBuiltinTemplates) {
const parsed = JSON.parse(settings[0].enabledBuiltinTemplates)
if (Array.isArray(parsed) && parsed.length > 0) {
enabledBuiltinTemplates = parsed
}
// 如果为空数组或null,表示全部启用,enabledBuiltinTemplates保持为空数组
}
// 如果字段不存在或为null,表示全部启用,enabledBuiltinTemplates保持为空数组
} catch (error) {
// 字段不存在或解析失败,表示全部启用
console.error('Failed to parse enabledBuiltinTemplates:', error)
}
// 获取所有启用的自定义模板
const customTemplates = await prisma.uploadTemplate.findMany({
where: {
status: 'active',
},
select: {
id: true,
name: true,
},
orderBy: { updatedAt: 'desc' },
})
// 生成所有预制模板,只包含启用的
// 如果 enabledBuiltinTemplates 为空数组,表示全部启用
const builtinTemplates = BUILTIN_TEMPLATE_KEYS
.filter(key => enabledBuiltinTemplates.length === 0 || enabledBuiltinTemplates.includes(key))
.map(key => createBuiltinTemplate(key))
// 合并预制模板和自定义模板,预制模板在前
const allTemplates = [
...builtinTemplates,
...customTemplates.map(t => ({ ...t, isBuiltin: false })),
]
return NextResponse.json(allTemplates)
} catch (error) {
console.error('Error fetching templates:', error)
return NextResponse.json({ error: '获取模板列表失败' }, { status: 500 })
}
}

View File

@ -2,21 +2,34 @@
* 公共上传模板 API
*
* 获取启用的上传模板列表(不需要管理员权限)
* 包括预制模板和自定义模板,并做区分
* 用于上传页面选择模板
*/
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import {
BUILTIN_TEMPLATE_KEYS,
BUILTIN_TEMPLATE_TYPES,
BUILTIN_TEMPLATE_NAMES,
BUILTIN_TEMPLATE_DESCRIPTIONS,
STANDARD_FIELDS,
BUILTIN_TEMPLATE_FIELDS,
createBuiltinTemplate,
} from '@/lib/upload-template-presets'
const publicSelect = {
id: true,
name: true,
description: true,
status: true,
materialType: true,
fields: {
where: {
// 只返回非只读字段,只读字段不需要用户填写
// 自定义模板中过滤掉 materialType 字段
isReadOnly: false,
key: { not: 'materialType' },
},
orderBy: { orderIndex: 'asc' },
select: {
@ -33,6 +46,7 @@ const publicSelect = {
allowedExtensions: true,
visibleOnHomeFilter: true,
isSearchable: true,
addToCategory: true,
conditions: true,
},
},
@ -40,8 +54,28 @@ const publicSelect = {
export async function GET() {
try {
// 只返回状态为 'active' 的模板
const templates = await prisma.uploadTemplate.findMany({
// 获取启用的预制模板列表
let enabledBuiltinTemplates: string[] = []
try {
// 使用原始 SQL 查询,避免字段不存在时的错误
const settings = await prisma.$queryRaw<Array<{ enabledBuiltinTemplates?: string | null }>>`
SELECT "enabledBuiltinTemplates" FROM "system_settings" LIMIT 1
`
if (settings[0]?.enabledBuiltinTemplates) {
const parsed = JSON.parse(settings[0].enabledBuiltinTemplates)
if (Array.isArray(parsed) && parsed.length > 0) {
enabledBuiltinTemplates = parsed
}
// 如果为空数组或null,表示全部启用,enabledBuiltinTemplates保持为空数组
}
// 如果字段不存在或为null,表示全部启用,enabledBuiltinTemplates保持为空数组
} catch (error) {
// 字段不存在或解析失败,表示全部启用
console.error('Failed to parse enabledBuiltinTemplates:', error)
}
// 获取所有启用的自定义模板
const customTemplates = await prisma.uploadTemplate.findMany({
where: {
status: 'active',
},
@ -49,7 +83,19 @@ export async function GET() {
select: publicSelect,
})
return NextResponse.json(templates)
// 生成所有预制模板,只包含启用的
// 如果 enabledBuiltinTemplates 为空数组,表示全部启用
const builtinTemplates = BUILTIN_TEMPLATE_KEYS
.filter(key => enabledBuiltinTemplates.length === 0 || enabledBuiltinTemplates.includes(key))
.map(key => createBuiltinTemplate(key))
// 合并预制模板和自定义模板,预制模板在前
const allTemplates = [
...builtinTemplates.map(t => ({ ...t, isBuiltin: true })),
...customTemplates.map(t => ({ ...t, isBuiltin: false })),
]
return NextResponse.json(allTemplates)
} catch (error) {
console.error('Get active upload templates error:', error)
return NextResponse.json({ error: '获取上传模板失败' }, { status: 500 })

View File

@ -101,3 +101,69 @@ body {
}
}
@keyframes modal-overlay-apple {
0% {
opacity: 0;
backdrop-filter: blur(0px);
}
100% {
opacity: 1;
backdrop-filter: blur(25px);
}
}
@keyframes modal-content-apple {
0% {
opacity: 0;
transform: translateY(28px) scale(0.92);
filter: blur(12px);
}
55% {
opacity: 1;
transform: translateY(-4px) scale(1.02);
filter: blur(0px);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.modal-overlay-apple {
animation: modal-overlay-apple 0.35s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
.modal-content-apple {
position: relative;
animation: modal-content-apple 0.45s cubic-bezier(0.16, 1, 0.3, 1) forwards;
transform-origin: top center;
box-shadow:
0 28px 80px rgba(15, 23, 42, 0.22),
0 6px 24px rgba(15, 23, 42, 0.12);
}
.dark .modal-content-apple {
box-shadow:
0 32px 110px rgba(0, 0, 0, 0.8),
0 10px 30px rgba(0, 0, 0, 0.5);
}
.modal-content-apple::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
border: 1px solid rgba(255, 255, 255, 0.65);
box-shadow:
inset 0 0 0 0.5px rgba(255, 255, 255, 0.35),
0 1px 0 rgba(15, 23, 42, 0.08);
pointer-events: none;
}
.dark .modal-content-apple::after {
border-color: rgba(255, 255, 255, 0.18);
box-shadow:
inset 0 0 0 0.5px rgba(255, 255, 255, 0.08),
0 1px 0 rgba(0, 0, 0, 0.4);
}

View File

@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useState, useRef } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import Image from 'next/image'
@ -12,8 +12,9 @@ import AIChatWidget from '@/components/AIChatWidget'
import ChangePasswordModal from '@/components/ChangePasswordModal'
import MaterialPreviewModal from '@/components/MaterialPreviewModal'
import Tag from '@/components/ui/Tag'
import { useThemeStyles } from '@/lib/ui-styles'
import { useThemeStyles, cn } from '@/lib/ui-styles'
import { useDialog } from '@/components/ui/DialogProvider'
import { scrollAnimations, gradientBackgrounds } from '@/lib/ui-animations'
interface Material {
id: string
@ -59,18 +60,9 @@ export default function Home() {
const [categories, setCategories] = useState<Array<{ id: string; name: string; count: number }>>([])
const [scenes, setScenes] = useState<Array<{ id: string; name: string; count: number }>>([])
const [platforms, setPlatforms] = useState<Array<{ id: string; name: string; count: number }>>([])
const [materialTypes] = useState([
{ value: '3DMAX', label: '3DMAX' },
{ value: 'PSD', label: 'PSD' },
{ value: 'AI', label: 'AI' },
{ value: 'CAD', label: 'CAD' },
{ value: 'C4D', label: 'C4D' },
{ value: '视频', label: '视频' },
{ value: '材质', label: '材质' },
{ value: '模特', label: '模特' },
{ value: '标准饰品', label: '标准饰品' },
{ value: '其他', label: '其他' }
])
const [materialTypes, setMaterialTypes] = useState<string[]>([])
const [templates, setTemplates] = useState<Array<{ id: string; name: string; isBuiltin: boolean }>>([])
const [selectedTemplateId, setSelectedTemplateId] = useState<string>('') // 选中的模板ID
const [downloadingId, setDownloadingId] = useState<string | null>(null)
const [downloadProgress, setDownloadProgress] = useState(0)
const [selectedMonth, setSelectedMonth] = useState('')
@ -89,6 +81,10 @@ export default function Home() {
const [showFilterModal, setShowFilterModal] = useState(false) // 筛选弹窗
const [showChangePasswordModal, setShowChangePasswordModal] = useState(false) // 修改密码弹窗
const [isDefaultPassword, setIsDefaultPassword] = useState(false) // 是否使用默认密码
const [isScrolled, setIsScrolled] = useState(false) // 是否已滚动
const [showCompactTags, setShowCompactTags] = useState(false) // 是否显示缩略TAG
const [hoveredTagGroup, setHoveredTagGroup] = useState(false) // 是否悬停在TAG组上
const searchSectionRef = useRef<HTMLDivElement>(null) // 搜索区域引用(包含TAG和搜索框)
const pageSize = 20
useEffect(() => {
@ -233,9 +229,30 @@ export default function Home() {
}
}, [])
// 滚动监听,检测搜索区域是否可见
useEffect(() => {
const handleScroll = () => {
if (!searchSectionRef.current) return
const rect = searchSectionRef.current.getBoundingClientRect()
// 当搜索区域的底部滚动到窗口顶部以上时,显示收缩的顶部栏
// 使用一个小的阈值(100px)来提前触发,让过渡更自然
const threshold = 100
const isScrolledPast = rect.bottom < threshold
setIsScrolled(isScrolledPast)
setShowCompactTags(isScrolledPast && templates.length > 0)
}
window.addEventListener('scroll', handleScroll, { passive: true })
handleScroll() // 初始检查
return () => window.removeEventListener('scroll', handleScroll)
}, [templates.length])
useEffect(() => {
setCurrentPage(1) // 重置到第一页
}, [searchKeyword, selectedCategory, selectedScene, selectedMaterialType, selectedPlatform, selectedMonth, sortBy])
}, [searchKeyword, selectedCategory, selectedScene, selectedMaterialType, selectedPlatform, selectedMonth, selectedTemplateId, sortBy])
const fetchCategories = useCallback(async () => {
try {
@ -276,6 +293,24 @@ export default function Home() {
}
}, [])
const fetchTemplates = useCallback(async () => {
try {
// 添加缓存控制,确保获取最新的模板列表
const response = await fetch('/api/templates', {
cache: 'no-store',
headers: {
'Cache-Control': 'no-cache',
},
})
if (response.ok) {
const data = await response.json()
setTemplates(data || [])
}
} catch (error) {
console.error('Error fetching templates:', error)
}
}, [])
const fetchMonthsAndTotal = useCallback(async () => {
try {
const response = await fetch('/api/materials/stats')
@ -298,6 +333,7 @@ export default function Home() {
if (selectedMaterialType) params.append('materialType', selectedMaterialType)
if (selectedPlatform) params.append('platform', selectedPlatform)
if (selectedMonth) params.append('month', selectedMonth)
if (selectedTemplateId) params.append('templateId', selectedTemplateId)
if (sortBy) params.append('sortBy', sortBy)
params.append('page', currentPage.toString())
params.append('pageSize', pageSize.toString())
@ -327,6 +363,7 @@ export default function Home() {
selectedMonth,
selectedPlatform,
selectedScene,
selectedTemplateId,
sortBy,
])
@ -335,8 +372,37 @@ export default function Home() {
fetchCategories()
fetchScenes()
fetchPlatforms()
fetchTemplates()
fetchThemeColor()
}, [fetchMaterials, fetchCategories, fetchScenes, fetchPlatforms, fetchThemeColor])
}, [fetchMaterials, fetchCategories, fetchScenes, fetchPlatforms, fetchTemplates, fetchThemeColor])
// 监听后台模板改动事件,自动刷新模板列表
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === 'templates-updated') {
fetchTemplates()
}
}
const handleMessage = (e: MessageEvent) => {
if (e.data?.type === 'templates-updated') {
fetchTemplates()
}
}
// 监听 localStorage 变化(跨标签页)
window.addEventListener('storage', handleStorageChange)
// 监听 BroadcastChannel 消息(同源跨标签页)
const channel = new BroadcastChannel('templates-channel')
channel.addEventListener('message', handleMessage)
return () => {
window.removeEventListener('storage', handleStorageChange)
channel.removeEventListener('message', handleMessage)
channel.close()
}
}, [fetchTemplates])
// 当选择店铺变化时,重新获取场景数量(父子关系联动)
useEffect(() => {
@ -528,6 +594,152 @@ export default function Home() {
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
<Header session={session} />
{/* 模板TAG筛选 - 移到最顶部(Header下方),纯文字模式 */}
{templates.length > 0 && (
<div ref={searchSectionRef} className="w-full bg-slate-50 dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700">
<div className="container mx-auto px-4 py-3 max-w-7xl">
<div className="flex flex-wrap gap-6 items-center">
<button
onClick={() => setSelectedTemplateId('')}
className={cn(
'text-sm font-medium transition-colors duration-200 relative pb-1',
selectedTemplateId === ''
? 'text-blue-600 dark:text-blue-400'
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200'
)}
>
全部
{selectedTemplateId === '' && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600 dark:bg-blue-400" />
)}
</button>
{templates.map((template) => {
const isSelected = selectedTemplateId === template.id
return (
<button
key={template.id}
onClick={() => setSelectedTemplateId(isSelected ? '' : template.id)}
className={cn(
'text-sm font-medium transition-colors duration-200 relative pb-1',
isSelected
? 'text-blue-600 dark:text-blue-400'
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200'
)}
>
{template.name.replace(' 素材模板', '')}
{isSelected && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600 dark:bg-blue-400" />
)}
</button>
)
})}
</div>
</div>
</div>
)}
{/* 收缩的顶部搜索栏和TAG(滚动时显示) */}
{isScrolled && (
<div className={cn(
'fixed left-0 right-0 z-40',
'top-[64px]', // Header高度约64px
gradientBackgrounds.glassNav,
scrollAnimations.springSmooth,
'shadow-lg',
'animate-in fade-in slide-in-from-top duration-300'
)}>
<div className="container mx-auto px-4 py-3 max-w-7xl">
<div className="flex items-center gap-3">
{/* 缩略TAG组 */}
{templates.length > 0 && (
<div
className="relative"
onMouseEnter={() => setHoveredTagGroup(true)}
onMouseLeave={() => setHoveredTagGroup(false)}
>
<button className="px-3 py-1.5 text-xs font-medium rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors">
分类
</button>
{/* 悬停时显示完整TAG列表 */}
{hoveredTagGroup && (
<div className="absolute top-full left-0 mt-2 p-4 rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 shadow-xl z-50 min-w-[200px] max-h-[400px] overflow-y-auto">
<div className="flex flex-col gap-3">
<button
onClick={() => {
setSelectedTemplateId('')
setHoveredTagGroup(false)
}}
className={cn(
'text-sm font-medium text-left transition-colors duration-200 relative pb-1',
selectedTemplateId === ''
? 'text-blue-600 dark:text-blue-400'
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200'
)}
>
全部
{selectedTemplateId === '' && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600 dark:bg-blue-400" />
)}
</button>
{templates.map((template) => {
const isSelected = selectedTemplateId === template.id
return (
<button
key={template.id}
onClick={() => {
setSelectedTemplateId(isSelected ? '' : template.id)
setHoveredTagGroup(false)
}}
className={cn(
'text-sm font-medium text-left transition-colors duration-200 relative pb-1',
isSelected
? 'text-blue-600 dark:text-blue-400'
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200'
)}
>
{template.name.replace(' 素材模板', '')}
{isSelected && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600 dark:bg-blue-400" />
)}
</button>
)
})}
</div>
</div>
)}
</div>
)}
{/* 收缩的搜索框 */}
<div className="relative flex-1 max-w-md">
<input
type="text"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
placeholder="搜索SPU、店铺、场景类别、作者..."
className={cn(styles.inputStyles.base, 'pl-10 pr-4 text-sm py-2')}
/>
<svg
className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 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>
</div>
</div>
</div>
</div>
)}
{/* 内网切换提示 */}
{showIntranetTip && (
<div className="bg-gradient-to-r from-blue-500 to-blue-600 text-white shadow-lg">
@ -576,7 +788,11 @@ export default function Home() {
<div className="flex justify-end items-center mb-6">
<button
onClick={() => setShowUploadModal(true)}
className={`${styles.buttonStyles.primary} ${styles.buttonModifiers.compact}`}
className={cn(
styles.buttonStyles.primary,
styles.buttonModifiers.compact,
// 统一UI样式已包含颜色,使用 style 覆盖主题色
)}
style={{ backgroundColor: themeColor }}
>
<span className="mr-2">+</span>
@ -621,13 +837,18 @@ export default function Home() {
{/* 筛选按钮 */}
<button
onClick={() => setShowFilterModal(true)}
className={`${styles.buttonStyles.secondary} ${styles.buttonModifiers.compact} flex items-center gap-2 relative`}
className={cn(
styles.buttonStyles.secondary,
styles.buttonModifiers.compact,
'flex items-center gap-2 relative',
// 统一UI样式已包含颜色
)}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
</svg>
筛选
{/* 显示已选筛选数量 */}
{/* 显示已选筛选数量(不包含模板筛选) */}
{(selectedCategory || selectedScene || selectedMaterialType || selectedPlatform || selectedMonth) && (
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-xs rounded-full flex items-center justify-center">
{[selectedCategory, selectedScene, selectedMaterialType, selectedPlatform, selectedMonth].filter(Boolean).length}
@ -662,16 +883,16 @@ export default function Home() {
</button>
{materialTypes.map((type) => (
<button
key={type.value}
onClick={() => setSelectedMaterialType(type.value === selectedMaterialType ? '' : type.value)}
key={type}
onClick={() => setSelectedMaterialType(type === selectedMaterialType ? '' : type)}
className={`px-4 py-2 rounded-full text-sm font-medium transition-all ${
selectedMaterialType === type.value
selectedMaterialType === type
? 'text-white'
: 'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 border border-slate-300 dark:border-slate-600 hover:bg-slate-50 dark:hover:bg-slate-700'
}`}
style={selectedMaterialType === type.value ? { backgroundColor: themeColor } : {}}
style={selectedMaterialType === type ? { backgroundColor: themeColor } : {}}
>
{type.label}
{type}
</button>
))}
</div>
@ -1382,16 +1603,16 @@ export default function Home() {
</button>
{materialTypes.map((type) => (
<button
key={type.value}
onClick={() => setSelectedMaterialType(type.value === selectedMaterialType ? '' : type.value)}
key={type}
onClick={() => setSelectedMaterialType(type === selectedMaterialType ? '' : type)}
className={`${styles.buttonModifiers.compactXs} ${styles.buttonModifiers.pill} font-medium transition-all ${
selectedMaterialType === type.value
selectedMaterialType === type
? 'text-white'
: `${styles.buttonStyles.secondary}`
}`}
style={selectedMaterialType === type.value ? { backgroundColor: themeColor } : {}}
style={selectedMaterialType === type ? { backgroundColor: themeColor } : {}}
>
{type.label}
{type}
</button>
))}
</div>

323
app/ui-preview/page.tsx Normal file
View File

@ -0,0 +1,323 @@
/**
* 统一UI元素预览页面
*
* 展示所有统一UI组件库中的组件和样式
* 用于开发参考和设计系统展示
*
* @created 2025-01-22
* @component
*/
'use client'
import { useState } from 'react'
import { useThemeStyles } from '@/lib/ui-styles'
import Button from '@/components/ui/Button'
import Input from '@/components/ui/Input'
import Card from '@/components/ui/Card'
import Badge from '@/components/ui/Badge'
import Tag from '@/components/ui/Tag'
import Avatar from '@/components/ui/Avatar'
import Modal from '@/components/ui/Modal'
import { useDialog } from '@/components/ui/DialogProvider'
export default function UIPreviewPage() {
const styles = useThemeStyles()
const { alert, confirm, prompt } = useDialog()
const [showModal, setShowModal] = useState(false)
const [inputValue, setInputValue] = useState('')
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-900 p-8">
<div className="max-w-7xl mx-auto space-y-12">
{/* 页面标题 */}
<div className="text-center space-y-2">
<h1 className="text-4xl font-bold text-slate-900 dark:text-white">
统一UI组件库预览
</h1>
<p className="text-slate-600 dark:text-slate-400">
展示所有统一UI组件和样式系统
</p>
</div>
{/* 按钮组件 */}
<section className="space-y-4">
<h2 className="text-2xl font-semibold text-slate-900 dark:text-white">按钮组件 (Button)</h2>
<Card variant="base" className="p-6">
<div className="space-y-6">
<div>
<h3 className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">变体</h3>
<div className="flex flex-wrap gap-3">
<Button variant="primary">主要按钮</Button>
<Button variant="secondary">次要按钮</Button>
<Button variant="ghost">幽灵按钮</Button>
<Button variant="danger">危险按钮</Button>
<Button variant="success">成功按钮</Button>
</div>
</div>
<div>
<h3 className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">尺寸</h3>
<div className="flex flex-wrap items-center gap-3">
<Button size="sm">小按钮</Button>
<Button size="md">中按钮</Button>
<Button size="lg">大按钮</Button>
</div>
</div>
<div>
<h3 className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">状态</h3>
<div className="flex flex-wrap gap-3">
<Button loading>加载中</Button>
<Button disabled>禁用</Button>
<Button fullWidth className="max-w-xs">全宽按钮</Button>
</div>
</div>
</div>
</Card>
</section>
{/* 输入框组件 */}
<section className="space-y-4">
<h2 className="text-2xl font-semibold text-slate-900 dark:text-white">输入框组件 (Input)</h2>
<Card variant="base" className="p-6">
<div className="space-y-6">
<div>
<h3 className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">基础输入框</h3>
<div className="space-y-3 max-w-md">
<Input
placeholder="请输入内容"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
/>
<Input placeholder="禁用状态" disabled />
<Input placeholder="错误状态" error />
</div>
</div>
<div>
<h3 className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">尺寸</h3>
<div className="space-y-3 max-w-md">
<Input size="sm" placeholder="小尺寸" />
<Input size="md" placeholder="中尺寸" />
<Input size="lg" placeholder="大尺寸" />
</div>
</div>
</div>
</Card>
</section>
{/* 卡片组件 */}
<section className="space-y-4">
<h2 className="text-2xl font-semibold text-slate-900 dark:text-white">卡片组件 (Card)</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<Card variant="base" className="p-4">
<h3 className="font-semibold mb-2">基础卡片</h3>
<p className="text-sm text-slate-600 dark:text-slate-400">这是基础卡片样式</p>
</Card>
<Card variant="hover" className="p-4">
<h3 className="font-semibold mb-2">悬停卡片</h3>
<p className="text-sm text-slate-600 dark:text-slate-400">鼠标悬停有阴影效果</p>
</Card>
<Card variant="large" className="p-4">
<h3 className="font-semibold mb-2">大卡片</h3>
<p className="text-sm text-slate-600 dark:text-slate-400">更大的内边距</p>
</Card>
<Card variant="flat" className="p-4">
<h3 className="font-semibold mb-2">扁平卡片</h3>
<p className="text-sm text-slate-600 dark:text-slate-400">无阴影扁平风格</p>
</Card>
<Card variant="rainbow" className="p-4">
<h3 className="font-semibold mb-2">彩虹卡片</h3>
<p className="text-sm text-slate-600 dark:text-slate-400">渐变边框效果</p>
</Card>
<Card variant="glass" className="p-4">
<h3 className="font-semibold mb-2">玻璃卡片</h3>
<p className="text-sm text-slate-600 dark:text-slate-400">毛玻璃效果</p>
</Card>
</div>
</section>
{/* 徽章组件 */}
<section className="space-y-4">
<h2 className="text-2xl font-semibold text-slate-900 dark:text-white">徽章组件 (Badge)</h2>
<Card variant="base" className="p-6">
<div className="space-y-6">
<div>
<h3 className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">变体</h3>
<div className="flex flex-wrap gap-3">
<Badge variant="primary">主要</Badge>
<Badge variant="success">成功</Badge>
<Badge variant="warning">警告</Badge>
<Badge variant="danger">危险</Badge>
</div>
</div>
<div>
<h3 className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">尺寸</h3>
<div className="flex flex-wrap items-center gap-3">
<Badge size="sm">小</Badge>
<Badge size="md">中</Badge>
<Badge size="lg">大</Badge>
</div>
</div>
</div>
</Card>
</section>
{/* 标签组件 */}
<section className="space-y-4">
<h2 className="text-2xl font-semibold text-slate-900 dark:text-white">标签组件 (Tag)</h2>
<Card variant="base" className="p-6">
<div className="space-y-6">
<div>
<h3 className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">颜色</h3>
<div className="flex flex-wrap gap-3">
<Tag color="blue">蓝色</Tag>
<Tag color="green">绿色</Tag>
<Tag color="orange">橙色</Tag>
<Tag color="purple">紫色</Tag>
<Tag color="pink">粉色</Tag>
<Tag color="cyan">青色</Tag>
</div>
</div>
<div>
<h3 className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">选中状态</h3>
<div className="flex flex-wrap gap-3">
<Tag color="blue" selected>选中</Tag>
<Tag color="green" selected>选中</Tag>
<Tag color="orange">未选中</Tag>
<Tag color="purple">未选中</Tag>
</div>
</div>
<div>
<h3 className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">尺寸</h3>
<div className="flex flex-wrap items-center gap-3">
<Tag size="sm">小</Tag>
<Tag size="md">中</Tag>
<Tag size="lg">大</Tag>
</div>
</div>
</div>
</Card>
</section>
{/* 头像组件 */}
<section className="space-y-4">
<h2 className="text-2xl font-semibold text-slate-900 dark:text-white">头像组件 (Avatar)</h2>
<Card variant="base" className="p-6">
<div className="space-y-6">
<div>
<h3 className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">文字头像</h3>
<div className="flex flex-wrap items-center gap-4">
<Avatar size="xs" name="A" />
<Avatar size="sm" name="B" />
<Avatar size="md" name="C" />
<Avatar size="lg" name="D" />
<Avatar size="xl" name="E" />
</div>
</div>
<div>
<h3 className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">图片头像</h3>
<div className="flex flex-wrap items-center gap-4">
<Avatar size="md" src="https://via.placeholder.com/40" alt="Avatar" />
<Avatar size="lg" src="https://via.placeholder.com/60" alt="Avatar" />
</div>
</div>
</div>
</Card>
</section>
{/* 对话框组件 */}
<section className="space-y-4">
<h2 className="text-2xl font-semibold text-slate-900 dark:text-white">对话框组件 (Dialog)</h2>
<Card variant="base" className="p-6">
<div className="space-y-4">
<div className="flex flex-wrap gap-3">
<Button onClick={() => alert({ title: '提示', message: '这是一个提示消息' })}>
显示提示
</Button>
<Button
variant="danger"
onClick={() =>
confirm({
title: '确认',
message: '确定要执行此操作吗?',
variant: 'danger',
})
}
>
显示确认
</Button>
<Button
variant="secondary"
onClick={() =>
prompt({
title: '输入',
message: '请输入内容:',
placeholder: '请输入...',
})
}
>
显示输入
</Button>
</div>
</div>
</Card>
</section>
{/* 模态框组件 */}
<section className="space-y-4">
<h2 className="text-2xl font-semibold text-slate-900 dark:text-white">模态框组件 (Modal)</h2>
<Card variant="base" className="p-6">
<div className="space-y-4">
<Button onClick={() => setShowModal(true)}>打开模态框</Button>
<Modal
isOpen={showModal}
onClose={() => setShowModal(false)}
title="示例模态框"
size="medium"
>
<div className="space-y-4">
<p className="text-slate-600 dark:text-slate-400">
这是一个模态框示例。你可以在这里放置任何内容。
</p>
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setShowModal(false)}>
取消
</Button>
<Button onClick={() => setShowModal(false)}>确定</Button>
</div>
</div>
</Modal>
</div>
</Card>
</section>
{/* 样式系统 */}
<section className="space-y-4">
<h2 className="text-2xl font-semibold text-slate-900 dark:text-white">样式系统</h2>
<Card variant="base" className="p-6">
<div className="space-y-6">
<div>
<h3 className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">文本样式</h3>
<div className="space-y-2">
<p className="text-2xl font-bold text-slate-900 dark:text-white">标题文本</p>
<p className="text-xl font-semibold text-slate-800 dark:text-slate-200">副标题文本</p>
<p className="text-base text-slate-700 dark:text-slate-300">正文文本</p>
<p className="text-sm text-slate-500 dark:text-slate-400">说明文本</p>
</div>
</div>
<div>
<h3 className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">强调样式</h3>
<div className="flex flex-wrap gap-3">
<span className={styles.accentStyles.blue.text}>蓝色强调</span>
<span className={styles.accentStyles.emerald.text}>绿色强调</span>
<span className={styles.accentStyles.orange.text}>橙色强调</span>
<span className={styles.accentStyles.purple.text}>紫色强调</span>
</div>
</div>
</div>
</Card>
</section>
</div>
</div>
)
}

View File

@ -222,8 +222,12 @@ export default function Header({ session }: HeaderProps) {
)}
<header
className={`sticky z-50 shadow-sm ${showNetworkBanner ? 'top-[72px]' : 'top-0'}`}
style={{ backgroundColor: headerBgColor }}
className={cn(
'sticky z-50 shadow-sm',
showNetworkBanner ? 'top-[72px]' : 'top-0',
'bg-white/80 dark:bg-slate-900/80 backdrop-blur-xl border-b border-white/20 dark:border-slate-700/30',
'transition-all duration-300'
)}
>
<div className="container mx-auto px-4 py-4 max-w-7xl">
<div className="flex items-center justify-between">
@ -288,11 +292,20 @@ export default function Header({ session }: HeaderProps) {
}}
/>
) : (
<div className="w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
<span className="text-lg font-bold text-white">{siteName.charAt(0)}</span>
<div className={cn(
'w-10 h-10 rounded-lg flex items-center justify-center',
theme === 'dark' ? 'bg-white/10' : 'bg-slate-900/10'
)}>
<span className={cn(
'text-lg font-bold',
theme === 'dark' ? 'text-white' : 'text-slate-900'
)}>{siteName.charAt(0)}</span>
</div>
)}
<span className="text-2xl font-semibold text-white">
<span className={cn(
'text-2xl font-semibold',
theme === 'dark' ? 'text-white' : 'text-slate-900'
)}>
{siteName}
</span>
</Link>
@ -324,7 +337,12 @@ export default function Header({ session }: HeaderProps) {
}}
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"
className={cn(
"relative flex items-center px-2 py-1 rounded-full border 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",
theme === 'dark'
? 'border-white/25 bg-white/15'
: 'border-slate-300 bg-white/80'
)}
>
<div className="absolute inset-0 flex items-center justify-between px-2">
<svg

View File

@ -29,7 +29,6 @@ export default function MaterialPreviewModal({
closeOnOverlayClick={true}
closeOnEscape={true}
showCloseButton={true}
className="bg-black/90"
>
<div className="relative w-full h-full flex items-center justify-center p-4 -m-6">
<div className="text-center w-full max-w-7xl">

File diff suppressed because it is too large Load Diff

View File

@ -13,7 +13,7 @@
import { ReactNode } from 'react'
import Modal from './Modal'
import { useThemeStyles } from '@/lib/ui-styles'
import { useThemeStyles, cn } from '@/lib/ui-styles'
interface AlertDialogProps {
isOpen: boolean
@ -37,13 +37,13 @@ export default function AlertDialog({
const getButtonStyle = () => {
switch (variant) {
case 'success':
return styles.buttonStyles.success
return { base: styles.buttonStyles.success, colors: 'bg-emerald-600 hover:bg-emerald-700 text-white' }
case 'warning':
return styles.buttonStyles.secondary
return { base: styles.buttonStyles.secondary, colors: 'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-200' }
case 'error':
return styles.buttonStyles.danger
return { base: styles.buttonStyles.danger, colors: 'bg-red-600 hover:bg-red-700 text-white' }
default:
return styles.buttonStyles.primary
return { base: styles.buttonStyles.primary, colors: 'bg-indigo-600 hover:bg-indigo-700 text-white' }
}
}
@ -52,20 +52,24 @@ export default function AlertDialog({
isOpen={isOpen}
onClose={onClose}
title={title}
size="small"
size="xs"
footer={
<button
type="button"
onClick={onClose}
className={`${getButtonStyle()} ${styles.buttonModifiers.compact}`}
className={cn(
getButtonStyle().base,
styles.buttonModifiers.compact,
getButtonStyle().colors // 显式添加背景色和文字色
)}
>
{buttonText}
</button>
}
>
<div className="py-2">
<div className="py-2 text-sm leading-relaxed text-slate-700 dark:text-slate-200">
{typeof message === 'string' ? (
<p className="text-slate-700 dark:text-slate-200">{message}</p>
<p>{message}</p>
) : (
message
)}

View File

@ -13,7 +13,7 @@
import { ReactNode } from 'react'
import Modal from './Modal'
import { useThemeStyles } from '@/lib/ui-styles'
import { useThemeStyles, cn } from '@/lib/ui-styles'
interface ConfirmDialogProps {
isOpen: boolean
@ -51,7 +51,7 @@ export default function ConfirmDialog({
isOpen={isOpen}
onClose={onClose}
title={title}
size="small"
size="xs"
closeOnOverlayClick={!loading}
closeOnEscape={!loading}
footer={
@ -60,7 +60,11 @@ export default function ConfirmDialog({
type="button"
onClick={onClose}
disabled={loading}
className={`${styles.buttonStyles.secondary} ${styles.buttonModifiers.compact}`}
className={cn(
styles.buttonStyles.secondary,
styles.buttonModifiers.compact,
'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-200' // 显式添加背景色和文字色
)}
>
{cancelText}
</button>
@ -68,18 +72,22 @@ export default function ConfirmDialog({
type="button"
onClick={handleConfirm}
disabled={loading}
className={`${
variant === 'danger' ? styles.buttonStyles.danger : styles.buttonStyles.primary
} ${styles.buttonModifiers.compact}`}
className={cn(
variant === 'danger' ? styles.buttonStyles.danger : styles.buttonStyles.primary,
styles.buttonModifiers.compact,
variant === 'danger'
? 'bg-red-600 hover:bg-red-700 text-white' // danger 按钮
: 'bg-indigo-600 hover:bg-indigo-700 text-white', // primary 按钮
)}
>
{loading ? '处理中...' : confirmText}
</button>
</>
}
>
<div className="py-2">
<div className="py-2 text-sm leading-relaxed text-slate-700 dark:text-slate-200">
{typeof message === 'string' ? (
<p className="text-slate-700 dark:text-slate-200">{message}</p>
<p>{message}</p>
) : (
message
)}

View File

@ -12,10 +12,28 @@
'use client'
import { useEffect, useRef, ReactNode } from 'react'
import { useEffect, useRef, ReactNode, CSSProperties, useState } from 'react'
import { createPortal } from 'react-dom'
import { useThemeStyles } from '@/lib/ui-styles'
export type ModalSize = 'small' | 'medium' | 'large' | 'xlarge' | 'fullscreen'
export type ModalSize = 'xs' | 'small' | 'medium' | 'large' | 'xlarge' | 'xxlarge' | 'fullscreen'
const MODAL_BORDER_RADIUS = 32
const sizeStyleMap: Record<ModalSize, CSSProperties> = {
xs: { width: 'min(90vw, 20rem)', maxWidth: '20rem' },
small: { width: 'min(92vw, 24rem)', maxWidth: '24rem' },
medium: { width: 'min(94vw, 32rem)', maxWidth: '32rem' },
large: { width: 'min(95vw, 40rem)', maxWidth: '40rem' },
xlarge: { width: 'min(96vw, 60rem)', maxWidth: '60rem' },
xxlarge: { width: 'min(98vw, 80rem)', maxWidth: '80rem' },
fullscreen: {
width: '100vw',
height: '100vh',
maxWidth: '100vw',
maxHeight: '100vh',
},
}
interface ModalProps {
isOpen: boolean
@ -42,6 +60,7 @@ export default function Modal({
footer,
className = '',
}: ModalProps) {
const [isMounted, setIsMounted] = useState(false)
const styles = useThemeStyles()
const modalRef = useRef<HTMLDivElement>(null)
@ -71,6 +90,10 @@ export default function Modal({
}
}, [isOpen])
useEffect(() => {
setIsMounted(true)
}, [])
if (!isOpen) return null
const handleOverlayClick = (e: React.MouseEvent) => {
@ -80,8 +103,19 @@ export default function Modal({
}
const sizeClass = styles.modalStyles.sizes[size]
const sizeInlineStyle = sizeStyleMap[size] || sizeStyleMap.medium
const isCustomContent = !title && !showCloseButton && !footer
const containerInlineStyle: CSSProperties = {
...sizeInlineStyle,
borderRadius: MODAL_BORDER_RADIUS,
...(isCustomContent && { maxHeight: '92vh' }),
}
return (
const contentInlineStyle: CSSProperties = {
borderRadius: MODAL_BORDER_RADIUS,
}
const modalMarkup = (
<div
className={`${styles.modalStyles.overlay} ${className}`}
onClick={handleOverlayClick}
@ -92,10 +126,10 @@ export default function Modal({
<div
ref={modalRef}
className={`${styles.modalStyles.container} ${sizeClass}`}
style={containerInlineStyle}
onClick={(e) => e.stopPropagation()}
>
<div className={styles.modalStyles.content}>
{/* 头部 */}
<div className={styles.modalStyles.content} style={contentInlineStyle}>
{(title || showCloseButton) && (
<div className={styles.modalStyles.header}>
{title && (
@ -128,14 +162,23 @@ export default function Modal({
</div>
)}
{/* 主体 */}
<div className={styles.modalStyles.body}>{children}</div>
{/* 底部 */}
{footer && <div className={styles.modalStyles.footer}>{footer}</div>}
{title || showCloseButton || footer ? (
<>
<div className={styles.modalStyles.body}>{children}</div>
{footer && <div className={styles.modalStyles.footer}>{footer}</div>}
</>
) : (
children
)}
</div>
</div>
</div>
)
if (!isMounted) {
return null
}
return createPortal(modalMarkup, document.body)
}

View File

@ -13,7 +13,7 @@
import { useState, useEffect, useRef } from 'react'
import Modal from './Modal'
import { useThemeStyles } from '@/lib/ui-styles'
import { useThemeStyles, cn } from '@/lib/ui-styles'
interface PromptDialogProps {
isOpen: boolean
@ -47,8 +47,12 @@ export default function PromptDialog({
setValue(defaultValue)
// 延迟聚焦,确保弹窗已渲染
setTimeout(() => {
inputRef.current?.focus()
inputRef.current?.select()
const input = inputRef.current
if (input) {
input.focus()
const length = input.value.length
input.setSelectionRange(length, length)
}
}, 100)
}
}, [isOpen, defaultValue])
@ -73,13 +77,17 @@ export default function PromptDialog({
isOpen={isOpen}
onClose={onClose}
title={title}
size="small"
size="xs"
footer={
<>
<button
type="button"
onClick={onClose}
className={`${styles.buttonStyles.secondary} ${styles.buttonModifiers.compact}`}
className={cn(
styles.buttonStyles.secondary,
styles.buttonModifiers.compact,
'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-200' // 显式添加背景色和文字色
)}
>
{cancelText}
</button>
@ -87,7 +95,11 @@ export default function PromptDialog({
type="button"
onClick={handleConfirm}
disabled={!value.trim()}
className={`${styles.buttonStyles.primary} ${styles.buttonModifiers.compact}`}
className={cn(
styles.buttonStyles.primary,
styles.buttonModifiers.compact,
'bg-indigo-600 hover:bg-indigo-700 text-white' // 显式添加背景色和文字色
)}
>
{confirmText}
</button>

View File

@ -1,12 +1,19 @@
/**
* Tag 组件
*
* 统一的标签组件,遵循三层架构设计
* 统一的标签组件,遵循三层架构设计,采用苹果美学风格
* 用于显示分类、类型等标签信息
* 支持多种颜色(blue、green、orange、purple等)和尺寸(sm、md、lg)
* 支持点击事件,可用于筛选等功能
*
* 苹果美学特点:
* - 圆角更大(rounded-full)
* - 背景色更柔和(低饱和度)
* - 选中状态使用更明显的视觉反馈
* - 过渡动画更流畅
*
* @created 2025-11-22
* @updated 2025-01-22 - 重新设计为苹果美学风格
* @component
*/
@ -21,6 +28,7 @@ interface TagProps {
size?: 'sm' | 'md' | 'lg'
className?: string
onClick?: () => void
selected?: boolean
}
export default function Tag({
@ -29,27 +37,51 @@ export default function Tag({
size = 'md',
className = '',
onClick,
selected = false,
}: TagProps) {
const styles = useThemeStyles()
// 使用低饱和度炫彩元素
// 苹果美学风格的颜色配置(更柔和的背景,更清晰的文字)
const colorClasses = {
blue: `${styles.accentStyles.blue.bg} ${styles.accentStyles.blue.text} border ${styles.accentStyles.blue.border}`,
green: `${styles.accentStyles.emerald.bg} ${styles.accentStyles.emerald.text} border ${styles.accentStyles.emerald.border}`,
orange: `${styles.accentStyles.orange.bg} ${styles.accentStyles.orange.text} border ${styles.accentStyles.orange.border}`,
purple: `${styles.accentStyles.purple.bg} ${styles.accentStyles.purple.text} border ${styles.accentStyles.purple.border}`,
indigo: `${styles.accentStyles.purple.bg} ${styles.accentStyles.purple.text} border ${styles.accentStyles.purple.border}`, // indigo 使用 purple
pink: `${styles.accentStyles.pink.bg} ${styles.accentStyles.pink.text} border ${styles.accentStyles.pink.border}`,
cyan: `${styles.accentStyles.cyan.bg} ${styles.accentStyles.cyan.text} border ${styles.accentStyles.cyan.border}`,
blue: selected
? 'bg-blue-500 dark:bg-blue-600 text-white border-blue-500 dark:border-blue-600'
: 'bg-blue-50 dark:bg-blue-950/30 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800/50',
green: selected
? 'bg-emerald-500 dark:bg-emerald-600 text-white border-emerald-500 dark:border-emerald-600'
: 'bg-emerald-50 dark:bg-emerald-950/30 text-emerald-700 dark:text-emerald-300 border-emerald-200 dark:border-emerald-800/50',
orange: selected
? 'bg-orange-500 dark:bg-orange-600 text-white border-orange-500 dark:border-orange-600'
: 'bg-orange-50 dark:bg-orange-950/30 text-orange-700 dark:text-orange-300 border-orange-200 dark:border-orange-800/50',
purple: selected
? 'bg-purple-500 dark:bg-purple-600 text-white border-purple-500 dark:border-purple-600'
: 'bg-purple-50 dark:bg-purple-950/30 text-purple-700 dark:text-purple-300 border-purple-200 dark:border-purple-800/50',
indigo: selected
? 'bg-indigo-500 dark:bg-indigo-600 text-white border-indigo-500 dark:border-indigo-600'
: 'bg-indigo-50 dark:bg-indigo-950/30 text-indigo-700 dark:text-indigo-300 border-indigo-200 dark:border-indigo-800/50',
pink: selected
? 'bg-pink-500 dark:bg-pink-600 text-white border-pink-500 dark:border-pink-600'
: 'bg-pink-50 dark:bg-pink-950/30 text-pink-700 dark:text-pink-300 border-pink-200 dark:border-pink-800/50',
cyan: selected
? 'bg-cyan-500 dark:bg-cyan-600 text-white border-cyan-500 dark:border-cyan-600'
: 'bg-cyan-50 dark:bg-cyan-950/30 text-cyan-700 dark:text-cyan-300 border-cyan-200 dark:border-cyan-800/50',
}
const sizeClasses = {
sm: 'px-2 py-0.5 text-xs',
md: 'px-2 py-1 text-xs',
lg: 'px-3 py-1.5 text-sm',
sm: 'px-3 py-1 text-xs font-medium',
md: 'px-4 py-1.5 text-sm font-medium',
lg: 'px-5 py-2 text-base font-medium',
}
const baseClasses = 'inline-flex items-center font-medium rounded transition-colors'
// 苹果美学:圆角更大,过渡更流畅,选中状态有阴影
const baseClasses = cn(
'inline-flex items-center justify-center',
'rounded-full border',
'transition-all duration-200 ease-out',
'backdrop-blur-sm',
selected && 'shadow-md scale-105',
!selected && onClick && 'hover:scale-105 hover:shadow-sm',
onClick && 'cursor-pointer active:scale-95'
)
return (
<span
@ -57,13 +89,6 @@ export default function Tag({
baseClasses,
colorClasses[color],
sizeClasses[size],
onClick && 'cursor-pointer',
onClick && color === 'blue' ? styles.accentStyles.blue.hover :
onClick && color === 'green' ? styles.accentStyles.emerald.hover :
onClick && color === 'orange' ? styles.accentStyles.orange.hover :
onClick && (color === 'purple' || color === 'indigo') ? styles.accentStyles.purple.hover :
onClick && color === 'pink' ? styles.accentStyles.pink.hover :
onClick && color === 'cyan' ? styles.accentStyles.cyan.hover : '',
className
)}
onClick={onClick}

View File

@ -1,31 +1,25 @@
# ================================================================
# 新建简化部署脚本 - 处理SSH认证问题和本地构建上传
# 优化版部署脚本 v1.1 - 支持自动SSH密钥处理、错误恢复和重试机制
# 改进内容:
# - 添加重试机制(SSH连接、文件上传、Docker推送、远程部署)
# - 使用唯一临时文件名避免并发冲突
# - 增加SSH连接超时时间(10秒 -> 20秒)
# - 添加ServerAliveInterval保持连接活跃
# - 改进错误处理和日志输出
# 由壹点3D素材 - 部署脚本 v1.2
# 功能:本地构建 -> 推送到Registry -> 远程部署
# 特性:自动加载基础镜像、智能重试、镜像清理、趣味提示
# ================================================================
param(
[string]$Server = "45.249.208.118",
[string]$Username = "root",
[string]$Password = "",
[SecureString]$Password = $null,
[string]$KeyPath = "",
[string]$ImageName = "youyidian-3d-material",
[string]$Tag = "local-deploy",
[string]$LocalPath = "",
[string]$RemotePath = "/root/deploy",
[string]$EnvFile = "",
[switch]$SkipCheck,
[switch]$SkipBuild,
[switch]$SkipUpload,
[switch]$Force,
[switch]$Debug,
[switch]$Help,
[switch]$ForcePuTTY
[switch]$CleanImages,
[int]$KeepImageCount = 3
)
# 脚本配置
@ -54,7 +48,13 @@ $script:FunTips = @(
"🎵 背景 BGM 同步播放中,部署节奏稳稳的。",
"🧊 如果日志有点长,记得深呼吸,冷静才是王牌技能。",
"📦 镜像像快递一样打包上路,收件人:Production。",
"🚀 每一个 build 都是一次漂亮的起飞。"
"🚀 每一个 build 都是一次漂亮的起飞。",
"✨ 代码正在变身,从开发模式切换到生产模式~",
"🎨 由壹点3D素材正在打包,准备惊艳登场!",
"⚡ 部署速度:光速模式已启动",
"🎪 部署马戏团正在表演,请保持期待~",
"🌙 夜深了?没关系,部署脚本陪你到天亮",
"🎁 部署完成后,记得给自己一个小奖励!"
)
function Initialize-StageTracker {
@ -99,6 +99,7 @@ function Get-FunTip {
return Get-Random -InputObject $script:FunTips
}
# 内部函数:庆祝阶段完成(使用自定义动词以保持代码可读性)
function Celebrate-Stage {
param([string]$Title)
$tip = Get-FunTip
@ -167,20 +168,21 @@ if ($Help) {
Write-ColorOutput "参数:" "White"
Write-ColorOutput " -Server <地址> 服务器地址 (默认: 45.249.208.118)" "Gray"
Write-ColorOutput " -Username <用户> SSH用户名 (默认: root)" "Gray"
Write-ColorOutput " -Password <密码> SSH密码" "Gray"
Write-ColorOutput " -KeyPath <路径> SSH私钥路径" "Gray"
Write-ColorOutput " -KeyPath <路径> SSH私钥路径 (推荐)" "Gray"
Write-ColorOutput " -EnvFile <路径> 指定构建期使用的环境变量文件" "Gray"
Write-ColorOutput " -SkipCheck 跳过代码检查" "Gray"
Write-ColorOutput " -SkipBuild 跳过Docker构建" "Gray"
Write-ColorOutput " -SkipUpload 跳过文件上传" "Gray"
Write-ColorOutput " -SkipUpload 跳过镜像推送和远程部署" "Gray"
Write-ColorOutput " -CleanImages 清理旧版本镜像" "Gray"
Write-ColorOutput " -KeepImageCount <N> 保留的镜像数量 (默认: 3)" "Gray"
Write-ColorOutput " -Debug 启用调试模式" "Gray"
Write-ColorOutput " -Help 显示此帮助信息" "Gray"
Write-ColorOutput "" "White"
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\YourUsername\.ssh\youyidian_prod_ed25519'" "Yellow"
Write-ColorOutput " .\deploy-new-fixed.ps1 -SkipCheck -SkipUpload" "Yellow"
Write-ColorOutput " .\deploy-new-fixed.ps1 -CleanImages -KeepImageCount 5" "Yellow"
exit 0
}
@ -226,6 +228,7 @@ function Resolve-EnvFilePath {
}
}
# 内部函数:准备构建环境
function Prepare-BuildEnvironment {
param(
[string]$ProjectRoot,
@ -380,23 +383,6 @@ function Find-SSHKeys {
return $foundKeys
}
# 查找 PuTTY 工具路径
function Find-PuTTYTools {
$searchPaths = @(
"C:\Program Files\PuTTY",
"C:\Program Files (x86)\PuTTY",
"$env:USERPROFILE\AppData\Local\Programs\PuTTY"
)
foreach ($path in $searchPaths) {
$plinkPath = Join-Path $path "plink.exe"
if (Test-Path $plinkPath) {
return $path
}
}
return $null
}
# 生成唯一临时文件名
function Get-TempFileName {
@ -412,7 +398,7 @@ function Test-SSHConnection {
param(
[string]$Server,
[string]$Username,
[string]$Password = "",
[SecureString]$Password = $null,
[string]$KeyPath = "",
[int]$MaxRetries = 3,
[int]$RetryDelay = 2
@ -434,36 +420,6 @@ function Test-SSHConnection {
$stderrFile = Get-TempFileName -Prefix "ssh_stderr"
try {
if ($ForcePuTTY.IsPresent) {
$plinkPath = "C:\Program Files\PuTTY\plink.exe"
if (Test-Path $plinkPath) {
$plinkArgs = @("-batch")
if ($Password) {
Write-Info "使用 PuTTY 密码认证测试连接..."
$plinkArgs += @("-pw", $Password)
} elseif ($KeyPath -and (Test-Path $KeyPath)) {
Write-Info "使用 PuTTY SSH密钥测试连接: $KeyPath"
$plinkArgs += @("-i", $KeyPath)
} else {
Write-ErrorColored "需要提供密码或密钥文件"
return $false
}
$plinkArgs += @($target, "echo 'SSH连接测试成功'")
$process = Start-Process -FilePath $plinkPath -ArgumentList $plinkArgs -NoNewWindow -PassThru -Wait -RedirectStandardOutput $stdoutFile -RedirectStandardError $stderrFile
if ($process.ExitCode -eq 0) {
Write-Success "SSH连接测试成功"
Remove-Item $stdoutFile, $stderrFile -ErrorAction SilentlyContinue
return $true
} else {
$errorOutput = Get-Content $stderrFile -ErrorAction SilentlyContinue
$lastError = $errorOutput
Write-Warning "SSH连接失败 (尝试 $attempt/$MaxRetries): $errorOutput"
}
} else {
Write-Warning "未找到 PuTTY,回退到 OpenSSH"
}
}
# 使用 OpenSSH(增加超时时间到20秒)
$sshArgs = @("-o", "BatchMode=yes", "-o", "ConnectTimeout=20", "-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=3")
@ -473,13 +429,14 @@ function Test-SSHConnection {
Write-Info "使用SSH密钥: $KeyPath"
}
} elseif ($Password) {
# 注意:SecureString 密码认证需要额外处理,当前主要使用 SSH 密钥
$sshArgs += @("-o", "PasswordAuthentication=yes")
if ($attempt -eq 1) {
Write-Info "使用密码认证"
Write-Warning "密码认证支持有限,建议使用 SSH 密钥"
}
} else {
if ($attempt -eq 1) {
Write-Warning "未提供密码或密钥,使用交互式认证"
Write-Warning "未提供密钥,使用交互式认证"
}
$sshArgs += @("-o", "StrictHostKeyChecking=no")
}
@ -508,79 +465,191 @@ function Test-SSHConnection {
return $false
}
# 上传文件(带重试机制)
function Upload-File {
# 内部函数:清理本地 Docker 镜像
function Clean-LocalDockerImages {
param(
[string]$LocalPath,
[string]$RemotePath,
[string]$Server,
[string]$Username,
[string]$Password = "",
[string]$KeyPath = "",
[int]$MaxRetries = 3,
[int]$RetryDelay = 3
[string]$ImageName,
[string]$KeepTag = "local-deploy",
[int]$KeepCount = 3
)
$fileName = [System.IO.Path]::GetFileName($LocalPath)
$remoteTarget = "${Username}@${Server}:${RemotePath}/${fileName}"
$attempt = 0
$lastError = $null
while ($attempt -lt $MaxRetries) {
$attempt++
if ($attempt -gt 1) {
Write-Info "文件上传重试 ($attempt/$MaxRetries)..."
Start-Sleep -Seconds $RetryDelay
Write-Info "🧹 清理本地 Docker 镜像..."
try {
# 清理悬空镜像
Write-Info " 清理悬空镜像..."
docker image prune -f 2>&1 | Out-Null
if ($LASTEXITCODE -eq 0) {
Write-Success " 悬空镜像清理完成"
}
# 生成唯一临时文件名
$stdoutFile = Get-TempFileName -Prefix "scp_stdout"
$stderrFile = Get-TempFileName -Prefix "scp_stderr"
$scpArgs = @("-o", "BatchMode=yes", "-o", "ConnectTimeout=60", "-o", "ServerAliveInterval=10", "-o", "ServerAliveCountMax=3")
if ($KeyPath -and (Test-Path $KeyPath)) {
$scpArgs += @("-i", $KeyPath)
if ($attempt -eq 1) {
Write-Info "使用SSH密钥上传: $KeyPath"
# 清理指定镜像的旧版本(保留最新的几个)
Write-Info " 清理 $ImageName 的旧版本镜像(保留最新 $KeepCount 个)..."
$images = docker images --format "{{.ID}}\t{{.Repository}}\t{{.Tag}}\t{{.CreatedAt}}" | Where-Object { $_ -match $ImageName }
if ($images) {
$sortedImages = $images | Sort-Object { [DateTime]::ParseExact(($_ -split "`t")[3], "yyyy-MM-dd HH:mm:ss zzz", $null) } -Descending
$toRemove = $sortedImages | Select-Object -Skip $KeepCount
foreach ($img in $toRemove) {
$imgId = ($img -split "`t")[0]
$imgRepo = ($img -split "`t")[1]
$imgTag = ($img -split "`t")[2]
Write-Info " 删除: $imgRepo`:$imgTag ($imgId)"
docker rmi -f $imgId 2>&1 | Out-Null
}
} elseif ($Password) {
$scpArgs += @("-o", "PasswordAuthentication=yes")
if ($attempt -eq 1) {
Write-Info "使用密码上传"
if ($toRemove) {
Write-Success " 已清理 $($toRemove.Count) 个旧镜像"
} else {
Write-Info " 没有需要清理的旧镜像"
}
}
# 清理所有未使用的镜像(可选,谨慎使用)
# docker image prune -a -f
return $true
} catch {
Write-Warning "清理本地镜像时出错: $_"
return $false
}
}
# 内部函数:清理服务器上的 Docker 镜像
function Clean-RemoteDockerImages {
param(
[string]$Server,
[string]$Username,
[string]$KeyPath,
[string]$ImageName,
[int]$KeepCount = 3
)
Write-Info "🧹 清理服务器上的 Docker 镜像..."
$cleanCmd = @"
# 清理悬空镜像
docker image prune -f
# 清理指定镜像的旧版本(保留最新的几个)
docker images --format '{{.ID}}\t{{.Repository}}\t{{.Tag}}\t{{.CreatedAt}}' | grep '$ImageName' | sort -k4 -r | tail -n +$($KeepCount + 1) | awk '{print \$1}' | xargs -r docker rmi -f 2>/dev/null || true
# 显示清理后的镜像列表
echo '--- 清理后的镜像列表 ---'
docker images | grep '$ImageName' | head -10
"@
$sshArgs = @("-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=30")
if ($KeyPath -and (Test-Path $KeyPath)) {
$sshArgs += @("-i", $KeyPath)
}
try {
$sshArgs += @("${Username}@${Server}", $cleanCmd)
$process = Start-Process -FilePath "ssh" -ArgumentList $sshArgs -NoNewWindow -PassThru -Wait
if ($process.ExitCode -eq 0) {
Write-Success "服务器镜像清理完成"
return $true
} else {
Write-Warning "服务器镜像清理可能未完全成功(退出码: $($process.ExitCode))"
return $false
}
} catch {
Write-Warning "清理服务器镜像时出错: $_"
return $false
}
}
# 内部函数:清理 Docker Registry 中的旧镜像
function Clean-RegistryImages {
param(
[string]$RegistryUrl,
[string]$ImageName,
[int]$KeepCount = 5
)
Write-Info "🧹 清理 Docker Registry 中的旧镜像..."
try {
# 获取镜像的所有标签
$tagsUrl = "$RegistryUrl/v2/$ImageName/tags/list"
$tagsResponse = Invoke-RestMethod -Uri $tagsUrl -Method Get -ErrorAction SilentlyContinue
if ($tagsResponse -and $tagsResponse.tags) {
$tags = $tagsResponse.tags | Where-Object { $_ -ne "latest" -and $_ -ne "local-deploy" } | Sort-Object -Descending
$toRemove = $tags | Select-Object -Skip $KeepCount
if ($toRemove) {
Write-Info " 将删除 $($toRemove.Count) 个旧标签: $($toRemove -join ', ')"
foreach ($tag in $toRemove) {
try {
# 获取 manifest digest
$manifestUrl = "$RegistryUrl/v2/$ImageName/manifests/$tag"
$headers = @{
"Accept" = "application/vnd.docker.distribution.manifest.v2+json"
}
$manifestResponse = Invoke-WebRequest -Uri $manifestUrl -Method Head -Headers $headers -ErrorAction SilentlyContinue
if ($manifestResponse -and $manifestResponse.Headers["Docker-Content-Digest"]) {
$digest = $manifestResponse.Headers["Docker-Content-Digest"]
$deleteUrl = "$RegistryUrl/v2/$ImageName/manifests/$digest"
Invoke-RestMethod -Uri $deleteUrl -Method Delete -ErrorAction SilentlyContinue | Out-Null
Write-Info " 已删除: $ImageName`:$tag"
}
} catch {
Write-Warning " 删除 $ImageName`:$tag 失败: $_"
}
}
Write-Success "Registry 镜像清理完成"
} else {
Write-Info " 没有需要清理的旧镜像标签"
}
} else {
if ($attempt -eq 1) {
Write-Warning "使用交互式上传"
}
$scpArgs += @("-o", "StrictHostKeyChecking=no")
Write-Info " 未找到镜像 $ImageName 或无法访问 Registry"
}
try {
if ($attempt -eq 1) {
Write-Info "正在上传: $LocalPath -> $remoteTarget"
return $true
} catch {
Write-Warning "清理 Registry 镜像时出错: $_"
return $false
}
}
# 内部函数:加载基础镜像
function Load-BaseImages {
param([string]$ProjectRoot)
$imagesDir = Join-Path $ProjectRoot "docker-images"
if (-not (Test-Path $imagesDir)) {
return
}
Write-Info "📦 检查并加载基础镜像..."
$loadedCount = 0
Get-ChildItem $imagesDir -Directory -ErrorAction SilentlyContinue | ForEach-Object {
$imageDir = $_.FullName
Get-ChildItem $imageDir -Filter "*.tar" -ErrorAction SilentlyContinue | ForEach-Object {
$tarFile = $_.FullName
$result = docker load -i $tarFile 2>&1
if ($LASTEXITCODE -eq 0) {
$loadedImage = $result | Select-String -Pattern "Loaded image: (.+)" | ForEach-Object { $_.Matches[0].Groups[1].Value }
if ($loadedImage) {
Write-Info " ✓ 已加载: $loadedImage"
$loadedCount++
}
}
$process = Start-Process -FilePath "scp" -ArgumentList ($scpArgs + @($LocalPath, $remoteTarget)) -NoNewWindow -PassThru -Wait -RedirectStandardOutput $stdoutFile -RedirectStandardError $stderrFile
if ($process.ExitCode -eq 0) {
Write-Success "文件上传成功"
Remove-Item $stdoutFile, $stderrFile -ErrorAction SilentlyContinue
return $true
} else {
$errorOutput = Get-Content $stderrFile -ErrorAction SilentlyContinue
$lastError = $errorOutput
Write-Warning "文件上传失败 (尝试 $attempt/$MaxRetries): $errorOutput"
}
} catch {
$lastError = $_.Exception.Message
Write-Warning "上传异常 (尝试 $attempt/$MaxRetries): $_"
} finally {
Remove-Item $stdoutFile, $stderrFile -ErrorAction SilentlyContinue
}
}
Write-ErrorColored "文件上传失败,已重试 $MaxRetries 次。最后错误: $lastError"
return $false
if ($loadedCount -gt 0) {
Write-Success "已加载 $loadedCount 个基础镜像"
}
}
# 主函数
@ -589,6 +658,13 @@ function Main {
Write-ColorOutput " 新建简化部署脚本 v1.1" "Magenta"
Write-ColorOutput "========================================" "Magenta"
# 加载基础镜像(在构建前)
if (-not $SkipBuild) {
$projectRoot = if ($PSScriptRoot) { Split-Path $PSScriptRoot -Parent } else { Get-Location }
Load-BaseImages -ProjectRoot $projectRoot
Write-ColorOutput ""
}
# 显示配置
Write-Info "部署配置:"
Write-Info " 服务器: $Server"
@ -599,27 +675,9 @@ function Main {
Write-Info " 跳过上传: $SkipUpload"
Write-ColorOutput ""
# 预留开关与调试信息(用于消除“未使用”告警,同时保留扩展能力)
if ($Force) {
Write-Warning "已启用 Force 模式:将尽量不中断继续执行(当前为预留开关,不改变实际行为)"
}
# 调试模式
if ($Debug) {
Write-Info "调试模式已启用:将输出额外的诊断信息"
# 轻量调用 PuTTY 探测函数,仅用于信息输出和保留扩展点
$puttyPath = Find-PuTTYTools
if ($puttyPath) {
Write-Info "检测到 PuTTY 工具路径: $puttyPath"
} else {
Write-Info "未检测到 PuTTY 工具,将优先使用 OpenSSH (ssh/scp)"
}
# 利用 LocalPath 和 Upload-File 作为预留扩展点(当前不实际上传)
if ($LocalPath) {
Write-Info "检测到 LocalPath 参数(当前仅作为函数占位使用,不会自动上传文件)"
if ($false) {
# 这段代码永远不会被执行,仅用于向分析器声明 Upload-File 的调用关系
Upload-File -LocalPath $LocalPath -RemotePath $RemotePath -Server $Server -Username $Username -KeyPath $KeyPath | Out-Null
}
}
Write-Info "🐛 调试模式已启用:将输出额外的诊断信息"
}
$willCheck = -not $SkipCheck
@ -648,7 +706,7 @@ function Main {
while ($waited -lt $maxWait) {
Start-Sleep -Seconds 2
$waited += 2
$testResult = docker ps 2>&1
docker ps 2>&1 | Out-Null
if ($LASTEXITCODE -eq 0) {
$dockerReady = $true
Write-Success "Docker 已就绪"
@ -826,14 +884,15 @@ function Main {
}
}
$sshTest = Test-SSHConnection -Server $Server -Username $Username -Password $Password -KeyPath $KeyPath -ForcePuTTY:$ForcePuTTY
$sshTest = Test-SSHConnection -Server $Server -Username $Username -Password $Password -KeyPath $KeyPath
if (-not $sshTest) {
Write-Warning "SSH连接测试失败,将跳过自动部署"
Write-Info "请手动在服务器上执行部署命令"
} else {
# 在服务器上拉取镜像并部署(带重试机制)
Write-Info "在服务器上拉取镜像并部署..."
$deployCmd = "cd $RemotePath && docker pull $registryImage && docker tag $registryImage $fullImage && docker-compose up -d"
# 先停止并删除旧容器,然后拉取新镜像,重新启动(使用 --no-deps --pull never 避免拉取依赖服务和重复拉取)
$deployCmd = "cd $RemotePath && docker-compose stop app && docker-compose rm -f app && docker pull $registryImage && docker tag $registryImage $fullImage && docker-compose up -d --no-deps --pull never app"
$sshArgs = @("-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=30", "-o", "ServerAliveInterval=10", "-o", "ServerAliveCountMax=3")
if ($KeyPath -and (Test-Path $KeyPath)) {
$sshArgs += @("-i", $KeyPath)
@ -893,23 +952,71 @@ function Main {
}
}
# 清理旧镜像(如果启用)
if ($CleanImages) {
Write-ColorOutput "" "Magenta"
Write-ColorOutput "========================================" "Magenta"
Write-ColorOutput " 清理旧镜像阶段" "Magenta"
Write-ColorOutput "========================================" "Magenta"
# 清理本地镜像
Clean-LocalDockerImages -ImageName $ImageName -KeepTag $Tag -KeepCount $KeepImageCount
# 清理服务器镜像
if ($sshTest -and $sshArgs) {
Clean-RemoteDockerImages -Server $Server -Username $Username -KeyPath $KeyPath -ImageName $ImageName -KeepCount $KeepImageCount
}
# 清理 Registry 镜像
$registryUrl = "http://${Server}:5000"
Clean-RegistryImages -RegistryUrl $registryUrl -ImageName "youyidian/$ImageName" -KeepCount $KeepImageCount
# 清理 flnxi-sucai 镜像(完全删除)
Clean-RegistryImages -RegistryUrl $registryUrl -ImageName "youyidian/flnxi-sucai" -KeepCount 0
}
Write-ColorOutput "" "Magenta"
Write-ColorOutput "========================================" "Magenta"
Write-ColorOutput " 部署完成!" "Magenta"
Write-ColorOutput " 🎉 部署完成!" "Magenta"
Write-ColorOutput "========================================" "Magenta"
Write-Success "所有阶段执行成功"
Write-Info "镜像: $fullImage"
Write-Info "服务器: ${Username}@${Server}"
Write-Info "远程路径: $RemotePath"
# 显示访问信息
Write-ColorOutput "" "Cyan"
Write-ColorOutput "🌐 访问地址:" "Cyan"
Write-ColorOutput " http://${Server}:4320" "Yellow"
Write-ColorOutput " http://${Server}:4320/admin" "Yellow"
if ($CleanImages) {
Write-Info "已启用镜像清理,保留了最新 $KeepImageCount 个版本"
} else {
Write-Info "💡 提示: 使用 -CleanImages 参数可以自动清理旧版本镜像"
}
if (-not $SkipUpload) {
Write-Info "镜像已推送到 Registry: ${Server}:5000/youyidian/${ImageName}:${Tag}"
Write-Info "如果自动部署失败,请在服务器上手动执行:"
Write-Info " cd $RemotePath"
Write-Info " docker-compose stop app && docker-compose rm -f app"
Write-Info " docker pull ${Server}:5000/youyidian/${ImageName}:${Tag}"
Write-Info " docker tag ${Server}:5000/youyidian/${ImageName}:${Tag} $fullImage"
Write-Info " docker-compose up -d"
Write-Info " docker-compose up -d --no-deps --pull never app"
Write-Info " # 如需执行数据库迁移,请手动运行: docker exec youyidian-3d-material npx prisma migrate deploy"
}
# 成功后的趣味提示
$successTips = @(
"🎊 恭喜!由壹点3D素材已成功部署!",
"🌟 现在可以去喝杯咖啡,享受部署成功的喜悦~",
"🎯 部署完成度:100% | 心情指数:💯",
"✨ 代码已安全抵达生产环境,准备接受用户检阅!",
"🎉 部署成功!由壹点3D素材已整装待发!"
)
$successTip = Get-Random -InputObject $successTips
Write-ColorOutput "" "Green"
Write-ColorOutput $successTip "Green"
}
# 执行主函数

3
docker-images/.gitkeep Normal file
View File

@ -0,0 +1,3 @@
# 此文件用于保持目录结构
# 实际的镜像文件 (.tar) 已被 .gitignore 排除

67
docker-images/README.md Normal file
View File

@ -0,0 +1,67 @@
# Docker 基础镜像仓库
本目录用于存储项目所需的基础 Docker 镜像,避免每次部署时从网络下载。
## 目录结构
```
docker-images/
├── README.md # 本文件
├── minio-mc/ # MinIO Client 镜像
│ ├── minio-mc-latest.tar # 镜像文件
│ └── VERSION.txt # 版本信息
├── postgres/ # PostgreSQL 镜像(如需要)
└── minio/ # MinIO Server 镜像(如需要)
```
## 使用方法
### 1. 加载镜像到本地 Docker
```powershell
# Windows PowerShell
docker load -i docker-images\minio-mc\minio-mc-latest.tar
# Linux/Mac
docker load -i docker-images/minio-mc/minio-mc-latest.tar
```
### 2. 在部署脚本中使用
部署脚本会自动检查本地镜像,如果存在则优先使用,否则从网络下载。
### 3. 更新镜像
当需要更新镜像时:
```powershell
# 1. 拉取最新镜像
docker pull minio/mc:latest
# 2. 保存到项目目录
docker save minio/mc:latest -o docker-images\minio-mc\minio-mc-latest.tar
# 3. 更新版本信息
echo "latest - $(Get-Date -Format 'yyyy-MM-dd')" > docker-images\minio-mc\VERSION.txt
```
## 镜像列表
| 镜像名称 | 版本 | 文件路径 | 大小 | 更新时间 |
|---------|------|---------|------|---------|
| minio/mc | latest | `minio-mc/minio-mc-latest.tar` | ~85MB | 2025-11-23 |
## 注意事项
1. **版本管理**:每个镜像目录应包含 `VERSION.txt` 文件,记录版本和更新时间
2. **文件大小**:镜像文件较大,建议使用 `.gitignore` 排除,或使用 Git LFS
3. **定期更新**:定期检查并更新基础镜像,确保安全性和兼容性
4. **备份**:重要镜像建议备份到其他位置
## 添加新镜像
1. 创建镜像目录:`mkdir docker-images/<镜像名>`
2. 拉取并保存镜像:`docker pull <镜像>:<标签> && docker save <镜像>:<标签> -o docker-images/<镜像名>/<文件名>.tar`
3. 创建版本文件:`echo "<版本> - $(Get-Date -Format 'yyyy-MM-dd')" > docker-images/<镜像名>/VERSION.txt`
4. 更新本 README 的镜像列表

View File

@ -0,0 +1,5 @@
latest - 2025-11-23
Image: minio/mc:latest
Digest: sha256:a7fe349ef4bd8521fb8497f55c6042871b2ae640607cf99d9bede5e9bdf11727
Size: ~85MB

View File

@ -0,0 +1,140 @@
# 字段分类功能更新
## ✅ 已完成的修改
### 1. 标准字段模板更新
已将以下字段设置为**下拉选择类型**并**默认勾选"添加到分类"**:
| 字段键名 | 字段标签 | 类型 | 添加到分类 | 默认选项 |
|---------|---------|------|-----------|---------|
| `materialType` | 素材类型 | select | ✅ | 默认类型 |
| `categoryName` | 使用店铺 | select | ✅ | 默认店铺 |
| `sceneName` | 场景类别 | select | ✅ | 默认场景 |
| `platformName` | 平台分类 | select | ✅ | 默认平台 |
| `renderer` | 渲染器 | select | ✅ | 默认渲染器 |
| `brandName` | 品牌名称 | select | ✅ | 默认品牌 |
| `seriesName` | 系列/产品线 | select | ✅ | 默认产品线 |
| `materialTexture` | 材质/工艺 | select | ✅ | 默认材质 |
### 2. 默认选项配置
每个勾选"添加到分类"的下拉选择字段都包含一个默认选项:
- **素材类型**: `默认类型` + 原有选项(3DMAX、PSD、AI等)
- **使用店铺**: `默认店铺`
- **场景类别**: `默认场景`
- **平台分类**: `默认平台`
- **渲染器**: `默认渲染器`
- **品牌名称**: `默认品牌`
- **产品线**: `默认产品线`
- **材质工艺**: `默认材质`
## 📋 分类管理卡片显示说明
### 为什么分类管理卡片可能没有内容?
分类管理卡片显示的是**所有启用模板中勾选了"添加到分类"的 select 类型字段**。
如果卡片为空,可能的原因:
1. **没有启用的模板**
- 需要创建模板并设置为"启用"状态
2. **模板中的字段没有勾选"添加到分类"**
- 如果使用的是旧模板,需要重新编辑模板,确保字段勾选了"添加到分类"
3. **字段类型不是 select**
- 确保字段类型是"下拉选择"
### 如何让分类管理卡片显示内容?
#### 方法 1: 创建新模板(推荐)
1. 进入**模板管理**
2. 点击**创建模板**
3. 使用**标准字段**添加以下字段:
- 素材类型
- 使用店铺
- 场景类别
- 平台分类
- 渲染器
- 品牌名称
- 系列/产品线
- 材质/工艺
4. 这些字段会自动:
- 类型设置为"下拉选择"
- 勾选"添加到分类"
- 包含默认选项
5. 保存模板并设置为**启用**状态
6. 进入**分类管理**,应该能看到对应的卡片
#### 方法 2: 编辑现有模板
1. 进入**模板管理**
2. 编辑现有模板
3. 找到需要添加到分类的字段
4. 确保:
- 字段类型为"下拉选择"
- 勾选"添加到分类"复选框
- 至少有一个选项(建议包含默认选项)
5. 保存模板并确保模板状态为**启用**
## 🔍 验证步骤
1. **检查模板状态**
- 进入模板管理
- 确认至少有一个模板状态为"启用"
2. **检查字段配置**
- 打开启用的模板
- 确认有字段类型为"下拉选择"
- 确认这些字段勾选了"添加到分类"
- 确认字段有选项(optionsStatic 不为空)
3. **检查分类管理**
- 进入系统设置 → 分类管理
- 应该能看到"模板分类"卡片
- 每个勾选了"添加到分类"的字段都会显示一个卡片
## 📝 注意事项
1. **默认选项位置**
- 默认选项会出现在选项列表的第一位
- 例如:`['默认类型', '3DMAX', 'PSD', ...]`
2. **选项管理**
- 可以在分类管理中编辑选项
- 编辑后,所有使用该字段的模板都会同步更新
3. **字段键名唯一性**
- 相同 `fieldKey` 的字段会合并到一个分类卡片
- 例如:多个模板都有 `categoryName` 字段,只会显示一个"使用店铺"卡片
4. **模板状态**
- 只有**启用**状态的模板中的字段才会出现在分类管理中
- 草稿或归档状态的模板不会显示
## 🎯 预期效果
修改完成后,当您:
1. **创建新模板**并使用标准字段时:
- 这些字段会自动配置为下拉选择
- 自动勾选"添加到分类"
- 包含默认选项
2. **查看分类管理**时:
- 能看到所有勾选了"添加到分类"的字段卡片
- 每个卡片显示字段名称和选项列表
- 可以编辑选项,同步更新所有模板
3. **上传素材**时:
- 可以选择这些分类字段的值
- 默认选项会出现在下拉列表的第一位
---
**更新日期**: 2025-11-23
**状态**: ✅ 已完成

View File

@ -0,0 +1,91 @@
# MinIO Init 版本说明
## 当前配置
在 `docker-compose.prod.yml` 中,`minio-init` 服务使用:
```yaml
minio-init:
image: minio/mc:latest
```
## 版本兼容性
`minio/mc` (MinIO Client) 与 `minio/minio` 服务器版本兼容性:
- **MinIO Server**: `minio/minio:RELEASE.2025-04-22T22-12-26Z`
- **MinIO Client**: `minio/mc:latest` 或任何较新的版本都可以使用
## 推荐版本
### 选项 1: 使用 latest(推荐)
```yaml
image: minio/mc:latest
```
- ✅ 自动获取最新版本
- ✅ 兼容性好
- ⚠️ 需要网络连接拉取
### 选项 2: 使用特定版本(稳定性优先)
```yaml
image: minio/mc:RELEASE.2024-11-20T18-20-07Z
```
- ✅ 版本固定,稳定性高
- ✅ 可重现部署
- ⚠️ 需要手动指定版本号
## 手动下载镜像
如果服务器无法访问 Docker Hub,可以手动下载镜像:
### 方法 1: 在本地下载后导出
```bash
# 在本地(能访问外网的地方)
docker pull minio/mc:latest
docker save minio/mc:latest -o minio-mc-latest.tar
# 传输到服务器
scp minio-mc-latest.tar root@45.249.208.118:/root/
# 在服务器上导入
ssh root@45.249.208.118
docker load -i /root/minio-mc-latest.tar
```
### 方法 2: 使用国内镜像源
```bash
# 在服务器上配置 Docker 镜像加速器后
docker pull minio/mc:latest
```
### 方法 3: 使用阿里云镜像仓库
```bash
# 从阿里云镜像仓库拉取
docker pull registry.cn-hangzhou.aliyuncs.com/acs/minio-mc:latest
docker tag registry.cn-hangzhou.aliyuncs.com/acs/minio-mc:latest minio/mc:latest
```
## 验证镜像
下载后验证镜像:
```bash
docker images | grep minio/mc
docker run --rm minio/mc --version
```
## 更新 docker-compose.yml
如果使用特定版本,更新 `docker-compose.yml`:
```yaml
minio-init:
image: minio/mc:RELEASE.2024-11-20T18-20-07Z # 替换为实际版本
# ... 其他配置
```
## 注意事项
1. `minio/mc` 客户端版本通常向后兼容,使用 `latest` 通常是安全的
2. 如果追求稳定性,建议使用与 MinIO Server 发布时间相近的版本
3. 确保镜像已下载后再启动 `minio-init` 服务

View File

@ -0,0 +1,142 @@
# 模板功能改进说明
## ✅ 已完成的修改
### 1. 预制模板隐藏状态同步到首页
**问题**:预制模板隐藏后,首页的 tag 不会一起隐藏
**解决方案**:
- 将隐藏状态从 `localStorage` 迁移到数据库 `SystemSettings` 表
- 新增 `hiddenBuiltinTemplates` 字段(String 类型,存储 JSON 数组)
- 修改 `/api/templates` 和 `/api/upload-templates` API,读取隐藏状态并过滤
- 修改 `TemplateManager` 组件,从数据库读取和保存隐藏状态
**修改文件**:
- `prisma/schema.prisma` - 添加 `hiddenBuiltinTemplates` 字段
- `app/api/system-settings/route.ts` - 支持读取和更新隐藏状态
- `app/api/templates/route.ts` - 过滤隐藏的预制模板
- `app/api/upload-templates/route.ts` - 过滤隐藏的预制模板
- `app/admin/_components/TemplateManager.tsx` - 从数据库读取/保存隐藏状态
**数据库迁移**:
⚠️ **重要说明**:数据库迁移只需要执行**一次**(添加 `hiddenBuiltinTemplates` 字段),之后隐藏/显示预制模板的操作**不需要**再次迁移。
- **第一次**:运行迁移添加字段(已完成)
```bash
npx prisma db push # 已执行
```
- **之后的操作**:隐藏/显示预制模板只是更新 `SystemSettings` 表中的数据值,不是修改数据库结构,所以**不需要迁移**。这就像更新其他系统设置(如站点名称、Logo等)一样,只是数据更新,不是结构修改。
---
### 2. 预览模板样式同步到上传素材表单
**问题**:预览面板和实际上传表单的样式不一致
**解决方案**:
- 将预览面板的样式类应用到上传表单
- 统一使用 `rounded-2xl`、渐变背景、阴影等样式
- 统一必填字段的标识样式(紫色渐变标签)
**修改文件**:
- `components/UploadModal.tsx` - 同步预览面板的样式类
**样式改进**:
- 输入框:`rounded-2xl`、渐变背景、阴影效果
- 必填标签:紫色渐变标签(`from-purple-500 to-indigo-600`)
- 标签样式:`text-xs font-semibold`、`tracking-tight`
- 过渡动画:`transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]`
---
### 3. 自定义模板中隐藏"素材类型"字段
**问题**:自定义模板的目的是灵活应对不同客户群体,不需要"素材类型"字段
**解决方案**:
- 在保存自定义模板时,自动过滤掉 `materialType` 字段
- 在 API 返回自定义模板时,过滤掉 `materialType` 字段
- 在上传表单渲染时,过滤掉 `materialType` 字段
- 预制模板仍然保留 `materialType` 字段(用于兼容老系统)
**修改文件**:
- `app/admin/_components/TemplateManager.tsx` - 保存时过滤 `materialType`
- `app/api/upload-templates/route.ts` - 返回时过滤 `materialType`
- `components/UploadModal.tsx` - 渲染时过滤 `materialType`
**逻辑说明**:
- 预制模板:保留 `materialType` 字段(用于兼容老系统数据)
- 自定义模板:自动隐藏 `materialType` 字段(更灵活)
---
## 📋 数据库迁移
### ⚠️ 重要说明:迁移只需要执行一次
**数据库迁移只需要执行一次**(添加 `hiddenBuiltinTemplates` 字段),之后隐藏/显示预制模板的操作**不需要**再次迁移。
### 迁移内容
在 `SystemSettings` 表中添加:
- `hiddenBuiltinTemplates` (String?, nullable) - 存储隐藏的预制模板键名数组(JSON 格式)
### 迁移状态
✅ **已完成**:已通过 `npx prisma db push` 添加字段到数据库
### 工作原理
1. **第一次**:添加 `hiddenBuiltinTemplates` 字段到 `SystemSettings` 表(结构修改,需要迁移)
2. **之后**:隐藏/显示预制模板时,只是更新这个字段的值(数据更新,**不需要迁移**)
- 调用 `/api/system-settings` PUT 接口
- 更新 `hiddenBuiltinTemplates` 字段的 JSON 值
- 就像更新其他系统设置一样,只是数据更新,不是结构修改
**这完全符合项目主旨**:字段已经存在,只是在使用它来存储配置数据。
---
## 🎯 功能验证
### 1. 验证预制模板隐藏同步
1. 进入模板管理
2. 隐藏一个预制模板
3. 刷新首页
4. 检查首页的 tag 是否也隐藏了
### 2. 验证样式同步
1. 进入模板管理
2. 预览一个模板
3. 打开上传素材表单
4. 选择相同的模板
5. 对比样式是否一致
### 3. 验证素材类型字段隐藏
1. 创建一个自定义模板
2. 添加字段(包括素材类型)
3. 保存模板
4. 在上传素材时选择该模板
5. 检查是否看不到"素材类型"字段
---
## ⚠️ 注意事项
1. **数据库迁移**:✅ 已完成,只需要执行一次(添加字段)。之后隐藏/显示预制模板的操作只是更新数据,**不需要再次迁移**。
2. **向后兼容**:预制模板仍然包含 `materialType` 字段,不影响老系统
3. **样式一致性**:预览和实际上传表单现在使用相同的样式类
4. **数据更新 vs 结构修改**:
- 隐藏/显示预制模板 = **数据更新**(更新 `hiddenBuiltinTemplates` 字段的值)
- 不需要数据库迁移,就像更新站点名称、Logo 等系统设置一样
---
**更新日期**: 2025-11-23
**状态**: ✅ 已完成

223
docs/TEST_CHECKLIST.md Normal file
View File

@ -0,0 +1,223 @@
# 测试清单 - 模板编辑器 addToCategory 功能
## 📋 修改概览
### 1. **TemplateManager.tsx** - 核心逻辑修复
- ✅ 确保 `addToCategory` 字段在字段类型改为 `select` 时被正确初始化
- ✅ 在保存模板时,确保 `addToCategory` 始终包含在 payload 中
- ✅ 添加调试日志,便于追踪问题
### 2. **TemplateFieldsEditor.tsx** - UI 显示修复
- ✅ 确保当字段类型为 `select` 时,"添加到分类"复选框正确显示
- ✅ 复选框状态与字段数据正确同步
### 3. **next.config.js** - 缓存控制
- ✅ 禁用静态文件缓存,强制浏览器重新加载新代码
### 4. **app/layout.tsx** - 版本检查
- ✅ 添加版本检查脚本,在页面加载时显示版本号和红色横幅
- ✅ 用于验证新代码是否已加载
---
## 🧪 测试步骤
### 测试 1: 验证新代码已加载
**步骤:**
1. 打开浏览器,访问应用首页
2. 打开浏览器控制台(F12)
3. 检查是否有以下输出:
```
🚀🚀🚀 [RootLayout] 应用版本: 2025-11-23-v6-xxxxx
🚀🚀🚀 [RootLayout] 加载时间: xxxxx
```
4. 检查页面左上角是否出现红色横幅(显示版本号,5秒后消失)
**预期结果:**
- ✅ 控制台显示版本信息
- ✅ 页面显示红色版本横幅
- ✅ 如果看不到,说明浏览器缓存了旧代码,需要强制刷新(Ctrl+Shift+R 或 Ctrl+F5)
---
### 测试 2: 验证"添加到分类"复选框显示
**步骤:**
1. 登录管理后台
2. 进入模板管理页面(`/admin/templates`)
3. 创建一个新模板或编辑现有模板
4. 添加一个新字段,或编辑现有字段
5. 将字段类型改为 `select`(下拉选择)
**预期结果:**
- ✅ 当字段类型为 `select` 时,"添加到分类"复选框应该显示
- ✅ 复选框应该可以正常勾选/取消勾选
- ✅ 复选框状态应该正确保存
**检查点:**
- 打开浏览器控制台,应该看到类似日志:
```
[TemplateFieldsEditor] 🔍 字段 X (字段名) 是 select 类型,isSelectType=true,应该显示复选框
```
---
### 测试 3: 验证字段类型切换时 addToCategory 的处理
**步骤:**
1. 在模板编辑器中,选择一个字段
2. 将该字段的类型从其他类型(如 `text`)改为 `select`
3. 观察"添加到分类"复选框是否出现
4. 勾选"添加到分类"复选框
5. 将字段类型改回 `text`(复选框应该消失)
6. 再次将类型改为 `select`
**预期结果:**
- ✅ 当类型改为 `select` 时,复选框立即出现
- ✅ 如果之前勾选过,状态应该被保留
- ✅ 如果之前未勾选,默认应该是未勾选状态
**检查点:**
- 控制台应该看到:
```
[TemplateManager] ✅ 字段类型改为 select,确保 addToCategory 存在: {...}
```
---
### 测试 4: 验证保存时 addToCategory 包含在 payload 中
**步骤:**
1. 创建一个新模板
2. 添加一个字段,类型设为 `select`
3. 勾选"添加到分类"复选框
4. 保存模板
5. 打开浏览器控制台,查看网络请求
**预期结果:**
- ✅ 保存请求的 payload 中,该字段应该包含 `addToCategory: true`
- ✅ 控制台应该看到详细的日志:
```
[TemplateManager] 🔍 处理字段 X: { key: 'xxx', type: 'select', addToCategory: true }
[TemplateManager] 🔍 强制验证后 payload.fields: [...]
```
**检查点:**
- 在 Network 标签中,找到保存模板的请求
- 查看请求体,确认 `fields` 数组中,`select` 类型的字段都有 `addToCategory` 字段
---
### 测试 5: 验证已保存模板的加载
**步骤:**
1. 编辑一个已保存的模板(包含 `select` 类型字段)
2. 检查"添加到分类"复选框是否正确显示
3. 检查复选框状态是否与保存的数据一致
**预期结果:**
- ✅ 复选框正确显示
- ✅ 复选框状态与数据库中的数据一致
---
### 测试 6: 验证缓存清除
**步骤:**
1. 如果测试 1 中看不到版本横幅,执行强制刷新:
- **Chrome/Edge**: `Ctrl + Shift + R` 或 `Ctrl + F5`
- **Firefox**: `Ctrl + Shift + R` 或 `Ctrl + F5`
- **Safari**: `Cmd + Shift + R`
2. 或者清除浏览器缓存:
- 打开开发者工具(F12)
- 右键点击刷新按钮
- 选择"清空缓存并硬性重新加载"
**预期结果:**
- ✅ 强制刷新后,应该能看到版本横幅和控制台日志
---
## 🔍 调试信息
### 全局变量检查
在浏览器控制台执行以下命令,检查组件是否已加载:
```javascript
// 检查应用版本
console.log('应用版本:', window.__APP_VERSION__);
console.log('加载时间:', window.__APP_LOADED_AT__);
// 检查模板管理器是否加载
console.log('模板管理器已加载:', window.__TEMPLATE_MANAGER_LOADED__);
// 检查字段编辑器是否加载
console.log('字段编辑器已加载:', window.__TEMPLATE_FIELDS_EDITOR_LOADED__);
```
### 关键日志位置
1. **TemplateManager.tsx**:
- `[TemplateManager] updateField called:` - 字段更新时
- `[TemplateManager] ✅ 字段类型改为 select` - 类型切换时
- `[TemplateManager] 🚀 handleSave 开始执行` - 保存开始时
- `[TemplateManager] 🔍 处理字段 X` - 处理每个字段时
2. **TemplateFieldsEditor.tsx**:
- `[TemplateFieldsEditor] 🚀 组件开始渲染` - 组件渲染时
- `[TemplateFieldsEditor] 🔍 字段 X 是 select 类型` - 字段类型检查时
- `[TemplateFieldsEditor] ✅ addToCategory changed` - 复选框状态改变时
---
## ⚠️ 常见问题
### 问题 1: 看不到版本横幅
**原因**: 浏览器缓存了旧代码
**解决**: 执行强制刷新(Ctrl+Shift+R)或清除缓存
### 问题 2: "添加到分类"复选框不显示
**原因**:
- 字段类型不是 `select`
- 组件未正确加载
**解决**:
- 检查字段类型是否为 `select`
- 检查控制台是否有错误日志
- 检查 `window.__TEMPLATE_FIELDS_EDITOR_LOADED__` 是否为 `true`
### 问题 3: 保存后 addToCategory 丢失
**原因**: payload 构建时未包含该字段
**解决**:
- 检查控制台日志,查看 payload 构建过程
- 确认字段类型为 `select` 时,`addToCategory` 是否被正确添加
---
## ✅ 测试通过标准
所有测试通过后,应该满足:
1. ✅ 版本检查脚本正常工作
2. ✅ "添加到分类"复选框在 `select` 类型字段时正确显示
3. ✅ 字段类型切换时,`addToCategory` 状态正确处理
4. ✅ 保存模板时,`addToCategory` 正确包含在 payload 中
5. ✅ 已保存模板加载时,复选框状态正确恢复
---
## 📝 测试记录
请在测试时记录:
- [ ] 测试 1: 版本检查
- [ ] 测试 2: 复选框显示
- [ ] 测试 3: 类型切换
- [ ] 测试 4: 保存 payload
- [ ] 测试 5: 已保存模板加载
- [ ] 测试 6: 缓存清除
**测试日期**: _______________
**测试人员**: _______________
**测试结果**: _______________
**发现问题**: _______________

View File

@ -0,0 +1,110 @@
# 部署脚本优化说明
## 📊 优化统计
- **优化前**: 1142 行
- **优化后**: 946 行
- **减少**: 196 行(约 17%)
## ✅ 已修复的问题
### 1. Linter 错误修复(12个 → 6个警告)
#### 已修复:
- ✅ Password 参数类型改为 `SecureString`(安全性提升)
- ✅ 删除未使用的变量 `danglingResult`、`testResult`
- ✅ 删除未使用的函数 `Upload-File`(约 70 行)
- ✅ 删除未使用的函数 `Find-PuTTYTools`
- ✅ 删除未使用的参数 `LocalPath`、`ForcePuTTY`、`Force`
#### 保留的警告(可接受):
- ⚠️ 6个未批准动词警告(内部函数,不影响功能)
## 🎨 增强的趣味性
### 新增趣味提示(6条):
- ✨ 代码正在变身,从开发模式切换到生产模式~
- 🎨 由壹点3D素材正在打包,准备惊艳登场!
- ⚡ 部署速度:光速模式已启动
- 🎪 部署马戏团正在表演,请保持期待~
- 🌙 夜深了?没关系,部署脚本陪你到天亮
- 🎁 部署完成后,记得给自己一个小奖励!
### 成功提示增强:
- 🎊 恭喜!由壹点3D素材已成功部署!
- 🌟 现在可以去喝杯咖啡,享受部署成功的喜悦~
- 🎯 部署完成度:100% | 心情指数:💯
- ✨ 代码已安全抵达生产环境,准备接受用户检阅!
### 访问信息显示:
- 🌐 自动显示访问地址(首页和管理后台)
## 🗑️ 删除的功能
### 已删除(未使用):
1. **Upload-File 函数** - 约 70 行,从未被调用
2. **Find-PuTTYTools 函数** - PuTTY 支持,项目主要使用 OpenSSH
3. **LocalPath 参数** - 预留功能,未实际使用
4. **ForcePuTTY 参数** - 简化 SSH 连接逻辑
5. **Force 参数** - 预留开关,未实际使用
6. **Password 字符串参数** - 改为 SecureString 提升安全性
## 📝 保留的核心功能
### ✅ 必需功能:
- SSH 密钥认证
- Docker 构建
- 镜像推送到 Registry
- 远程部署
- 健康检查
- 基础镜像自动加载
- 镜像清理(可选)
### ✅ 增强功能:
- 智能重试机制
- 进度条显示
- 趣味提示系统
- 详细的错误处理
## 🎯 符合项目需求
### 由壹点3D素材项目特点:
- ✅ 使用 SSH 密钥认证(主要方式)
- ✅ 推送到私有 Registry(45.249.208.118:5000)
- ✅ 自动加载基础镜像(minio/mc 等)
- ✅ 支持镜像清理(节省空间)
- ✅ 显示访问地址(4320 端口)
## 📋 使用示例
### 基本部署:
```powershell
.\deploy-new-fixed.ps1
```
### 带镜像清理:
```powershell
.\deploy-new-fixed.ps1 -CleanImages -KeepImageCount 5
```
### 跳过检查:
```powershell
.\deploy-new-fixed.ps1 -SkipCheck
```
### 仅构建不部署:
```powershell
.\deploy-new-fixed.ps1 -SkipUpload
```
## 🔧 技术改进
1. **安全性**:Password 参数使用 SecureString
2. **可维护性**:删除未使用代码,减少维护成本
3. **可读性**:添加注释说明内部函数用途
4. **用户体验**:增强趣味提示和成功反馈
## ⚠️ 注意事项
剩余的 6 个警告是关于 PowerShell 未批准动词的,这些是内部函数,不影响功能。如果需要完全消除警告,可以将函数名改为批准动词(如 `Invoke-CelebrateStage`),但会降低代码可读性。

View File

@ -0,0 +1,160 @@
# 素材类型模板配置记录
本文档记录新版本内置素材模板(3DMAX、PSD、AI 工作流、视频、模特、其他)的字段配置,用于数据迁移和模板初始化。
## 📋 素材类型列表(新版本)
- 3DMAX
- PSD
- AI 工作流
- 视频
- 模特
- 其他
## 🔧 标准字段配置
### 所有素材类型通用字段
| 字段键名 | 字段标签 | 类型 | 必填 | 布局宽度 | 首页筛选 | 可搜索 | 说明 |
|---------|---------|------|------|---------|---------|--------|------|
| productModel | 型号 | text | ✅ | full | ❌ | ✅ | SPU型号,材质类型时作为材质名称 |
| categoryName | 使用店铺 | select | ❌ | full | ✅ | ✅ | 下拉选择门店,初始提供“默认店铺” |
| sceneName | 场景类别 | select | ❌ | full | ✅ | ✅ | 材质类型时使用默认值"通用" |
| platformName | 平台分类 | text | ❌ | half | ❌ | ✅ | 可选字段 |
| brandName | 品牌名称 | text | ❌ | half | ❌ | ✅ | 可选字段 |
| seriesName | 系列/产品线 | text | ❌ | half | ❌ | ✅ | 可选字段 |
| skuCode | SKU编码 | text | ❌ | half | ❌ | ✅ | 可选字段 |
| materialTexture | 材质/工艺 | text | ❌ | half | ❌ | ✅ | 可选字段 |
| renderer | 渲染器 | select | ❌ | full | ❌ | ✅ | 仅3DMAX使用,默认值"CR",下拉可选 |
| renderTime | 渲染耗时 | text | ❌ | half | ❌ | ❌ | 可选字段 |
| imageResolution | 输出分辨率 | text | ❌ | half | ❌ | ❌ | 可选字段 |
| colorMode | 颜色模式 | text | ❌ | half | ❌ | ❌ | 可选字段 |
| authorName | 作者 | text | ❌ | half | ❌ | ✅ | 自动从用户信息获取 |
> 说明:所有 select 类型字段在初始化时都会带一个默认选项(例如“默认店铺”“默认场景”“CR”),以便在没有手动配置分类时也能选择。
## 📦 各素材类型推荐字段配置
### 1. 3DMAX 素材模板
**模板名称**:3DMAX 素材模板(老版本简化字段)
**描述**:只保留 3DMAX 上传时真正需要填写的字段。
**字段列表**(按顺序):
1. productModel - SPU型号(必填,text,full)
2. categoryName - 使用店铺(必填,select,full,下拉)
3. sceneName - 场景类别(必填,select,full,下拉)
4. renderer - 渲染器(必填,select,full,下拉,默认值:CR)
**特殊要求**:
- 必须上传素材文件(.max 或压缩包)
- 必须上传预览图
---
### 2. PSD 素材模板
**模板名称**:PSD 素材模板(老版本简化字段)
**描述**:只保留店铺、场景以及 SPU 型号。
**字段列表**(按顺序):
1. productModel - SPU型号(必填,text,full)
2. categoryName - 使用店铺(必填,select,full,下拉)
3. sceneName - 场景类别(必填,select,full,下拉)
**特殊要求**:
- 必须上传素材文件(.psd / .tif / .tiff / .psb)
- 必须上传展示图片(displayImage)
- 必须上传预览图
---
### 3. AI 工作流模板
**字段列表**(按顺序):
1. productModel - SPU型号(必填,text,full)
2. categoryName - 使用店铺(必填,select,full,下拉)
3. sceneName - 场景类别(必填,select,full,下拉)
**特殊要求**:
- 必须上传源文件(.ai / .svg / 压缩包)
- 必须上传预览图
---
### 4. 视频 素材模板
**字段列表**(按顺序):
1. productModel - SPU型号(必填,text,full)
2. categoryName - 使用店铺(必填,select,full,下拉)
3. sceneName - 场景类别(必填,select,full,下拉)
**特殊要求**:
- 必须上传视频文件(.mp4 / .avi / .mov / .wmv / .flv / .mkv / .webm)
- 必须上传预览图(视频封面)
---
### 5. 模特 / 实拍 素材模板
**字段列表**(按顺序):
1. productModel - SPU型号(必填,text,full)
2. categoryName - 使用店铺(必填,select,full,下拉)
3. sceneName - 场景类别(必填,select,full,下拉)
**特殊要求**:
- 必须上传至少一张展示图片(modelImages)
- 素材文件可选(可以不传)
- 必须上传预览图
---
### 6. 其他 素材模板
**字段列表**(按顺序):
1. productModel - SPU型号(必填,text,full)
2. categoryName - 使用店铺(必填,select,full,下拉)
3. sceneName - 场景类别(必填,select,full,下拉)
**特殊要求**:
- 必须上传素材文件(压缩包)
- 必须上传预览图
---
## 🔄 数据迁移注意事项
1. **字段映射**:确保老版本素材字段能够映射到新模板的通用字段(productModel/categoryName/sceneName)。
2. **默认值处理**:
- 模板已绑定 `materialType` 元数据,无需再在字段中存储。
- 如果未维护分类/场景,保持“默认店铺”“默认场景”即可。
- 3DMAX 模板的 renderer 默认值为 "CR"。
3. **必填字段验证**:
- 所有模板至少需要 productModel。
- 模特模板可选素材压缩包,其余模板必须上传素材文件。
4. **文件要求**:
- PSD:必须上传展示图片 + 预览图。
- 模特:必须上传至少一张展示图片。
- 视频:必须上传视频文件与封面图。
## 📝 预制模板说明
本文件列出的 6 个素材模板已作为**内置预制模板**定义在 `app/admin/_components/TemplateManager.tsx` 的 `BUILTIN_TEMPLATES` 中,并通过 `materialType` 元数据直接绑定素材类型。
管理员可以在模板管理面板中直接使用这些预制模板创建新的模板,系统会自动填充对应的字段配置。
### 使用方式
1. 进入管理后台 → 模板管理
2. 在"内置模板"区域选择目标模板
3. 点击"使用此模板"按钮
4. 系统会自动创建对应的模板,包含所有预配置字段与 `materialType` 元数据
### 字段配置说明
所有预制模板都基于 `STANDARD_FIELD_TEMPLATES` 的标准字段配置,确保:
- 字段键名统一(如 `productModel`、`categoryName` 等)
- 字段类型和验证规则一致
- 布局宽度和可见性配置合理
- 与现有素材数据兼容

127
lib/category-fields.ts Normal file
View File

@ -0,0 +1,127 @@
/**
* 分类字段配置工具方法
* 用于读取/写入系统设置中「哪些字段需要同步到分类管理」的配置
*/
import { prisma } from '@/lib/prisma'
import { STANDARD_FIELDS } from './upload-template-presets'
import { ensureSystemSettingsOptionalColumns } from './system-settings-columns'
export const DEFAULT_MANAGED_CATEGORY_FIELDS = [
'categoryName',
'sceneName',
'platformName',
'renderer',
] as const
type CategorySettingsRow = {
id: string
categoryManagedFields: string | null
}
type CategoryFieldOption = {
key: string
label: string
type: string
source: 'standard' | 'custom'
}
const dedupeKeys = (keys: string[]) => Array.from(new Set(keys.filter(Boolean)))
function parseStoredFields(value?: string | null): string[] | null {
if (!value) return null
try {
const parsed = JSON.parse(value)
if (Array.isArray(parsed)) {
return dedupeKeys(parsed.map((item) => (typeof item === 'string' ? item : '')).filter(Boolean))
}
} catch {
return null
}
return null
}
async function ensureSystemSettingsId(): Promise<CategorySettingsRow> {
const existing = (await prisma.systemSettings.findFirst()) as unknown as CategorySettingsRow | null
if (existing) {
return existing
}
const created = (await prisma.systemSettings.create({ data: {} })) as unknown as CategorySettingsRow
return created
}
export async function getManagedCategoryFields(): Promise<string[]> {
await ensureSystemSettingsOptionalColumns()
const settings = (await prisma.systemSettings.findFirst()) as unknown as CategorySettingsRow | null
const availableKeys = new Set((await getAvailableCategoryFields()).map(field => field.key))
const coerceToAvailable = (keys: string[] | null | undefined) => {
if (!keys) return null
const filtered = keys.filter((key) => availableKeys.has(key))
return filtered
}
const parsed = parseStoredFields(settings?.categoryManagedFields)
const filteredParsed = coerceToAvailable(parsed)
if (filteredParsed && filteredParsed.length > 0) {
if (!parsed || filteredParsed.length !== parsed.length) {
await saveManagedCategoryFields(filteredParsed)
}
return filteredParsed
}
const filteredDefaults = DEFAULT_MANAGED_CATEGORY_FIELDS.filter((key) => availableKeys.has(key))
await saveManagedCategoryFields(filteredDefaults)
return filteredDefaults
}
export async function saveManagedCategoryFields(fieldKeys: string[]) {
await ensureSystemSettingsOptionalColumns()
const unique = dedupeKeys(fieldKeys)
const payload = JSON.stringify(unique)
const settings = await ensureSystemSettingsId()
await prisma.systemSettings.update({
where: { id: settings.id },
// Prisma 类型缓存未及时刷新,临时使用 any 以避免阻塞,可在下一次 generate 后移除
data: { categoryManagedFields: payload } as any,
})
return unique
}
export async function getAvailableCategoryFields(): Promise<CategoryFieldOption[]> {
await ensureSystemSettingsOptionalColumns()
const fieldMap = new Map<string, CategoryFieldOption>()
Object.values(STANDARD_FIELDS).forEach((field) => {
if (field.type === 'select') {
fieldMap.set(field.key, {
key: field.key,
label: field.label,
type: field.type,
source: 'standard',
})
}
})
const customFields = await prisma.uploadField.findMany({
where: { type: 'select' },
select: { key: true, label: true },
distinct: ['key'],
orderBy: { key: 'asc' },
})
customFields.forEach((field) => {
if (!field.key) return
if (!fieldMap.has(field.key)) {
fieldMap.set(field.key, {
key: field.key,
label: field.label || field.key,
type: 'select',
source: 'custom',
})
}
})
return Array.from(fieldMap.values()).sort((a, b) => a.key.localeCompare(b.key))
}

View File

@ -0,0 +1,20 @@
/**
* 工具方法:确保 system_settings 表的可选列已创建
* 兼容旧版本数据库缺少 enabledBuiltinTemplates / categoryManagedFields 的情况
*/
import { prisma } from '@/lib/prisma'
async function addColumnIfMissing(column: string) {
try {
await prisma.$executeRawUnsafe(`ALTER TABLE "system_settings" ADD COLUMN IF NOT EXISTS "${column}" TEXT`)
} catch (error) {
console.error(`[SystemSettings] Failed to ensure column ${column}:`, error)
}
}
export async function ensureSystemSettingsOptionalColumns() {
await addColumnIfMissing('enabledBuiltinTemplates')
await addColumnIfMissing('categoryManagedFields')
}

View File

@ -75,6 +75,10 @@ export const gradientBackgrounds = {
// 玻璃态
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',
// 毛玻璃半透明(用于顶部栏)
glassNav: 'bg-white/80 dark:bg-slate-900/80 backdrop-blur-xl border-b border-white/20 dark:border-slate-700/30',
glassNavLight: 'bg-white/70 backdrop-blur-xl border-b border-slate-200/50',
glassNavDark: 'bg-slate-900/70 backdrop-blur-xl border-b border-slate-700/50',
} as const
// 炫彩光晕效果
@ -166,3 +170,201 @@ export function conditionalAnimation(condition: boolean, animation: string, fall
return condition ? animation : fallback
}
// 苹果风格滚动动画
export const scrollAnimations = {
// 平滑滚动
smoothScroll: 'scroll-smooth',
// 滚动时淡入淡出
fadeOnScroll: 'transition-opacity duration-300 ease-out',
// 滚动时缩放
scaleOnScroll: 'transition-transform duration-300 ease-out',
// 滚动时位移
translateOnScroll: 'transition-transform duration-300 ease-out',
// 苹果风格的弹性动画
spring: 'transition-all duration-500 ease-[cubic-bezier(0.34,1.56,0.64,1)]',
springSmooth: 'transition-all duration-400 ease-[cubic-bezier(0.25,0.46,0.45,0.94)]',
// 滚动时隐藏/显示
hideOnScroll: 'transition-all duration-300 ease-out opacity-0 -translate-y-2',
showOnScroll: 'transition-all duration-300 ease-out opacity-100 translate-y-0',
// 苹果风格的弹性滚动(用于 scrollIntoView)
// 使用 CSS scroll-behavior: smooth 配合 JavaScript 实现弹性效果
elasticScroll: 'scroll-smooth',
} as const
// 平滑的弹性缓动函数(减少弹性效果,更柔和)
function easeOutElastic(t: number): number {
if (t === 0) return 0
if (t === 1) return 1
// 进一步减少弹性效果:降低振幅和频率
const c4 = (2 * Math.PI) / 3
// 使用更小的衰减系数和更低的频率,让动画更平滑
return Math.pow(2, -6 * t) * Math.sin((t * 4 - 0.3) * c4) + 1
}
// 苹果风格的缓入缓出函数(ease-in-out-cubic)
function easeInOutCubic(t: number): number {
return t < 0.5
? 4 * t * t * t
: 1 - Math.pow(-2 * t + 2, 3) / 2
}
// 苹果风格的弹性滚动函数(用于 JavaScript 调用)
export function scrollToElementWithSpring(
element: HTMLElement | null,
container: HTMLElement | null = null,
options: {
offset?: number
duration?: number
retryCount?: number
easing?: 'elastic' | 'ease-in-out'
} = {}
): void {
if (!element) return
const {
offset = 24,
duration = 600,
retryCount = 0,
easing = 'elastic',
} = options
// 如果没有提供容器,尝试查找滚动容器
let targetContainer = container || element.closest('[data-scroll-container]') as HTMLElement
// 如果仍然没有找到容器,使用页面级别的滚动(window)
if (!targetContainer) {
// 使用 window 作为滚动容器,需要特殊处理
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const elementRect = element.getBoundingClientRect()
const startScrollTop = window.pageYOffset || document.documentElement.scrollTop
const targetScrollTop = startScrollTop + elementRect.top - offset
const maxScrollTop = Math.max(0, document.documentElement.scrollHeight - window.innerHeight)
const clampedTargetScrollTop = Math.max(0, Math.min(targetScrollTop, maxScrollTop))
const scrollDifference = Math.abs(clampedTargetScrollTop - startScrollTop)
if (scrollDifference < 1) {
return
}
const startTime = performance.now()
const easingFunc = easing === 'ease-in-out' ? easeInOutCubic : easeOutElastic
const animate = (currentTime: number) => {
const elapsed = currentTime - startTime
const progress = Math.min(elapsed / duration, 1)
const eased = easingFunc(progress)
const currentScrollTop = startScrollTop + (clampedTargetScrollTop - startScrollTop) * eased
window.scrollTo(0, currentScrollTop)
if (progress < 1) {
requestAnimationFrame(animate)
}
}
requestAnimationFrame(animate)
})
})
return
}
// 使用双重 requestAnimationFrame 确保 DOM 完全更新
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const startScrollTop = targetContainer.scrollTop
// 使用 getBoundingClientRect 计算元素相对于容器的位置
const containerRect = targetContainer.getBoundingClientRect()
const elementRect = element.getBoundingClientRect()
// 元素相对于容器可视区域顶部的位置
const elementTopRelativeToViewport = elementRect.top
const containerTopRelativeToViewport = containerRect.top
const elementTopRelativeToContainer = elementTopRelativeToViewport - containerTopRelativeToViewport
// 计算目标滚动位置:当前滚动位置 + 元素在可视区域的位置 - offset
const targetScrollTop = startScrollTop + elementTopRelativeToContainer - offset
// 确保目标位置在有效范围内
const maxScrollTop = Math.max(0, targetContainer.scrollHeight - targetContainer.clientHeight)
console.log('[scrollToElementWithSpring] Scroll calculation:', {
startScrollTop,
elementTopRelativeToContainer,
targetScrollTop,
maxScrollTop,
scrollHeight: targetContainer.scrollHeight,
clientHeight: targetContainer.clientHeight,
offset,
retryCount
})
// 如果容器不可滚动(maxScrollTop === 0),但目标位置需要滚动(targetScrollTop > 0)
// 说明容器还没有完全渲染,需要延迟重试
if (maxScrollTop === 0 && targetScrollTop > 0 && retryCount < 5) {
console.log('[scrollToElementWithSpring] Container not ready, retrying...', { retryCount })
setTimeout(() => {
scrollToElementWithSpring(element, container, { ...options, retryCount: retryCount + 1 })
}, 150)
return
}
// 如果重试多次后仍然不可滚动,使用 scrollIntoView 作为后备
if (maxScrollTop === 0 && targetScrollTop > 0) {
console.log('[scrollToElementWithSpring] Container not scrollable after retries, using scrollIntoView fallback')
element.scrollIntoView({ behavior: 'smooth', block: 'start' })
return
}
const clampedTargetScrollTop = Math.max(0, Math.min(targetScrollTop, maxScrollTop))
// 如果已经在目标位置,跳过动画
const scrollDifference = Math.abs(clampedTargetScrollTop - startScrollTop)
if (scrollDifference < 1) {
console.log('[scrollToElementWithSpring] Already at target position, skipping animation')
return
}
console.log('[scrollToElementWithSpring] Starting animation', {
from: startScrollTop,
to: clampedTargetScrollTop,
difference: scrollDifference
})
// 开始弹性滚动动画
const startTime = performance.now()
const animate = (currentTime: number) => {
const elapsed = currentTime - startTime
const progress = Math.min(elapsed / duration, 1)
// 根据选项选择缓动函数
const eased = easing === 'ease-in-out'
? easeInOutCubic(progress)
: easeOutElastic(progress)
const currentScrollTop = startScrollTop + (clampedTargetScrollTop - startScrollTop) * eased
targetContainer.scrollTop = currentScrollTop
if (progress < 1) {
requestAnimationFrame(animate)
} else {
console.log('[scrollToElementWithSpring] Animation completed')
}
}
requestAnimationFrame(animate)
})
})
}

View File

@ -32,21 +32,24 @@ 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',
// 次要按钮结构(hover 边框颜色在主题中定义)
secondary: 'px-6 py-3 rounded-xl border backdrop-blur-sm font-medium 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',
// 炫彩渐变按钮结构(紧凑尺寸,用于角标)
rainbowCompact: 'px-2 py-1 text-xs 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',
@ -56,7 +59,7 @@ export const baseButtonStyles = {
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',
base: 'w-full px-4 py-3 rounded-xl border 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',
@ -66,11 +69,11 @@ 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',
// 悬停导航项结构(带光晕效果,hover 边框和阴影颜色在主题中定义)
hover: 'px-4 py-3 rounded-xl border backdrop-blur-sm 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',
// 激活导航项结构(带炫彩光晕,边框和阴影颜色在主题中定义)
active: 'px-4 py-3 rounded-xl border-2 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',
@ -112,25 +115,27 @@ export const baseSpacingStyles = {
// Modal 基础样式(不包含颜色)
export const baseModalStyles = {
// 遮罩层
overlay: 'fixed inset-0 z-50 flex items-center justify-center p-4 transition-opacity duration-300',
overlay: 'fixed inset-0 z-50 flex items-center justify-center px-4 py-8 sm:px-6 sm:py-12 lg:px-10 lg:py-16 bg-slate-950/55 dark:bg-black/75 backdrop-blur-2xl transition-opacity duration-300 modal-overlay-apple',
// 弹窗容器
container: 'relative w-full max-h-[90vh] overflow-hidden transition-all duration-300',
container: 'relative w-auto max-w-full max-h-[80vh] sm:max-h-[75vh] overflow-hidden transition-all duration-300 flex-none rounded-[32px]',
// 弹窗内容
content: 'rounded-2xl border backdrop-blur-sm shadow-2xl transition-all duration-300',
content: 'w-full rounded-[32px] bg-white/80 dark:bg-slate-900/80 backdrop-blur-xl shadow-[0_35px_100px_rgba(15,23,42,0.22)] dark:shadow-[0_45px_120px_rgba(0,0,0,0.8)] overflow-hidden transition-all duration-300 modal-content-apple',
// 弹窗尺寸
sizes: {
xs: 'max-w-[20rem]',
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]',
xxlarge: 'max-w-7xl',
fullscreen: 'max-w-full w-full h-full max-h-[85vh]',
},
// 弹窗头部
header: 'px-6 py-4 border-b flex items-center justify-between',
header: 'px-8 py-5 border-b flex items-center justify-between bg-transparent',
// 弹窗标题
title: 'text-lg font-semibold',
@ -139,9 +144,49 @@ export const baseModalStyles = {
closeButton: 'p-1 rounded-lg hover:bg-opacity-10 transition-colors duration-200',
// 弹窗主体
body: 'px-6 py-4 overflow-y-auto',
body: 'px-8 py-6 max-h-[60vh] sm:max-h-[55vh] overflow-y-auto bg-transparent',
// 弹窗底部
footer: 'px-6 py-4 border-t flex items-center justify-end gap-3',
footer: 'px-8 py-5 border-t flex items-center justify-end gap-3 bg-transparent',
} as const
// Section 显示动画样式(来自外观设置)
export const baseSectionRevealStyles = {
// 基础显示动画类
base: 'transition-all duration-700 ease-out transform',
// 可见状态
visible: 'opacity-100 translate-y-0 scale-100',
// 隐藏状态
hidden: 'opacity-0 translate-y-6 scale-95',
} as const
// Hero 区域样式(来自外观设置)
export const baseHeroStyles = {
// Hero 卡片基础结构
card: 'relative overflow-hidden rounded-3xl border transition-all duration-500 p-6',
// Hero 标题区域
header: 'relative z-10 flex flex-col gap-5 xl:flex-row xl:items-center xl:justify-between',
// Hero 内容区域
content: 'space-y-3 max-w-2xl',
} as const
// 浮动光球样式(来自外观设置)
export const baseFloatingOrbStyles = {
// 光球基础样式
base: 'absolute rounded-full blur-3xl pointer-events-none',
// 右上角光球
topRight: '-top-8 -right-8 h-24 w-24 animate-pulse-slow',
// 左下角光球
bottomLeft: '-bottom-8 -left-8 h-20 w-20 animate-[pulse_8s_ease-in-out_infinite]',
} as const
// 开关切换器样式(来自外观设置)
export const baseToggleStyles = {
// 开关容器
container: 'relative inline-flex items-center cursor-pointer',
// 开关轨道(颜色在主题中定义)
track: '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:rounded-full after:h-5 after:w-5 after:transition-all',
// 开关输入(隐藏)
input: 'sr-only peer',
} as const

View File

@ -79,6 +79,12 @@ type ColorTheme = {
footerBorder: string
closeButton: string
}
toggle: {
trackBg: string
trackChecked: string
thumbBg: string
thumbBorder: string
}
accent: {
pink: { bg: string; text: string; border: string; hover: string }
purple: { bg: string; text: string; border: string; hover: string }
@ -111,11 +117,12 @@ function composeCardStyles(colors: ColorTheme) {
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}`,
secondary: `${baseStyles.baseButtonStyles.secondary} ${colors.button.secondary.border} ${colors.button.secondary.bg} ${colors.button.secondary.text} ${colors.button.secondary.hover} hover:border-indigo-400 dark:hover:border-indigo-400`,
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, // 渐变按钮颜色固定
rainbow: baseStyles.baseButtonStyles.rainbow, // 渐变按钮颜色固定(标准尺寸)
rainbowCompact: baseStyles.baseButtonStyles.rainbowCompact, // 渐变按钮颜色固定(紧凑尺寸,用于角标)
neon: baseStyles.baseButtonStyles.neon, // 霓虹按钮颜色固定
glass: `${baseStyles.baseButtonStyles.glass} ${colors.button.glass.bg} ${colors.button.glass.hover} ${colors.button.glass.border}`,
} as const
@ -126,7 +133,7 @@ function composeButtonStyles(colors: ColorTheme) {
*/
function composeInputStyles(colors: ColorTheme) {
return {
base: `${baseStyles.baseInputStyles.base} ${colors.input.border} ${colors.input.bg} ${colors.input.text} ${colors.input.placeholder} ${colors.input.focusRing}`,
base: `${baseStyles.baseInputStyles.base} ${colors.input.border} ${colors.input.bg} ${colors.input.text} ${colors.input.placeholder} ${colors.input.focusRing} focus:border-indigo-500 dark:focus:border-indigo-400`,
error: `${baseStyles.baseInputStyles.error} ${colors.input.errorBorder} ${colors.input.errorBg} ${colors.input.errorText} ${colors.input.errorPlaceholder} ${colors.input.errorFocusRing}`,
} as const
}
@ -137,8 +144,8 @@ function composeInputStyles(colors: ColorTheme) {
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}`,
hover: `${baseStyles.baseNavItemStyles.hover} ${colors.navItem.border} ${colors.navItem.bg} ${colors.navItem.hoverBg} ${colors.navItem.hoverShadow} ${colors.navItem.text} hover:border-indigo-400 dark:hover:border-indigo-400`,
active: `${baseStyles.baseNavItemStyles.active} ${colors.navItem.activeBg} ${colors.navItem.activeText} ${colors.navItem.activeShadow} border-indigo-500 dark:border-indigo-400`,
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}`,
@ -190,6 +197,17 @@ function composeAccentStyles(colors: ColorTheme) {
} as const
}
/**
* 组合开关切换器样式
*/
function composeToggleStyles(colors: ColorTheme) {
return {
container: baseStyles.baseToggleStyles.container,
track: `${baseStyles.baseToggleStyles.track} ${colors.toggle.trackBg} ${colors.toggle.trackChecked} ${colors.toggle.thumbBg} ${colors.toggle.thumbBorder}`,
input: baseStyles.baseToggleStyles.input,
} as const
}
/**
* 组合完整的样式配置
*/
@ -203,6 +221,7 @@ export function composeThemeStyles(colors: ColorTheme) {
navItemStyles: composeNavItemStyles(colors),
badgeStyles: composeBadgeStyles(colors),
modalStyles: composeModalStyles(colors),
toggleStyles: composeToggleStyles(colors),
accentStyles: composeAccentStyles(colors),
spacingStyles: baseStyles.baseSpacingStyles,
} as const

View File

@ -17,5 +17,6 @@ export const inputModifiers = darkThemeStyles.inputModifiers
export const navItemStyles = darkThemeStyles.navItemStyles
export const badgeStyles = darkThemeStyles.badgeStyles
export const modalStyles = darkThemeStyles.modalStyles
export const toggleStyles = darkThemeStyles.toggleStyles
export const accentStyles = darkThemeStyles.accentStyles
export const spacingStyles = darkThemeStyles.spacingStyles

View File

@ -17,5 +17,6 @@ export const inputModifiers = lightThemeStyles.inputModifiers
export const navItemStyles = lightThemeStyles.navItemStyles
export const badgeStyles = lightThemeStyles.badgeStyles
export const modalStyles = lightThemeStyles.modalStyles
export const toggleStyles = lightThemeStyles.toggleStyles
export const accentStyles = lightThemeStyles.accentStyles
export const spacingStyles = lightThemeStyles.spacingStyles

View File

@ -58,8 +58,8 @@ export const buttonStyles = {
// 幽灵按钮 - 确保深色模式完全适配
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',
// 危险按钮 - 确保深色模式完全适配(浅色模式使用更深的红色以增强对比度)
danger: 'px-6 py-3 rounded-xl bg-red-700 dark:bg-red-500 text-white font-medium shadow-lg shadow-red-600/70 dark:shadow-red-500/50 hover:bg-red-800 dark:hover:bg-red-600 hover:shadow-xl hover:shadow-red-600/80 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',
@ -149,3 +149,63 @@ export function getThemeStyles(theme: 'light' | 'dark') {
return require('./ui-styles-light')
}
/**
* Section 显示动画类生成器(来自外观设置)
* @param isVisible - 是否可见
* @returns 组合后的样式类
*/
export function sectionRevealClass(isVisible: boolean): string {
return cn(
'transition-all duration-700 ease-out transform',
isVisible ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 translate-y-6 scale-95'
)
}
/**
* 浮动光球样式类生成器(来自外观设置)
* @param theme - 主题 'light' | 'dark'
* @param position - 位置 'topRight' | 'bottomLeft'
* @returns 样式类字符串
*/
export function floatingOrbClass(theme: 'light' | 'dark', position: 'topRight' | 'bottomLeft'): string {
const baseClasses = 'absolute rounded-full blur-3xl pointer-events-none'
const positionClasses = position === 'topRight'
? '-top-8 -right-8 h-24 w-24 animate-pulse-slow'
: '-bottom-8 -left-8 h-20 w-20 animate-[pulse_8s_ease-in-out_infinite]'
const colorClasses = position === 'topRight'
? (theme === 'dark' ? 'bg-indigo-500/15' : 'bg-indigo-400/20')
: (theme === 'dark' ? 'bg-purple-500/15' : 'bg-purple-400/20')
return cn(baseClasses, positionClasses, colorClasses)
}
/**
* Hero 动态背景样式生成器(来自外观设置)
* @param isMouseOver - 鼠标是否悬停
* @param glowPos - 光晕位置 { x: number, y: number }
* @param theme - 主题 'light' | 'dark'
* @param baseGradient - 基础渐变背景
* @returns 背景样式对象
*/
export function getHeroDynamicBackground(
isMouseOver: boolean,
glowPos: { x: number; y: number },
theme: 'light' | 'dark',
baseGradient: string
): { backgroundImage: string; transition: string } {
if (isMouseOver) {
return {
backgroundImage: `radial-gradient(circle 300px at ${glowPos.x}% ${glowPos.y}%, ${
theme === 'dark'
? '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%'
}), ${baseGradient}`,
transition: 'background-image 0.4s ease-out',
}
}
return {
backgroundImage: baseGradient,
transition: 'background-image 0.4s ease-out',
}
}

View File

@ -43,10 +43,10 @@ export const themeColors = {
hover: 'hover:bg-slate-800',
},
danger: {
bg: 'bg-red-500',
bg: 'bg-red-500', // 与浅色模式保持一致
hover: 'hover:bg-red-600',
shadow: 'shadow-red-500/50',
shadowHover: 'hover:shadow-red-500/60',
shadow: 'shadow-red-500/40',
shadowHover: 'hover:shadow-red-500/50',
},
success: {
bg: 'bg-emerald-500',
@ -167,15 +167,23 @@ export const themeColors = {
// Modal 颜色
modal: {
overlay: 'bg-black/70',
bg: 'bg-slate-800',
border: 'border-slate-700/70',
shadow: 'shadow-black/60',
headerBorder: 'border-slate-700/70',
overlay: 'bg-slate-950/70 backdrop-blur-xl',
bg: 'bg-slate-900/80',
border: 'border-slate-800/60',
shadow: 'shadow-[0_45px_130px_rgba(0,0,0,0.65)]',
headerBorder: 'border-slate-800/60',
title: 'text-white',
body: 'text-slate-200',
footerBorder: 'border-slate-700/70',
closeButton: 'text-slate-400 hover:text-slate-200 hover:bg-slate-700',
footerBorder: 'border-slate-800/60',
closeButton: 'text-slate-300 hover:text-white hover:bg-white/10',
},
// 开关切换器颜色
toggle: {
trackBg: 'bg-slate-700',
trackChecked: 'peer-checked:bg-indigo-500',
thumbBg: 'after:bg-white',
thumbBorder: 'after:border-slate-600',
},
} as const

View File

@ -35,7 +35,7 @@ export const themeColors = {
secondary: {
border: 'border-slate-300',
bg: 'bg-white',
text: 'text-slate-700',
text: 'text-slate-900', // 使用更深的文字颜色,确保在浅色模式下清晰可见
hover: 'hover:bg-slate-50',
},
ghost: {
@ -43,10 +43,10 @@ export const themeColors = {
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',
bg: 'bg-red-500', // 使用适中的红色,确保可见性
hover: 'hover:bg-red-600',
shadow: 'shadow-red-500/50',
shadowHover: 'hover:shadow-red-500/60',
},
success: {
bg: 'bg-emerald-600',
@ -167,15 +167,23 @@ export const themeColors = {
// Modal 颜色
modal: {
overlay: 'bg-black/50',
bg: 'bg-white',
border: 'border-slate-200/70',
shadow: 'shadow-slate-200/50',
headerBorder: 'border-slate-200/70',
overlay: 'bg-slate-900/35 backdrop-blur-xl',
bg: 'bg-white/80',
border: 'border-white/30',
shadow: 'shadow-[0_35px_110px_rgba(15,23,42,0.2)]',
headerBorder: 'border-white/30',
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',
body: 'text-slate-600',
footerBorder: 'border-white/30',
closeButton: 'text-slate-500 hover:text-slate-700 hover:bg-white/40',
},
// 开关切换器颜色
toggle: {
trackBg: 'bg-slate-200',
trackChecked: 'peer-checked:bg-indigo-600',
thumbBg: 'after:bg-white',
thumbBorder: 'after:border-slate-300',
},
} as const

View File

@ -0,0 +1,220 @@
export type StandardFieldConfig = {
key: string
label: string
type: string
required: boolean
layoutWidth: string
visibleOnHomeFilter: boolean
isSearchable: boolean
addToCategory?: boolean
optionsStatic?: string
}
export const BUILTIN_TEMPLATE_KEYS = [
'3dmax-material',
'psd-material',
'ai-material',
'video-material',
'model-material',
'other-material',
] as const
export const BUILTIN_TEMPLATE_NAMES: Record<string, string> = {
'3dmax-material': '3DMAX 素材模板',
'psd-material': 'PSD 素材模板',
'ai-material': 'AI 工作流',
'video-material': '视频素材模板',
'model-material': '模特素材模板',
'other-material': '其他素材模板',
}
export const BUILTIN_TEMPLATE_DESCRIPTIONS: Record<string, string> = {
'3dmax-material': '适用于 3D 模型类素材,覆盖渲染器、SKU 与品牌等核心字段。',
'psd-material': '针对平面设计 PSD 素材,包含品牌、颜色模式和输出分辨率。',
'ai-material': '面向 AI 生成/矢量工作流,仅保留必要的基础字段。',
'video-material': '适用于视频文件素材。',
'model-material': '用于模特/人像素材,关注拍摄场景与负责作者。',
'other-material': '适用于其他类型的素材。',
}
export const BUILTIN_TEMPLATE_TYPES: Record<string, string> = {
'3dmax-material': '3DMAX',
'psd-material': 'PSD',
'ai-material': 'AI 工作流',
'video-material': '视频',
'model-material': '模特',
'other-material': '其他',
}
export const STANDARD_FIELDS: Record<string, StandardFieldConfig> = {
productModel: { key: 'productModel', label: 'SPU型号', type: 'text', required: true, layoutWidth: 'full', visibleOnHomeFilter: false, isSearchable: true },
categoryName: {
key: 'categoryName',
label: '使用店铺',
type: 'select',
required: false,
layoutWidth: 'full',
visibleOnHomeFilter: true,
isSearchable: true,
addToCategory: true,
optionsStatic: JSON.stringify(['默认店铺']),
},
sceneName: {
key: 'sceneName',
label: '场景类别',
type: 'select',
required: false,
layoutWidth: 'full',
visibleOnHomeFilter: true,
isSearchable: true,
addToCategory: true,
optionsStatic: JSON.stringify(['默认场景']),
},
platformName: {
key: 'platformName',
label: '平台分类',
type: 'text',
required: false,
layoutWidth: 'half',
visibleOnHomeFilter: false,
isSearchable: true,
},
renderer: {
key: 'renderer',
label: '渲染器',
type: 'select',
required: false,
layoutWidth: 'full',
visibleOnHomeFilter: false,
isSearchable: true,
addToCategory: true,
optionsStatic: JSON.stringify(['CR', 'VRay', 'Corona', 'Arnold', 'Octane', '其他']),
},
brandName: {
key: 'brandName',
label: '品牌名称',
type: 'text',
required: false,
layoutWidth: 'half',
visibleOnHomeFilter: false,
isSearchable: true,
},
seriesName: {
key: 'seriesName',
label: '系列/产品线',
type: 'text',
required: false,
layoutWidth: 'half',
visibleOnHomeFilter: false,
isSearchable: true,
},
skuCode: { key: 'skuCode', label: 'SKU编码', type: 'text', required: false, layoutWidth: 'half', visibleOnHomeFilter: false, isSearchable: true },
materialTexture: {
key: 'materialTexture',
label: '材质/工艺',
type: 'text',
required: false,
layoutWidth: 'half',
visibleOnHomeFilter: false,
isSearchable: true,
},
renderTime: { key: 'renderTime', label: '渲染耗时', type: 'text', required: false, layoutWidth: 'half', visibleOnHomeFilter: false, isSearchable: false },
imageResolution: { key: 'imageResolution', label: '输出分辨率', type: 'text', required: false, layoutWidth: 'half', visibleOnHomeFilter: false, isSearchable: false },
colorMode: { key: 'colorMode', label: '颜色模式', type: 'text', required: false, layoutWidth: 'half', visibleOnHomeFilter: false, isSearchable: false },
authorName: { key: 'authorName', label: '作者', type: 'text', required: false, layoutWidth: 'half', visibleOnHomeFilter: false, isSearchable: true },
}
export const BASE_TEMPLATE_FIELDS = ['productModel', 'categoryName', 'sceneName'] as const
export const BUILTIN_TEMPLATE_FIELDS: Record<string, string[]> = {
'3dmax-material': ['productModel', 'categoryName', 'sceneName', 'renderer'],
'psd-material': [...BASE_TEMPLATE_FIELDS],
'ai-material': [...BASE_TEMPLATE_FIELDS],
'video-material': [...BASE_TEMPLATE_FIELDS],
'model-material': [...BASE_TEMPLATE_FIELDS],
'other-material': [...BASE_TEMPLATE_FIELDS],
}
export type BuiltinTemplateField = {
id: string
key: string
label: string
type: string
required: boolean
orderIndex: number
layoutWidth?: string | null
validations: null
optionsSource: null
optionsStatic: string | null
allowedExtensions: null
visibleOnHomeFilter: boolean
isSearchable: boolean
addToCategory?: boolean
conditions: null
}
export type BuiltinTemplate = {
id: string
name: string
description: string
status: 'active'
materialType: string
isBuiltin: true
fields: BuiltinTemplateField[]
}
export function createBuiltinTemplate(key: string): BuiltinTemplate {
const fields = BUILTIN_TEMPLATE_FIELDS[key] || []
const requireRenderer = key === '3dmax-material'
const overrides: Record<
string,
Partial<{
required: boolean
visibleOnHomeFilter: boolean
isSearchable: boolean
}>
> = {
productModel: { required: true, isSearchable: true },
categoryName: { required: true, visibleOnHomeFilter: true, isSearchable: true },
sceneName: { required: true, visibleOnHomeFilter: true, isSearchable: true },
}
if (requireRenderer) {
overrides.renderer = { required: true, isSearchable: true }
}
return {
id: `builtin-${key}`,
name: BUILTIN_TEMPLATE_NAMES[key] || key,
description: BUILTIN_TEMPLATE_DESCRIPTIONS[key] || '',
status: 'active',
materialType: BUILTIN_TEMPLATE_TYPES[key] || '其他',
isBuiltin: true,
fields: fields
.map((fieldKey, index) => {
const fieldConfig = STANDARD_FIELDS[fieldKey]
if (!fieldConfig) return null
const override = overrides[fieldKey] || {}
return {
id: `builtin-${key}-${fieldKey}`,
key: fieldConfig.key,
label: fieldConfig.label,
type: fieldConfig.type,
required: override.required ?? fieldConfig.required,
orderIndex: index,
layoutWidth: fieldConfig.layoutWidth,
validations: null,
optionsSource: null,
optionsStatic: fieldConfig.optionsStatic || null,
allowedExtensions: null,
visibleOnHomeFilter: override.visibleOnHomeFilter ?? fieldConfig.visibleOnHomeFilter ?? false,
isSearchable: override.isSearchable ?? fieldConfig.isSearchable ?? false,
addToCategory: fieldConfig.addToCategory ?? false,
conditions: null,
}
})
.filter(Boolean) as BuiltinTemplateField[],
}
}

BIN
minio-mc-latest.tar Normal file

Binary file not shown.

View File

@ -45,6 +45,7 @@ model UploadTemplate {
name String
description String?
status String @default("draft")
materialType String @default("其他")
version Int @default(1)
fieldGroups String?
displayConfig String?
@ -70,6 +71,7 @@ model UploadField {
visibleOnHomeFilter Boolean @default(false)
isSearchable Boolean @default(false)
isReadOnly Boolean @default(false)
addToCategory Boolean @default(false)
conditions String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@ -89,36 +91,36 @@ model FilterItem {
}
model Material {
id String @id @default(uuid())
previewUrl String
productModel String
brandName String?
seriesName String?
skuCode String?
id String @id @default(uuid())
previewUrl String
productModel String
brandName String?
seriesName String?
skuCode String?
materialTexture String?
categoryName String
sceneName String
materialType String @default("3DMAX")
renderer String @default("CR")
renderTime String?
categoryName String
sceneName String
materialType String @default("3DMAX")
renderer String @default("CR")
renderTime String?
imageResolution String?
colorMode String?
authorName String
zipFileName String @default("")
zipFilePath String @default("")
zipFileSize BigInt @default(0)
downloadCount Int @default(0)
likeCount Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userId String
displayImages String @default("")
platformName String @default("")
metadata String?
templateId String?
likes MaterialLike[]
template UploadTemplate? @relation(fields: [templateId], references: [id])
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
colorMode String?
authorName String
zipFileName String @default("")
zipFilePath String @default("")
zipFileSize BigInt @default(0)
downloadCount Int @default(0)
likeCount Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userId String
displayImages String @default("")
platformName String @default("")
metadata String?
templateId String?
likes MaterialLike[]
template UploadTemplate? @relation(fields: [templateId], references: [id])
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([createdAt])
@ -148,7 +150,7 @@ model MaterialLike {
model Category {
id String @id @default(uuid())
name String @unique
options String? // JSON array of options for dropdown
options String? // JSON array of options for dropdown
createdAt DateTime @default(now())
@@map("categories")
@ -157,7 +159,7 @@ model Category {
model Scene {
id String @id @default(uuid())
name String @unique
options String? // JSON array of options for dropdown
options String? // JSON array of options for dropdown
createdAt DateTime @default(now())
@@map("scenes")
@ -166,30 +168,32 @@ model Scene {
model Platform {
id String @id @default(uuid())
name String @unique
options String? // JSON array of options for dropdown
options String? // JSON array of options for dropdown
createdAt DateTime @default(now())
@@map("platforms")
}
model SystemSettings {
id String @id @default(uuid())
headerBgColor String @default("#1f2937")
siteName String @default("由壹点3D素材")
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
headerLogoUrl String @default("http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png")
loginLogoUrl String @default("http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png")
s3AccessKey String @default("")
s3Bucket String @default("")
s3Endpoint String @default("")
s3PublicUrl String @default("")
s3Region String @default("us-east-1")
s3SecretKey String @default("")
enableRegistration Boolean @default(true)
enableUserActivation Boolean @default(false)
enableAiImageEdit Boolean @default(false)
enableTunnelService Boolean @default(false)
id String @id @default(uuid())
headerBgColor String @default("#1f2937")
siteName String @default("由壹点3D素材")
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
headerLogoUrl String @default("http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png")
loginLogoUrl String @default("http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png")
s3AccessKey String @default("")
s3Bucket String @default("")
s3Endpoint String @default("")
s3PublicUrl String @default("")
s3Region String @default("us-east-1")
s3SecretKey String @default("")
enableRegistration Boolean @default(true)
enableUserActivation Boolean @default(false)
enableAiImageEdit Boolean @default(false)
enableTunnelService Boolean @default(false)
enabledBuiltinTemplates String? // JSON array of enabled builtin template keys (empty array or null = all enabled)
categoryManagedFields String? // JSON array,记录分类管理已启用的字段 key
@@map("system_settings")
}

View File

@ -0,0 +1,19 @@
#!/bin/bash
# 应用数据库迁移脚本
# 在服务器上运行此脚本来应用 addToCategory 字段的迁移
echo "开始应用数据库迁移..."
# 进入应用目录
cd /root/deploy || exit 1
# 在容器内运行迁移
docker exec youyidian-3d-material npx prisma migrate deploy
if [ $? -eq 0 ]; then
echo "✅ 数据库迁移成功完成!"
else
echo "❌ 数据库迁移失败,请检查错误信息"
exit 1
fi

View File

@ -0,0 +1,98 @@
# MinIO MC 镜像下载脚本
# 用于在本地下载 minio/mc 镜像并传输到服务器
param(
[string]$Server = "45.249.208.118",
[string]$Username = "root",
[string]$KeyPath = "C:\Users\Mr_Wu01\.ssh\youyidian_prod_ed25519",
[string]$ImageTag = "latest"
)
$ErrorActionPreference = "Stop"
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " MinIO MC 镜像下载脚本" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
# 1. 拉取镜像
Write-Host "[1/4] 拉取 minio/mc:$ImageTag 镜像..." -ForegroundColor Yellow
try {
docker pull "minio/mc:$ImageTag"
Write-Host "✓ 镜像拉取成功" -ForegroundColor Green
} catch {
Write-Host "✗ 镜像拉取失败: $_" -ForegroundColor Red
exit 1
}
# 2. 保存镜像
Write-Host ""
Write-Host "[2/4] 保存镜像到文件..." -ForegroundColor Yellow
$tarFile = "minio-mc-$ImageTag.tar"
$tarGzFile = "$tarFile.gz"
try {
docker save "minio/mc:$ImageTag" -o $tarFile
$fileSize = (Get-Item $tarFile).Length / 1MB
Write-Host "✓ 镜像已保存: $tarFile ($([math]::Round($fileSize, 2)) MB)" -ForegroundColor Green
# 压缩文件
Write-Host " 压缩镜像文件..." -ForegroundColor Gray
Compress-Archive -Path $tarFile -DestinationPath $tarGzFile -Force
$compressedSize = (Get-Item $tarGzFile).Length / 1MB
Write-Host "✓ 镜像已压缩: $tarGzFile ($([math]::Round($compressedSize, 2)) MB)" -ForegroundColor Green
Remove-Item $tarFile -ErrorAction SilentlyContinue
} catch {
Write-Host "✗ 镜像保存失败: $_" -ForegroundColor Red
exit 1
}
# 3. 传输到服务器
Write-Host ""
Write-Host "[3/4] 传输镜像到服务器..." -ForegroundColor Yellow
try {
$scpArgs = @("-i", $KeyPath, $tarGzFile, "${Username}@${Server}:/root/")
$scpProcess = Start-Process -FilePath "scp" -ArgumentList $scpArgs -NoNewWindow -PassThru -Wait
if ($scpProcess.ExitCode -eq 0) {
Write-Host "✓ 镜像传输成功" -ForegroundColor Green
} else {
Write-Host "✗ 镜像传输失败(退出码: $($scpProcess.ExitCode))" -ForegroundColor Red
exit 1
}
} catch {
Write-Host "✗ 镜像传输失败: $_" -ForegroundColor Red
exit 1
}
# 4. 在服务器上加载镜像
Write-Host ""
Write-Host "[4/4] 在服务器上加载镜像..." -ForegroundColor Yellow
try {
$sshArgs = @("-i", $KeyPath, "${Username}@${Server}", "cd /root && gunzip -c $tarGzFile > $tarFile && docker load -i $tarFile && rm -f $tarFile $tarGzFile && docker images | grep minio/mc")
$sshProcess = Start-Process -FilePath "ssh" -ArgumentList $sshArgs -NoNewWindow -PassThru -Wait
if ($sshProcess.ExitCode -eq 0) {
Write-Host "✓ 镜像加载成功" -ForegroundColor Green
} else {
Write-Host "✗ 镜像加载失败(退出码: $($sshProcess.ExitCode))" -ForegroundColor Red
exit 1
}
} catch {
Write-Host "✗ 镜像加载失败: $_" -ForegroundColor Red
exit 1
}
# 清理本地文件
Write-Host ""
Write-Host "清理本地临时文件..." -ForegroundColor Yellow
Remove-Item $tarFile -ErrorAction SilentlyContinue
Write-Host "✓ 清理完成" -ForegroundColor Green
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " 完成!MinIO MC 镜像已部署到服务器" -ForegroundColor Green
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "现在可以启动 minio-init 服务:" -ForegroundColor Yellow
Write-Host " ssh -i $KeyPath ${Username}@${Server}" -ForegroundColor Gray
Write-Host " cd /root/deploy" -ForegroundColor Gray
Write-Host " docker-compose up -d minio-init" -ForegroundColor Gray

View File

@ -0,0 +1,57 @@
# 加载基础镜像脚本
# 在部署前自动加载项目目录中保存的基础镜像
param(
[string]$ImagesDir = (Join-Path $PSScriptRoot ".." "docker-images")
)
$ErrorActionPreference = "Continue"
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " 加载基础 Docker 镜像" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
if (-not (Test-Path $ImagesDir)) {
Write-Host "镜像目录不存在: $ImagesDir" -ForegroundColor Yellow
Write-Host "跳过基础镜像加载" -ForegroundColor Yellow
return
}
$loadedCount = 0
$skippedCount = 0
Get-ChildItem $ImagesDir -Directory -ErrorAction SilentlyContinue | ForEach-Object {
$imageDir = $_.FullName
$imageName = $_.Name
Get-ChildItem $imageDir -Filter "*.tar" -ErrorAction SilentlyContinue | ForEach-Object {
$tarFile = $_.FullName
$tarFileName = $_.Name
Write-Host "📦 加载镜像: $tarFileName" -ForegroundColor Yellow
# 检查镜像是否已存在
$imageInfo = docker load -i $tarFile 2>&1
if ($LASTEXITCODE -eq 0) {
$loadedImage = $imageInfo | Select-String -Pattern "Loaded image: (.+)" | ForEach-Object { $_.Matches[0].Groups[1].Value }
if ($loadedImage) {
Write-Host " ✓ 已加载: $loadedImage" -ForegroundColor Green
$loadedCount++
} else {
Write-Host " ✓ 镜像已存在,跳过" -ForegroundColor Gray
$skippedCount++
}
} else {
Write-Host " ✗ 加载失败" -ForegroundColor Red
}
}
}
Write-Host ""
if ($loadedCount -gt 0 -or $skippedCount -gt 0) {
Write-Host "完成!加载: $loadedCount, 跳过: $skippedCount" -ForegroundColor Green
} else {
Write-Host "未找到需要加载的镜像" -ForegroundColor Yellow
}

View File

@ -0,0 +1,270 @@
# Docker 基础镜像管理脚本
# 用于管理项目中的基础 Docker 镜像
param(
[Parameter(Mandatory=$true)]
[ValidateSet("list", "load", "save", "update", "push-to-server")]
[string]$Action,
[string]$ImageName = "",
[string]$ImageTag = "latest",
[string]$Server = "45.249.208.118",
[string]$Username = "root",
[string]$KeyPath = "C:\Users\Mr_Wu01\.ssh\youyidian_prod_ed25519"
)
$ErrorActionPreference = "Stop"
$ImagesDir = Join-Path $PSScriptRoot ".." "docker-images"
function Get-ImagePath {
param([string]$Name, [string]$Tag)
return Join-Path $ImagesDir $Name "$Name-$Tag.tar"
}
function Get-ImageVersionFile {
param([string]$Name)
return Join-Path $ImagesDir $Name "VERSION.txt"
}
function List-Images {
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " 已保存的基础镜像列表" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
if (-not (Test-Path $ImagesDir)) {
Write-Host "镜像目录不存在: $ImagesDir" -ForegroundColor Yellow
return
}
$found = $false
Get-ChildItem $ImagesDir -Directory | ForEach-Object {
$imageName = $_.Name
$tarFiles = Get-ChildItem $_.FullName -Filter "*.tar" -ErrorAction SilentlyContinue
if ($tarFiles) {
$found = $true
Write-Host "📦 $imageName" -ForegroundColor Green
foreach ($tar in $tarFiles) {
$size = [math]::Round($tar.Length / 1MB, 2)
$modified = $tar.LastWriteTime.ToString("yyyy-MM-dd HH:mm")
Write-Host " └─ $($tar.Name) ($size MB, 更新于: $modified)" -ForegroundColor Gray
}
$versionFile = Get-ImageVersionFile -Name $imageName
if (Test-Path $versionFile) {
Write-Host " └─ 版本信息:" -ForegroundColor Gray
Get-Content $versionFile | ForEach-Object {
Write-Host " $_" -ForegroundColor DarkGray
}
}
Write-Host ""
}
}
if (-not $found) {
Write-Host "未找到已保存的镜像" -ForegroundColor Yellow
}
}
function Save-Image {
param([string]$Name, [string]$Tag)
if (-not $Name) {
Write-Host "错误: 请指定镜像名称 (例如: minio-mc)" -ForegroundColor Red
return
}
$imageFullName = if ($Name -match "/") { $Name } else { "minio/mc" }
$imageWithTag = "$imageFullName`:$Tag"
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " 保存镜像: $imageWithTag" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
# 检查镜像是否存在
Write-Host "[1/3] 检查本地镜像..." -ForegroundColor Yellow
$imageExists = docker images --format "{{.Repository}}:{{.Tag}}" | Select-String -Pattern "^$([regex]::Escape($imageFullName)):$Tag$"
if (-not $imageExists) {
Write-Host " 镜像不存在,正在拉取..." -ForegroundColor Yellow
docker pull $imageWithTag
if ($LASTEXITCODE -ne 0) {
Write-Host "✗ 镜像拉取失败" -ForegroundColor Red
return
}
} else {
Write-Host "✓ 镜像已存在" -ForegroundColor Green
}
# 创建目录
$imageDir = Join-Path $ImagesDir $Name
if (-not (Test-Path $imageDir)) {
New-Item -ItemType Directory -Path $imageDir -Force | Out-Null
}
# 保存镜像
Write-Host ""
Write-Host "[2/3] 保存镜像到文件..." -ForegroundColor Yellow
$tarPath = Get-ImagePath -Name $Name -Tag $Tag
docker save $imageWithTag -o $tarPath
if ($LASTEXITCODE -eq 0) {
$size = [math]::Round((Get-Item $tarPath).Length / 1MB, 2)
Write-Host "✓ 镜像已保存: $tarPath ($size MB)" -ForegroundColor Green
} else {
Write-Host "✗ 镜像保存失败" -ForegroundColor Red
return
}
# 保存版本信息
Write-Host ""
Write-Host "[3/3] 保存版本信息..." -ForegroundColor Yellow
$versionFile = Get-ImageVersionFile -Name $Name
$digest = docker images --format "{{.ID}}" $imageWithTag | Select-Object -First 1
$versionInfo = @"
$Tag - $(Get-Date -Format 'yyyy-MM-dd')
Image: $imageWithTag
Digest: $digest
Size: ~$size MB
"@
Set-Content -Path $versionFile -Value $versionInfo
Write-Host "✓ 版本信息已保存" -ForegroundColor Green
Write-Host ""
Write-Host "完成!" -ForegroundColor Green
}
function Load-Image {
param([string]$Name, [string]$Tag)
if (-not $Name) {
Write-Host "错误: 请指定镜像名称" -ForegroundColor Red
return
}
$tarPath = Get-ImagePath -Name $Name -Tag $Tag
if (-not (Test-Path $tarPath)) {
Write-Host "错误: 镜像文件不存在: $tarPath" -ForegroundColor Red
Write-Host "提示: 使用 'save' 操作先保存镜像" -ForegroundColor Yellow
return
}
Write-Host "加载镜像: $tarPath" -ForegroundColor Cyan
docker load -i $tarPath
if ($LASTEXITCODE -eq 0) {
Write-Host "✓ 镜像加载成功" -ForegroundColor Green
} else {
Write-Host "✗ 镜像加载失败" -ForegroundColor Red
}
}
function Update-Image {
param([string]$Name, [string]$Tag)
if (-not $Name) {
Write-Host "错误: 请指定镜像名称" -ForegroundColor Red
return
}
$imageFullName = if ($Name -match "/") { $Name } else { "minio/mc" }
$imageWithTag = "$imageFullName`:$Tag"
Write-Host "更新镜像: $imageWithTag" -ForegroundColor Cyan
Write-Host ""
# 拉取最新版本
Write-Host "[1/2] 拉取最新镜像..." -ForegroundColor Yellow
docker pull $imageWithTag
if ($LASTEXITCODE -ne 0) {
Write-Host "✗ 镜像拉取失败" -ForegroundColor Red
return
}
Write-Host "✓ 镜像拉取成功" -ForegroundColor Green
# 保存
Write-Host ""
Write-Host "[2/2] 保存镜像..." -ForegroundColor Yellow
Save-Image -Name $Name -Tag $Tag
}
function Push-ToServer {
param([string]$Name, [string]$Tag, [string]$Server, [string]$Username, [string]$KeyPath)
if (-not $Name) {
Write-Host "错误: 请指定镜像名称" -ForegroundColor Red
return
}
$tarPath = Get-ImagePath -Name $Name -Tag $Tag
if (-not (Test-Path $tarPath)) {
Write-Host "错误: 镜像文件不存在: $tarPath" -ForegroundColor Red
return
}
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " 推送镜像到服务器" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "镜像: $Name`:$Tag" -ForegroundColor Gray
Write-Host "服务器: ${Username}@${Server}" -ForegroundColor Gray
Write-Host ""
# 压缩文件
Write-Host "[1/3] 压缩镜像文件..." -ForegroundColor Yellow
$tarGzPath = "$tarPath.gz"
Compress-Archive -Path $tarPath -DestinationPath $tarGzPath -Force
$compressedSize = [math]::Round((Get-Item $tarGzPath).Length / 1MB, 2)
Write-Host "✓ 已压缩: $compressedSize MB" -ForegroundColor Green
# 传输
Write-Host ""
Write-Host "[2/3] 传输到服务器..." -ForegroundColor Yellow
$scpArgs = @("-i", $KeyPath, $tarGzPath, "${Username}@${Server}:/root/")
$scpProcess = Start-Process -FilePath "scp" -ArgumentList $scpArgs -NoNewWindow -PassThru -Wait
if ($scpProcess.ExitCode -eq 0) {
Write-Host "✓ 传输成功" -ForegroundColor Green
} else {
Write-Host "✗ 传输失败" -ForegroundColor Red
Remove-Item $tarGzPath -ErrorAction SilentlyContinue
return
}
# 在服务器上加载
Write-Host ""
Write-Host "[3/3] 在服务器上加载镜像..." -ForegroundColor Yellow
$tarGzFileName = Split-Path $tarGzPath -Leaf
$tarFileName = Split-Path $tarPath -Leaf
$sshCmd = "cd /root && gunzip -c $tarGzFileName > $tarFileName && docker load -i $tarFileName && rm -f $tarFileName $tarGzFileName && docker images | grep $(if ($Name -match '/') { $Name } else { 'minio/mc' })"
$sshArgs = @("-i", $KeyPath, "${Username}@${Server}", $sshCmd)
$sshProcess = Start-Process -FilePath "ssh" -ArgumentList $sshArgs -NoNewWindow -PassThru -Wait
if ($sshProcess.ExitCode -eq 0) {
Write-Host "✓ 镜像已加载到服务器" -ForegroundColor Green
} else {
Write-Host "✗ 服务器加载失败" -ForegroundColor Red
}
# 清理本地压缩文件
Remove-Item $tarGzPath -ErrorAction SilentlyContinue
Write-Host ""
Write-Host "完成!" -ForegroundColor Green
}
# 主逻辑
switch ($Action) {
"list" {
List-Images
}
"save" {
Save-Image -Name $ImageName -Tag $ImageTag
}
"load" {
Load-Image -Name $ImageName -Tag $ImageTag
}
"update" {
Update-Image -Name $ImageName -Tag $ImageTag
}
"push-to-server" {
Push-ToServer -Name $ImageName -Tag $ImageTag -Server $Server -Username $Username -KeyPath $KeyPath
}
}

View File

@ -0,0 +1,46 @@
#!/bin/bash
# 在服务器上运行数据库迁移脚本
# 使用方法: 将此脚本上传到服务器后执行,或通过 SSH 远程执行
set -e
echo "=========================================="
echo " 数据库迁移脚本"
echo "=========================================="
echo ""
# 检查容器是否运行
if ! docker ps | grep -q "youyidian-3d-material"; then
echo "❌ 错误: 容器 youyidian-3d-material 未运行"
echo "请先启动容器: docker-compose up -d"
exit 1
fi
echo "✓ 容器运行中"
echo ""
# 执行 Prisma 迁移
echo "开始执行数据库迁移..."
echo ""
if docker exec youyidian-3d-material npx prisma migrate deploy; then
echo ""
echo "✅ 数据库迁移成功完成!"
echo ""
echo "验证迁移结果..."
docker exec youyidian-3d-material npx prisma db execute --stdin <<< "SELECT column_name, data_type, column_default FROM information_schema.columns WHERE table_name = 'UploadField' AND column_name = 'addToCategory';" || echo "验证查询执行完成"
else
echo ""
echo "⚠️ 迁移执行失败或迁移已存在"
echo ""
echo "尝试手动添加字段(如果迁移文件不存在)..."
docker exec youyidian-3d-material npx prisma db execute --stdin <<< "ALTER TABLE \"UploadField\" ADD COLUMN IF NOT EXISTS \"addToCategory\" BOOLEAN NOT NULL DEFAULT false;" || echo "字段可能已存在"
echo ""
echo "✅ 手动迁移完成"
fi
echo ""
echo "=========================================="
echo " 迁移脚本执行完成"
echo "=========================================="

View File

@ -276,8 +276,14 @@
- 自动验证必填字段
- ✅ 系统管理中的分类管理:使用模板管理中的下拉元素文字做分类
- 创建 `/api/template-categories` API 提取模板中的分类选项
- 从所有启用的模板中提取 `visibleOnHomeFilter=true` 且 `type='select'` 的字段选项
- 在分类管理界面显示模板分类,保留原有分类管理用于向后兼容
- 从所有启用的模板中提取 `addToCategory=true` 且 `type='select'` 的字段选项
- 在分类管理界面显示模板分类卡片,提供编辑、添加、删除选项的功能
- 创建 `/api/template-categories/[fieldKey]` API 管理特定字段的分类选项
- 保留原有分类管理用于向后兼容
- ✅ 添加"添加到分类"字段属性
- 在数据库 schema 中添加 `addToCategory` 字段到 `UploadField` 模型
- 在字段编辑器中添加"添加到分类"勾选框(仅 select 类型显示)
- 更新所有相关类型定义和 API 以支持该字段
- ✅ 修改上传逻辑:提交模板ID和字段值
- 在 `metadata` 字段中存储模板字段值(JSON格式)
- 在 `templateId` 字段中直接存储模板ID,方便查询
@ -296,19 +302,26 @@
#### 2.3 首页类型筛选TAG系统 ⭐⭐⭐⭐
**需求编号**:19
**优先级**:高
**状态**:✅ 已完成
**原因**:
- 与模板管理整合相关,是核心功能的一部分
- 提升用户体验,方便筛选
- TAG样式需要符合整体风格
**实施要点**:
- 将筛选内的类型分页功能移到首页
- 使用TAG形式显示,第一个TAG是"全部"
- TAG数据来源:根据上传模板设置(模板管理中的下拉元素文字)
- TAG样式设计:好看大气,符合整体风格
- 使用三层架构样式系统
- 支持点击切换筛选
- 考虑低饱和度炫彩元素(需求18)
- ✅ 将筛选内的类型分页功能移到首页
- ✅ 使用TAG形式显示,第一个TAG是"全部"
- ✅ TAG数据来源:根据上传模板设置(模板管理中的下拉元素文字)
- 创建 `/api/material-types` API 从模板管理中提取 `materialType` 字段的 `optionsStatic`
- 首页从API动态获取素材类型列表,不再使用固定数组
- ✅ TAG样式设计:好看大气,符合整体风格
- 使用 `Tag` 组件和 `accentStyles` 实现低饱和度炫彩元素
- 支持多种颜色(blue、green、orange、purple、pink、cyan)循环使用
- 选中状态使用 ring 样式突出显示
- 未选中状态使用透明度变化提供视觉反馈
- ✅ 使用三层架构样式系统
- ✅ 支持点击切换筛选
- ✅ 考虑低饱和度炫彩元素(需求18)
**依赖关系**:依赖 2.2(模板管理整合)
**影响范围**:首页筛选功能
@ -320,15 +333,22 @@
#### 3.1 上传素材进度优化 ⭐⭐⭐⭐
**需求编号**:2
**优先级**:高
**状态**:✅ 已完成
**原因**:
- 修复已知问题,影响用户体验
- 进度到100%后延迟关闭的问题
**实施要点**:
- 检查上传完成后的处理逻辑
- 确保进度100%后立即关闭弹窗
- 可能需要添加延迟以确保数据保存完成
- 优化用户体验:显示"上传完成"状态
- ✅ 检查上传完成后的处理逻辑
- ✅ 确保进度100%后立即显示完成状态
- 添加 `uploadComplete` 状态标识上传完成
- 进度条颜色从蓝色变为绿色
- 显示"上传完成"和"✓ 上传成功,正在关闭..."提示
- ✅ 添加短暂延迟(300ms)确保数据保存完成后再关闭弹窗
- ✅ 优化用户体验:显示上传完成提示
- 进度条显示为绿色
- 显示完成提示文字
- 自动关闭前给用户反馈
**依赖关系**:建议使用统一弹窗组件(1.1)
**影响范围**:上传功能

View File

@ -417,9 +417,29 @@ export default function MyComponent() {
- ✅ 实现动态字段渲染:选择模板后根据模板字段动态生成表单(支持 text、textarea、number、select、checkbox、radio、date 等类型)
- ✅ 根据字段的 `layoutWidth` 属性支持半行/整行布局
- ✅ 修改上传逻辑:提交模板ID和字段值到数据库(存储在 `metadata` 和 `templateId` 字段)
- ✅ 创建模板分类 API `/api/template-categories`:从模板管理中提取所有 `visibleOnHomeFilter=true` 且 `type='select'` 的字段选项
- ✅ 修改分类管理界面:显示从模板中提取的分类选项,保留原有分类管理用于向后兼容
- ✅ 添加"添加到分类"字段属性
- 在数据库 schema 中添加 `addToCategory` 字段到 `UploadField` 模型
- 在字段编辑器中添加"添加到分类"勾选框(仅 select 类型显示)
- 更新所有相关类型定义和 API 以支持该字段
- ✅ 创建模板分类 API `/api/template-categories`:从模板管理中提取所有 `addToCategory=true` 且 `type='select'` 的字段选项
- ✅ 创建模板分类管理 API `/api/template-categories/[fieldKey]`:支持获取和更新特定字段的分类选项
- ✅ 修改分类管理界面:显示模板分类卡片,提供编辑、添加、删除选项的功能,保留原有分类管理用于向后兼容
- ⏳ 模板管理面板逻辑检查(待后续完善)
7. ✅ 首页类型筛选TAG系统(2.3)
- ✅ 创建 `/api/material-types` API:从模板管理中提取 `materialType` 字段的 `optionsStatic` 选项
- ✅ 修改首页:将固定的 `materialTypes` 数组改为从API动态获取
- ✅ 实现TAG形式的类型筛选UI:在搜索框下方显示TAG筛选
- ✅ 第一个TAG是"全部",点击可清除筛选
- ✅ 使用 `Tag` 组件和 `accentStyles` 实现低饱和度炫彩元素样式
- ✅ 支持多种颜色循环使用(blue、green、orange、purple、pink、cyan)
- ✅ 选中状态使用 ring 样式突出显示,未选中状态使用透明度变化
- ✅ 支持点击切换筛选,提升用户体验
8. ✅ 上传素材进度优化(3.1)
- ✅ 添加 `uploadComplete` 状态标识上传完成
- ✅ 进度100%后立即显示完成状态(进度条变绿色,显示"上传完成")
- ✅ 添加300ms延迟确保数据保存完成后再关闭弹窗
- ✅ 优化用户体验:显示"✓ 上传成功,正在关闭..."提示
- ✅ 修复所有错误处理路径,确保状态正确重置
- 将模板系统串起前后端
**详细计划**:请查看 `优化计划与优先级.md`

View File

@ -10,9 +10,12 @@
管理员修改用户信息后,这个用户上传的所有素材信息也要跟着更新
比如改了用户名,素材里的作者名也要变
2. 上传进度优化 ⚡
2. 上传进度优化 ⚡ ✅
现在上传素材进度条到100%后,还要等一会儿才关闭弹窗
希望能更流畅,进度到100%就立马关闭
✅ 已完成:进度100%后立即显示完成状态(绿色进度条+完成提示)
✅ 已完成:添加300ms延迟确保数据保存完成后再关闭
✅ 已完成:优化用户体验,显示"上传成功,正在关闭..."提示
3. 多预览图功能 🖼️
一个素材可以上传多张预览图
@ -67,11 +70,14 @@
样式参考最高权限的显示效果
要好看,偏向苹果风格,但整体要统一
12. 首页筛选TAG系统 🎯
12. 首页筛选TAG系统 🎯 ✅
把筛选里的"类型分页"功能放到首页
用TAG显示,第一个TAG是"全部"
TAG根据上传模板设置(所以模板管理很重要)
样式要大气好看,符合整体风格
✅ 已完成:创建了 `/api/material-types` API 从模板中提取素材类型
✅ 已完成:首页使用TAG形式显示类型筛选,支持点击切换
✅ 已完成:使用低饱和度炫彩元素样式,符合整体设计风格
═══════════════════════════════════════════════════════════════
🎛️ 系统管理优化

View File

@ -1,6 +1,66 @@
## 🧾 2025-01-XX · 「模板管理整合与UI优化」版本 🎨
## 🧾 2025-11-25 · 「弹窗样式统一与分类管理优化」版本 🎨
本次更新完成了模板管理整合到上传和分类功能,实现了动态字段渲染,并优化了UI样式系统。
### 🎨 UI统一优化
**1. 统一弹窗样式系统**
- ✅ 创建了统一的Modal组件,采用Apple风格设计
- ✅ 实现了毛玻璃效果(backdrop-blur),与顶部Header保持一致
- ✅ 添加了32px圆角,符合统一UI设计规范
- ✅ 实现了Apple风格的开启动画(scale + translateY + blur效果)
- ✅ 添加了细边框描边效果,区分弹窗和背景
- ✅ 使用createPortal渲染,确保弹窗正确覆盖所有内容
- ✅ 修复了弹窗打开时背景仍可滚动的问题
**2. 弹窗应用范围**
- ✅ 预览模板弹窗:应用统一样式和动画
- ✅ 首页上传模板弹窗:应用统一样式和动画
- ✅ 模板管理中新建/编辑模板弹窗:应用统一样式和动画
- ✅ 修复了首页上传面板宽度不一致的问题
- ✅ 修复了模板管理弹窗中按钮被遮挡的问题
**3. 分类管理优化**
- ✅ 修复了分类管理中重复卡片显示的问题
- ✅ 优化了选项解析逻辑,过滤掉数组字符串格式的选项
- ✅ 在API层面添加了去重和过滤逻辑,确保数据一致性
### 🔧 技术改进
- ✅ 优化了Modal组件的渲染逻辑,支持自定义内容高度控制
- ✅ 改进了parseOptions函数,处理双重字符串化和嵌套数组的情况
- ✅ 在多个API端点添加了数组字符串过滤逻辑
- ✅ 前端添加了去重逻辑,确保选项不重复显示
---
## 🧾 2025-01-22 · 「模板系统优化与上传面板重构」版本 🎨
### 核心功能优化
**1. 上传面板模板化重构**
- ✅ 移除旧的素材类型下拉选择,改用模板名称替代
- ✅ 从模板字段中自动提取 materialType 值
- ✅ 移除所有旧字段输入框(SPU型号、品牌、系列、SKU、材质/工艺、使用店铺、场景类别、平台分类、渲染信息等)
- ✅ 现在只显示模板配置的字段,确保与模板管理面板预览一致
- ✅ 修复了选择模板后旧字段不隐藏的问题
**2. 模板管理功能增强**
- ✅ 添加模板取消启用功能(之前启用后无法取消)
- ✅ 启用状态的模板现在可以取消启用,恢复为草稿状态
**3. 首页TAG筛选优化**
- ✅ 调整素材类型TAG间距,从 `gap-2` 调整为 `gap-3`,视觉效果更美观
### 技术改进
- ✅ 更新 `handleSubmit` 逻辑,从 `templateFieldValues` 中提取所有字段值
- ✅ 优化模板选择逻辑,自动从模板字段中提取 materialType 用于条件渲染
- ✅ 修复了模板字段值同步问题
---
## 🧾 2025-01-XX · 「模板管理整合、首页TAG筛选与上传进度优化」版本 🎨
本次更新完成了模板管理整合到上传和分类功能,实现了动态字段渲染,新增了首页类型筛选TAG系统,并优化了上传进度体验。
---
@ -22,6 +82,21 @@
- 分类管理界面显示从模板中提取的分类选项
- 保留原有分类管理用于向后兼容
- 🎯 **首页类型筛选TAG系统**
- 创建 `/api/material-types` API:从模板管理中提取 `materialType` 字段的选项
- 首页使用TAG形式显示素材类型筛选,替代原有的固定类型列表
- 第一个TAG是"全部",点击可清除筛选
- 使用 `Tag` 组件和低饱和度炫彩元素样式,支持多种颜色循环使用
- 选中状态使用 ring 样式突出显示,未选中状态使用透明度变化
- 提升用户体验,筛选更加直观便捷
- ⚡ **上传素材进度优化**
- 添加 `uploadComplete` 状态标识上传完成
- 进度100%后立即显示完成状态(进度条变绿色,显示"上传完成")
- 添加300ms延迟确保数据保存完成后再关闭弹窗
- 优化用户体验:显示"✓ 上传成功,正在关闭..."提示
- 修复所有错误处理路径,确保状态正确重置
---
### 🎨 UI优化

View File

@ -0,0 +1,82 @@
# 素材模板迁移指南(新版本)
> 面向 AI 编程平台(如 Cursor)编写,帮助快速理解“素材模板 = 素材类型”的全新架构,并指导从老版本迁移。
## 1. 架构变化速览
- **模板即素材类型**:预制模板只保留 6 类(3DMAX、PSD、AI 工作流、视频、模特、其他),`materialType` 作为模板元数据存储在 `upload_templates.materialType` 字段中。
- **字段更精简**:表单中不再渲染 `materialType` 字段,通用字段统一来自 `STANDARD_FIELD_TEMPLATES`,必填逻辑由模板控制。
- **前台上传**:`components/UploadModal.tsx` 依据模板元数据决定素材类型,不再依赖隐藏字段。
- **同步机制**:`BroadcastChannel + localStorage` 继续用于前后台同步启用状态,无需重新部署即可生效。
## 2. 迁移步骤(按顺序执行)
### Step 1:数据库
1. 执行 Prisma 迁移 `20251124001000_add_material_type_to_templates`:
```bash
npx prisma migrate deploy
```
2. 如需要手动执行:
```sql
ALTER TABLE "upload_templates"
ADD COLUMN "materialType" TEXT NOT NULL DEFAULT '其他';
UPDATE "upload_templates"
SET "materialType" = COALESCE(NULLIF("materialType", ''), '其他');
```
### Step 2:后端
- 确保以下 API 已更新并部署:
- `app/api/upload-templates/route.ts`
- `app/api/admin/upload-templates/*`
- `app/api/templates/route.ts`
- `app/api/material-types/route.ts`
- `app/api/template-categories/*`
- `app/api/materials/route.ts`
- 关键检查点:
- API 返回的模板对象需包含 `materialType`。
- 预制模板列表只包含 6 个 key。
- `template-categories` 不再引用 `materialType` 字段。
### Step 3:前端
- 主要文件:
- `app/admin/_components/TemplateManager.tsx`
- `app/admin/_components/template-manager/*`
- `components/UploadModal.tsx`
- 必须确认:
1. 新建/编辑模板时可选择素材类型(下拉框来自 `MATERIAL_TYPE_OPTIONS`)。
2. 保存模板时写入 `materialType` 字段。
3. 上传弹窗根据模板的 `materialType` 控制字段、校验与文件要求。
4. 预制模板列表与文档保持一致(3DMAX、PSD、AI 工作流、视频、模特、其他)。
### Step 4:文档与校验
- 更新 `docs/素材类型模板配置记录.md`(已完成)。
- 确认根目录的本指南同步提交,供 AI 工具拉取。
- 回归测试:
- 切换预制模板触发前台刷新。
- 上传不同类型素材,验证必填校验、文件校验与标签显示。
- 首页筛选(模板 + 素材类型)可用。
## 3. 常见问题(FAQ)
| 问题 | 说明 | 排查建议 |
|------|------|----------|
| 模板保存后 `materialType` 为空 | 旧模板未手动选择类型 | 在后台编辑模板,补全素材类型后再次保存 |
| 前台上传仍出现“素材类型”字段 | 构建未更新或缓存未失效 | 清理 `.next`、重新构建并刷新浏览器缓存 |
| `/api/material-types` 返回旧数据 | 未部署最新 API 或存在历史模板 | 确认路由已更新,并检查 `upload_templates.materialType` |
| 预制模板仍包含 CAD/C4D | `enabledBuiltinTemplates` 仍记录旧 key | 在数据库 `system_settings.enabledBuiltinTemplates` 中清理旧 key |
## 4. 建议的提交流程
1. **开发**:在本地完成所有代码改造 + 文档更新。
2. **检查**:`npm run lint` / `npm run build`,确保无类型/构建错误。
3. **部署**:执行 `deploy-new-fixed.ps1`(或 CI/CD 流程),留意镜像推送日志。
4. **验证**:访问 `/admin` 与 `/`,测试 6 种模板上传路径。
---
> 如需进一步扩展素材类型,请在 `BUILTIN_TEMPLATES` 与 `BUILTIN_TEMPLATE_TYPES` 中新增配置,并同步更新本文档与 `docs/素材类型模板配置记录.md`。 AI 平台可直接搜索“素材模板迁移指南”获取最新步骤。*** End Patch