- 创建统一的Modal组件,采用Apple风格设计 - 实现毛玻璃效果、圆角、动画和细边框描边 - 修复弹窗打开时背景滚动问题 - 修复分类管理中重复卡片和数组字符串显示问题 - 优化选项解析逻辑,添加去重和过滤机制
This commit is contained in:
parent
492b97213e
commit
8705b9846a
5
.gitignore
vendored
5
.gitignore
vendored
@ -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
|
||||
|
||||
@ -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 不会失败
|
||||
|
||||
@ -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
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) => (
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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',
|
||||
)}
|
||||
>
|
||||
|
||||
@ -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="清空模板搜索"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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="直接输入选项,每行一个,例如: 3DMAX PSD AI CAD C4D"
|
||||
/>
|
||||
<div className="mt-2 p-2.5 bg-blue-50/50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<p className="text-[10px] font-semibold text-blue-700 dark:text-blue-300 mb-1.5">填写说明:</p>
|
||||
<div className="text-[10px] text-blue-600 dark:text-blue-400">
|
||||
<p className="mb-1.5">每行输入一个选项,例如:</p>
|
||||
<div className="ml-3 font-mono bg-white dark:bg-slate-800 px-2 py-1 rounded border border-blue-200 dark:border-blue-700">
|
||||
3DMAX
|
||||
<br />
|
||||
PSD
|
||||
<br />
|
||||
AI
|
||||
<br />
|
||||
CAD
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 下拉选项已移除,选项在分类管理中管理 */}
|
||||
|
||||
<div className="flex flex-wrap gap-3 mt-3 pt-3 border-t border-slate-200/60 dark:border-slate-700/60">
|
||||
<label className="flex items-center gap-1.5 text-xs text-slate-700 dark:text-slate-300 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.required}
|
||||
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="{field.type}", isSelectType={String(isSelectType)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{placeholderAfter && renderDropPlaceholder(`editor-after-${index}`)}
|
||||
|
||||
@ -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[]
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
|
||||
88
app/api/category-fields/route.ts
Normal file
88
app/api/category-fields/route.ts
Normal 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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
74
app/api/material-types/route.ts
Normal file
74
app/api/material-types/route.ts
Normal 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 工作流', '视频', '模特', '其他'])
|
||||
}
|
||||
}
|
||||
|
||||
@ -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') {
|
||||
|
||||
@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
346
app/api/template-categories/[fieldKey]/route.ts
Normal file
346
app/api/template-categories/[fieldKey]/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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() {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
91
app/api/templates/route.ts
Normal file
91
app/api/templates/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
279
app/page.tsx
279
app/page.tsx
@ -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
323
app/ui-preview/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
@ -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
|
||||
)}
|
||||
|
||||
@ -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
|
||||
)}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
3
docker-images/.gitkeep
Normal file
@ -0,0 +1,3 @@
|
||||
# 此文件用于保持目录结构
|
||||
# 实际的镜像文件 (.tar) 已被 .gitignore 排除
|
||||
|
||||
67
docker-images/README.md
Normal file
67
docker-images/README.md
Normal 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 的镜像列表
|
||||
|
||||
5
docker-images/minio-mc/VERSION.txt
Normal file
5
docker-images/minio-mc/VERSION.txt
Normal file
@ -0,0 +1,5 @@
|
||||
latest - 2025-11-23
|
||||
Image: minio/mc:latest
|
||||
Digest: sha256:a7fe349ef4bd8521fb8497f55c6042871b2ae640607cf99d9bede5e9bdf11727
|
||||
Size: ~85MB
|
||||
|
||||
140
docs/FIELD_CATEGORY_UPDATES.md
Normal file
140
docs/FIELD_CATEGORY_UPDATES.md
Normal 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
|
||||
**状态**: ✅ 已完成
|
||||
|
||||
91
docs/MINIO_INIT_VERSION.md
Normal file
91
docs/MINIO_INIT_VERSION.md
Normal 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` 服务
|
||||
|
||||
142
docs/TEMPLATE_IMPROVEMENTS.md
Normal file
142
docs/TEMPLATE_IMPROVEMENTS.md
Normal 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
223
docs/TEST_CHECKLIST.md
Normal 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: 缓存清除
|
||||
|
||||
**测试日期**: _______________
|
||||
**测试人员**: _______________
|
||||
**测试结果**: _______________
|
||||
**发现问题**: _______________
|
||||
|
||||
110
docs/scripts/DEPLOY_SCRIPT_OPTIMIZATION.md
Normal file
110
docs/scripts/DEPLOY_SCRIPT_OPTIMIZATION.md
Normal 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`),但会降低代码可读性。
|
||||
|
||||
160
docs/素材类型模板配置记录.md
Normal file
160
docs/素材类型模板配置记录.md
Normal 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
127
lib/category-fields.ts
Normal 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))
|
||||
}
|
||||
|
||||
20
lib/system-settings-columns.ts
Normal file
20
lib/system-settings-columns.ts
Normal 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')
|
||||
}
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
220
lib/upload-template-presets.ts
Normal file
220
lib/upload-template-presets.ts
Normal 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
BIN
minio-mc-latest.tar
Normal file
Binary file not shown.
@ -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")
|
||||
}
|
||||
|
||||
19
scripts/apply-migration.sh
Normal file
19
scripts/apply-migration.sh
Normal 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
|
||||
|
||||
98
scripts/download-minio-mc.ps1
Normal file
98
scripts/download-minio-mc.ps1
Normal 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
|
||||
|
||||
57
scripts/load-base-images.ps1
Normal file
57
scripts/load-base-images.ps1
Normal 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
|
||||
}
|
||||
|
||||
270
scripts/manage-docker-images.ps1
Normal file
270
scripts/manage-docker-images.ps1
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
46
scripts/run-migration-on-server.sh
Normal file
46
scripts/run-migration-on-server.sh
Normal 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 "=========================================="
|
||||
|
||||
46
优化计划与优先级.md
46
优化计划与优先级.md
@ -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)
|
||||
**影响范围**:上传功能
|
||||
|
||||
24
优化记录.md
24
优化记录.md
@ -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`
|
||||
|
||||
10
后续修改.txt
10
后续修改.txt
@ -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形式显示类型筛选,支持点击切换
|
||||
✅ 已完成:使用低饱和度炫彩元素样式,符合整体设计风格
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
🎛️ 系统管理优化
|
||||
|
||||
79
更新日志.md
79
更新日志.md
@ -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优化
|
||||
|
||||
82
素材模板迁移指南.md
Normal file
82
素材模板迁移指南.md
Normal 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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user