This commit is contained in:
parent
58c7b21b22
commit
90af4a20fc
@ -29,6 +29,7 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [scenes, setScenes] = useState<Scene[]>([])
|
||||
const [platforms, setPlatforms] = useState<Platform[]>([])
|
||||
const [templateCategories, setTemplateCategories] = useState<Array<{ fieldKey: string; fieldLabel: string; options: string[] }>>([])
|
||||
const [editingCategory, setEditingCategory] = useState<Category | null>(null)
|
||||
const [editingScene, setEditingScene] = useState<Scene | null>(null)
|
||||
const [editingPlatform, setEditingPlatform] = useState<Platform | null>(null)
|
||||
@ -52,10 +53,11 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const [categoriesRes, scenesRes, platformsRes] = await Promise.all([
|
||||
const [categoriesRes, scenesRes, platformsRes, templateCategoriesRes] = await Promise.all([
|
||||
fetch('/api/categories'),
|
||||
fetch('/api/scenes'),
|
||||
fetch('/api/platforms')
|
||||
fetch('/api/platforms'),
|
||||
fetch('/api/template-categories')
|
||||
])
|
||||
if (categoriesRes.ok) {
|
||||
const data = await categoriesRes.json()
|
||||
@ -69,6 +71,10 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
|
||||
const data = await platformsRes.json()
|
||||
setPlatforms(data)
|
||||
}
|
||||
if (templateCategoriesRes.ok) {
|
||||
const data = await templateCategoriesRes.json()
|
||||
setTemplateCategories(data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching categories data:', error)
|
||||
}
|
||||
@ -669,12 +675,57 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* 使用店铺分类 */}
|
||||
{/* 模板分类(从模板管理中提取) */}
|
||||
{templateCategories.length > 0 && (
|
||||
<div className={cn(styles.cardStyles.base)}>
|
||||
<div className="px-6 py-4 border-b border-slate-200 dark:border-slate-800">
|
||||
<div>
|
||||
<h2 className={cn('text-lg font-semibold', textTitle)}>模板分类</h2>
|
||||
<p className={cn('text-sm mt-1', textHint)}>
|
||||
以下分类来自模板管理中的下拉选项(visibleOnHomeFilter=true 的 select 字段)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="space-y-6">
|
||||
{templateCategories.map((templateCategory) => (
|
||||
<div key={templateCategory.fieldKey} className="border border-slate-200 dark:border-slate-700 rounded-lg p-4">
|
||||
<h3 className={cn('text-base font-medium mb-3', textTitle)}>
|
||||
{templateCategory.fieldLabel} ({templateCategory.fieldKey})
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{templateCategory.options.map((option) => (
|
||||
<span
|
||||
key={option}
|
||||
className={cn(
|
||||
'px-3 py-1 rounded-full text-sm',
|
||||
'bg-slate-100 dark:bg-slate-700',
|
||||
'text-slate-700 dark:text-slate-300',
|
||||
'border border-slate-200 dark:border-slate-600'
|
||||
)}
|
||||
>
|
||||
{option}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className={cn('text-xs mt-2', textHint)}>
|
||||
共 {templateCategory.options.length} 个选项
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 使用店铺分类(保留用于向后兼容) */}
|
||||
<div className={cn(styles.cardStyles.base)}>
|
||||
<div className="px-6 py-4 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className={cn('text-lg font-semibold', textTitle)}>使用店铺分类</h2>
|
||||
<p className={cn('text-sm mt-1', textHint)}>管理所有使用店铺分类名称</p>
|
||||
<p className={cn('text-sm mt-1', textHint)}>
|
||||
管理所有使用店铺分类名称(已废弃,建议使用模板分类)
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAddCategoryModal(true)}
|
||||
|
||||
@ -62,6 +62,18 @@ export async function POST(req: NextRequest) {
|
||||
const imageResolution = normalizeInput(formData.get('imageResolution'))
|
||||
const colorMode = normalizeInput(formData.get('colorMode'))
|
||||
|
||||
// 模板相关字段
|
||||
const templateId = formData.get('templateId') as string | null
|
||||
const templateFieldsStr = formData.get('templateFields') as string | null
|
||||
let templateFields: Record<string, any> = {}
|
||||
if (templateFieldsStr) {
|
||||
try {
|
||||
templateFields = JSON.parse(templateFieldsStr)
|
||||
} catch (error) {
|
||||
console.error('[Upload] Failed to parse templateFields:', error)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[Upload] Form data received:', {
|
||||
hasPreviewImage: !!previewImage,
|
||||
previewImageSize: previewImage?.size,
|
||||
@ -370,6 +382,15 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
// Create material record
|
||||
console.log('[Upload] Creating database record...')
|
||||
// 构建 metadata,包含模板字段值
|
||||
let metadata: string | null = null
|
||||
if (templateId && Object.keys(templateFields).length > 0) {
|
||||
metadata = JSON.stringify({
|
||||
templateId,
|
||||
templateFields,
|
||||
})
|
||||
}
|
||||
|
||||
const material = await prisma.material.create({
|
||||
data: {
|
||||
previewUrl,
|
||||
@ -391,6 +412,8 @@ export async function POST(req: NextRequest) {
|
||||
zipFilePath,
|
||||
zipFileSize,
|
||||
displayImages: JSON.stringify(displayImages), // PSD/模特展示图片URLs数组
|
||||
metadata, // 模板ID和字段值
|
||||
templateId: templateId || null, // 直接存储模板ID,方便查询
|
||||
userId: (session.user as any).id,
|
||||
},
|
||||
include: {
|
||||
|
||||
99
app/api/template-categories/route.ts
Normal file
99
app/api/template-categories/route.ts
Normal file
@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 模板分类 API
|
||||
*
|
||||
* 从模板管理中提取分类选项
|
||||
* 返回所有 visibleOnHomeFilter=true 且 type='select' 的字段的选项
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// 获取所有启用的模板
|
||||
const templates = await prisma.uploadTemplate.findMany({
|
||||
where: {
|
||||
status: 'active',
|
||||
},
|
||||
include: {
|
||||
fields: {
|
||||
where: {
|
||||
visibleOnHomeFilter: true,
|
||||
type: 'select',
|
||||
},
|
||||
orderBy: { orderIndex: 'asc' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// 提取所有分类选项
|
||||
const categoriesMap = new Map<string, Set<string>>()
|
||||
|
||||
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())
|
||||
}
|
||||
options.forEach((option: string) => {
|
||||
categoriesMap.get(fieldKey)!.add(option)
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to parse optionsStatic for field ${field.key}:`, error)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 转换为数组格式
|
||||
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: [...new Set(options)].sort(),
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to parse optionsStatic for field ${field.key}:`, error)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return NextResponse.json(categories)
|
||||
} catch (error) {
|
||||
console.error('Error fetching template categories:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '获取模板分类失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
58
app/api/upload-templates/route.ts
Normal file
58
app/api/upload-templates/route.ts
Normal file
@ -0,0 +1,58 @@
|
||||
/**
|
||||
* 公共上传模板 API
|
||||
*
|
||||
* 获取启用的上传模板列表(不需要管理员权限)
|
||||
* 用于上传页面选择模板
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
const publicSelect = {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
status: true,
|
||||
fields: {
|
||||
where: {
|
||||
// 只返回非只读字段,只读字段不需要用户填写
|
||||
isReadOnly: false,
|
||||
},
|
||||
orderBy: { orderIndex: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
key: true,
|
||||
label: true,
|
||||
type: true,
|
||||
required: true,
|
||||
orderIndex: true,
|
||||
layoutWidth: true,
|
||||
validations: true,
|
||||
optionsSource: true,
|
||||
optionsStatic: true,
|
||||
allowedExtensions: true,
|
||||
visibleOnHomeFilter: true,
|
||||
isSearchable: true,
|
||||
conditions: true,
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// 只返回状态为 'active' 的模板
|
||||
const templates = await prisma.uploadTemplate.findMany({
|
||||
where: {
|
||||
status: 'active',
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
select: publicSelect,
|
||||
})
|
||||
|
||||
return NextResponse.json(templates)
|
||||
} catch (error) {
|
||||
console.error('Get active upload templates error:', error)
|
||||
return NextResponse.json({ error: '获取上传模板失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@ -55,7 +55,13 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
const [platformName, setPlatformName] = useState('')
|
||||
const [platforms, setPlatforms] = useState<Array<{ id: string; name: string }>>([])
|
||||
|
||||
// 素材类型
|
||||
// 模板相关
|
||||
const [templates, setTemplates] = useState<Array<{ id: string; name: string; description: string | null; fields: any[] }>>([])
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string>('')
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<any>(null)
|
||||
const [templateFieldValues, setTemplateFieldValues] = useState<Record<string, any>>({})
|
||||
|
||||
// 素材类型(保留用于向后兼容,但优先使用模板)
|
||||
const [materialType, setMaterialType] = useState('3DMAX')
|
||||
|
||||
// 渲染器(仅3DMAX使用)
|
||||
@ -224,11 +230,12 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
}
|
||||
}, [uploading, saveUploadState])
|
||||
|
||||
// 加载分类和场景列表
|
||||
// 加载分类、场景列表和模板列表
|
||||
useEffect(() => {
|
||||
fetchCategories()
|
||||
fetchScenes()
|
||||
fetchPlatforms()
|
||||
fetchTemplates()
|
||||
}, [])
|
||||
|
||||
const fetchCategories = async () => {
|
||||
@ -279,6 +286,52 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// 获取启用的模板列表
|
||||
const fetchTemplates = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/upload-templates')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setTemplates(data || [])
|
||||
console.log('[UploadModal] Templates loaded:', data?.length || 0)
|
||||
// 如果有模板且只有一个,自动选择
|
||||
if (data && data.length === 1) {
|
||||
handleTemplateChange(data[0].id)
|
||||
}
|
||||
} else {
|
||||
const errorData = await response.json().catch(() => ({ error: '未知错误' }))
|
||||
console.error('[UploadModal] Failed to fetch templates:', response.status, errorData)
|
||||
setError(`加载模板列表失败: ${errorData.error || '未知错误'}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[UploadModal] Error fetching templates:', error)
|
||||
setError('加载模板列表失败,请刷新页面重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 处理模板选择变化
|
||||
const handleTemplateChange = (templateId: string) => {
|
||||
setSelectedTemplateId(templateId)
|
||||
const template = templates.find(t => t.id === templateId)
|
||||
setSelectedTemplate(template || null)
|
||||
// 清空之前的字段值
|
||||
setTemplateFieldValues({})
|
||||
// 如果有模板,初始化字段值
|
||||
if (template) {
|
||||
const initialValues: Record<string, any> = {}
|
||||
template.fields.forEach((field: any) => {
|
||||
if (field.type === 'checkbox') {
|
||||
initialValues[field.key] = false
|
||||
} else if (field.type === 'select' || field.type === 'radio') {
|
||||
initialValues[field.key] = ''
|
||||
} else {
|
||||
initialValues[field.key] = ''
|
||||
}
|
||||
})
|
||||
setTemplateFieldValues(initialValues)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePreviewChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
@ -617,6 +670,25 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
// 验证模板选择
|
||||
if (!selectedTemplateId) {
|
||||
setError('请选择素材模板')
|
||||
return
|
||||
}
|
||||
|
||||
// 验证模板必填字段
|
||||
if (selectedTemplate && selectedTemplate.fields) {
|
||||
for (const field of selectedTemplate.fields) {
|
||||
if (field.required) {
|
||||
const value = templateFieldValues[field.key]
|
||||
if (!value || (typeof value === 'string' && value.trim() === '')) {
|
||||
setError(`请填写必填字段:${field.label}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validation - 基础信息验证
|
||||
if (!productModel) {
|
||||
setError(materialType === '材质' ? '请填写材质名称' : '请填写SPU型号')
|
||||
@ -905,28 +977,216 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||
{/* 素材类型 - 第一栏,让用户先选择类型 */}
|
||||
{/* 素材模板 - 第一栏,让用户先选择模板 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
素材类型 *
|
||||
素材模板 *
|
||||
</label>
|
||||
{templates.length > 0 ? (
|
||||
<select
|
||||
value={materialType}
|
||||
onChange={(e) => setMaterialType(e.target.value)}
|
||||
className="w-full px-4 py-2 rounded-apple border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-white focus:border-slate-900 dark:focus:border-slate-400 focus:ring-2 focus:ring-slate-200 dark:focus:ring-slate-600 outline-none"
|
||||
value={selectedTemplateId}
|
||||
onChange={(e) => handleTemplateChange(e.target.value)}
|
||||
className={cn(
|
||||
'w-full px-4 py-2 rounded-apple border outline-none',
|
||||
styles.inputStyles.base
|
||||
)}
|
||||
required
|
||||
>
|
||||
<option value="3DMAX">3DMAX</option>
|
||||
<option value="PSD">PSD</option>
|
||||
<option value="AI">AI</option>
|
||||
<option value="CAD">CAD</option>
|
||||
<option value="C4D">C4D</option>
|
||||
<option value="视频">视频</option>
|
||||
<option value="材质">材质</option>
|
||||
<option value="模特">模特</option>
|
||||
<option value="标准饰品">标准饰品</option>
|
||||
<option value="其他">其他</option>
|
||||
<option value="">请选择模板</option>
|
||||
{templates.map((template) => (
|
||||
<option key={template.id} value={template.id}>
|
||||
{template.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div className="text-sm text-slate-500 dark:text-slate-400">
|
||||
暂无可用模板,请联系管理员
|
||||
</div>
|
||||
)}
|
||||
{selectedTemplate?.description && (
|
||||
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
{selectedTemplate.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 动态渲染模板字段 */}
|
||||
{selectedTemplate && selectedTemplate.fields && selectedTemplate.fields.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="border-t border-slate-200 dark:border-slate-700 pt-4">
|
||||
<h3 className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-4">
|
||||
模板字段
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{selectedTemplate.fields.map((field: any) => {
|
||||
const fieldValue = templateFieldValues[field.key] || ''
|
||||
const isHalfWidth = field.layoutWidth === 'half'
|
||||
|
||||
return (
|
||||
<div
|
||||
key={field.id}
|
||||
className={isHalfWidth ? 'w-full sm:w-1/2 inline-block sm:pr-2' : 'w-full'}
|
||||
>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
{field.label}
|
||||
{field.required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
{field.type === 'text' || field.type === 'uppercase' ? (
|
||||
<Input
|
||||
type="text"
|
||||
value={fieldValue}
|
||||
onChange={(e) => {
|
||||
const value = field.type === 'uppercase'
|
||||
? e.target.value.toUpperCase()
|
||||
: e.target.value
|
||||
setTemplateFieldValues({
|
||||
...templateFieldValues,
|
||||
[field.key]: value,
|
||||
})
|
||||
}}
|
||||
required={field.required}
|
||||
className="w-full"
|
||||
/>
|
||||
) : field.type === 'textarea' ? (
|
||||
<textarea
|
||||
value={fieldValue}
|
||||
onChange={(e) => {
|
||||
setTemplateFieldValues({
|
||||
...templateFieldValues,
|
||||
[field.key]: e.target.value,
|
||||
})
|
||||
}}
|
||||
required={field.required}
|
||||
className={cn(
|
||||
'w-full px-4 py-2 rounded-apple border outline-none',
|
||||
styles.inputStyles.base
|
||||
)}
|
||||
rows={3}
|
||||
/>
|
||||
) : field.type === 'number' ? (
|
||||
<Input
|
||||
type="number"
|
||||
value={fieldValue}
|
||||
onChange={(e) => {
|
||||
setTemplateFieldValues({
|
||||
...templateFieldValues,
|
||||
[field.key]: e.target.value,
|
||||
})
|
||||
}}
|
||||
required={field.required}
|
||||
className="w-full"
|
||||
/>
|
||||
) : field.type === 'select' ? (
|
||||
<select
|
||||
value={fieldValue}
|
||||
onChange={(e) => {
|
||||
setTemplateFieldValues({
|
||||
...templateFieldValues,
|
||||
[field.key]: e.target.value,
|
||||
})
|
||||
}}
|
||||
required={field.required}
|
||||
className={cn(
|
||||
'w-full px-4 py-2 rounded-apple border outline-none',
|
||||
styles.inputStyles.base
|
||||
)}
|
||||
>
|
||||
<option value="">请选择</option>
|
||||
{field.optionsStatic ? (
|
||||
(() => {
|
||||
try {
|
||||
const options = typeof field.optionsStatic === 'string'
|
||||
? JSON.parse(field.optionsStatic)
|
||||
: field.optionsStatic
|
||||
return Array.isArray(options)
|
||||
? options.map((opt: string) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))
|
||||
: null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
) : null}
|
||||
</select>
|
||||
) : field.type === 'checkbox' ? (
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fieldValue || false}
|
||||
onChange={(e) => {
|
||||
setTemplateFieldValues({
|
||||
...templateFieldValues,
|
||||
[field.key]: e.target.checked,
|
||||
})
|
||||
}}
|
||||
className="w-4 h-4 rounded border-slate-300 dark:border-slate-600"
|
||||
/>
|
||||
<span className="text-sm text-slate-700 dark:text-slate-300">
|
||||
{field.label}
|
||||
</span>
|
||||
</label>
|
||||
) : field.type === 'radio' ? (
|
||||
<div className="space-y-2">
|
||||
{field.optionsStatic ? (
|
||||
(() => {
|
||||
try {
|
||||
const options = typeof field.optionsStatic === 'string'
|
||||
? JSON.parse(field.optionsStatic)
|
||||
: field.optionsStatic
|
||||
return Array.isArray(options)
|
||||
? options.map((opt: string) => (
|
||||
<label key={opt} className="flex items-center space-x-2">
|
||||
<input
|
||||
type="radio"
|
||||
name={field.key}
|
||||
value={opt}
|
||||
checked={fieldValue === opt}
|
||||
onChange={(e) => {
|
||||
setTemplateFieldValues({
|
||||
...templateFieldValues,
|
||||
[field.key]: e.target.value,
|
||||
})
|
||||
}}
|
||||
required={field.required}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="text-sm text-slate-700 dark:text-slate-300">
|
||||
{opt}
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
: null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
) : null}
|
||||
</div>
|
||||
) : field.type === 'date' ? (
|
||||
<Input
|
||||
type="date"
|
||||
value={fieldValue}
|
||||
onChange={(e) => {
|
||||
setTemplateFieldValues({
|
||||
...templateFieldValues,
|
||||
[field.key]: e.target.value,
|
||||
})
|
||||
}}
|
||||
required={field.required}
|
||||
className="w-full"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 视频类型预览图(视频封面) */}
|
||||
{materialType === '视频' && (
|
||||
|
||||
24
优化计划与优先级.md
24
优化计划与优先级.md
@ -260,21 +260,33 @@
|
||||
#### 2.2 模板管理整合到上传和分类 ⭐⭐⭐⭐⭐
|
||||
**需求编号**:14
|
||||
**优先级**:最高(核心功能)
|
||||
**状态**:✅ 已完成
|
||||
**原因**:
|
||||
- 这是核心业务逻辑,将前后端通过模板串起来
|
||||
- 影响整个系统的数据架构
|
||||
- 需要严格检查模板管理面板逻辑
|
||||
|
||||
**实施要点**:
|
||||
- 首页上传素材面板:将"素材类型"改为"素材模板",从模板管理拉取模板名称
|
||||
- 选择模板后,动态显示对应模板的字段
|
||||
- 系统管理中的分类管理:使用模板管理中的下拉元素文字做分类
|
||||
- 严格检查模板管理面板逻辑:
|
||||
- ✅ 首页上传素材面板:将"素材类型"改为"素材模板",从模板管理拉取模板名称
|
||||
- 创建公共 API 端点 `/api/upload-templates` 获取启用的模板列表
|
||||
- 修改 UploadModal 组件,添加模板选择功能
|
||||
- ✅ 选择模板后,动态显示对应模板的字段
|
||||
- 支持多种字段类型:text、textarea、number、select、checkbox、radio、date
|
||||
- 根据字段的 `layoutWidth` 属性支持半行/整行布局
|
||||
- 自动验证必填字段
|
||||
- ✅ 系统管理中的分类管理:使用模板管理中的下拉元素文字做分类
|
||||
- 创建 `/api/template-categories` API 提取模板中的分类选项
|
||||
- 从所有启用的模板中提取 `visibleOnHomeFilter=true` 且 `type='select'` 的字段选项
|
||||
- 在分类管理界面显示模板分类,保留原有分类管理用于向后兼容
|
||||
- ✅ 修改上传逻辑:提交模板ID和字段值
|
||||
- 在 `metadata` 字段中存储模板字段值(JSON格式)
|
||||
- 在 `templateId` 字段中直接存储模板ID,方便查询
|
||||
- ⏳ 严格检查模板管理面板逻辑(待后续完善):
|
||||
- 字段验证(键名唯一性、类型检查)
|
||||
- 模板保存/更新逻辑
|
||||
- 模板删除时的依赖检查
|
||||
- 模板使用情况统计
|
||||
- 确保数据一致性:模板修改后,已上传的素材如何处理
|
||||
- ⏳ 确保数据一致性:模板修改后,已上传的素材如何处理(待后续完善)
|
||||
|
||||
**依赖关系**:无(但需要先确保模板管理逻辑完善)
|
||||
**影响范围**:上传功能、分类管理、模板管理
|
||||
@ -538,7 +550,7 @@
|
||||
|
||||
### 第二阶段:数据一致性与核心功能(2-3周)
|
||||
8. ✅ 用户信息修改后同步素材信息(2.1)- 已完成
|
||||
9. ⏳ 模板管理整合(2.2)- 需要严格测试
|
||||
9. ✅ 模板管理整合(2.2)- 已完成(核心功能已实现,待完善模板管理面板逻辑检查)
|
||||
10. ⏳ 首页类型筛选TAG系统(2.3)
|
||||
|
||||
### 第三阶段:用户体验优化(2-3周)
|
||||
|
||||
18
优化记录.md
18
优化记录.md
@ -406,10 +406,20 @@ export default function MyComponent() {
|
||||
- 更新 `Tag` 组件使用新的 `accentStyles`,所有颜色都使用低饱和度炫彩元素
|
||||
|
||||
**优先级 2:数据一致性与核心功能**
|
||||
5. ⏳ 用户信息修改后同步素材信息
|
||||
- 在 `useUserOperations` 中扩展
|
||||
6. ⏳ 模板管理整合到上传和分类
|
||||
- 严格检查模板管理面板逻辑
|
||||
5. ✅ 用户信息修改后同步素材信息
|
||||
- ✅ API 端已实现:当用户名变化时,使用事务同步更新该用户所有素材的 `authorName`
|
||||
- ✅ 前端已处理:`useUserOperations.updateUser` 处理 `materialsUpdated` 返回值并显示提示
|
||||
- ✅ 使用事务确保数据一致性
|
||||
- ✅ 错误处理和回滚机制已实现
|
||||
6. ✅ 模板管理整合到上传和分类
|
||||
- ✅ 创建公共 API 端点 `/api/upload-templates` 获取启用的模板列表(不需要管理员权限)
|
||||
- ✅ 修改 UploadModal:将"素材类型"改为"素材模板",从 API 拉取模板列表
|
||||
- ✅ 实现动态字段渲染:选择模板后根据模板字段动态生成表单(支持 text、textarea、number、select、checkbox、radio、date 等类型)
|
||||
- ✅ 根据字段的 `layoutWidth` 属性支持半行/整行布局
|
||||
- ✅ 修改上传逻辑:提交模板ID和字段值到数据库(存储在 `metadata` 和 `templateId` 字段)
|
||||
- ✅ 创建模板分类 API `/api/template-categories`:从模板管理中提取所有 `visibleOnHomeFilter=true` 且 `type='select'` 的字段选项
|
||||
- ✅ 修改分类管理界面:显示从模板中提取的分类选项,保留原有分类管理用于向后兼容
|
||||
- ⏳ 模板管理面板逻辑检查(待后续完善)
|
||||
- 将模板系统串起前后端
|
||||
|
||||
**详细计划**:请查看 `优化计划与优先级.md`
|
||||
|
||||
10
后续修改.txt
10
后续修改.txt
@ -81,15 +81,15 @@
|
||||
- 点击头像菜单里,"系统设置"改名为"管理",图标也换一下
|
||||
- "外观设置"也移到头像菜单里,前面加个图标
|
||||
|
||||
14. 模板管理整合 🔗
|
||||
14. 模板管理整合 🔗 ✅
|
||||
现在模板管理已经做好了,要把前后端串起来:
|
||||
- 首页上传素材面板:把"素材类型"改成"素材模板"
|
||||
✅ 首页上传素材面板:把"素材类型"改成"素材模板"
|
||||
列表直接拉取模板管理中的模板名称
|
||||
- 选择模板后,界面显示对应模板的字段
|
||||
- 系统管理中的分类管理:
|
||||
✅ 选择模板后,界面显示对应模板的字段
|
||||
✅ 系统管理中的分类管理:
|
||||
不再用"店铺分类"和"场景类别"
|
||||
改用模板管理中的下拉选项文字做分类
|
||||
⚠️ 模板管理的面板逻辑要严格检查,这个很重要!
|
||||
⚠️ 模板管理的面板逻辑要严格检查,这个很重要!(待后续完善)
|
||||
|
||||
15. 系统监控交互增强 📈
|
||||
- 用户总数、活跃用户、总素材数、本月新用户、本月新素材
|
||||
|
||||
27
更新日志.md
27
更新日志.md
@ -1,6 +1,26 @@
|
||||
## 🧾 2025-01-XX · 「UI优化与功能完善」版本 🎨
|
||||
## 🧾 2025-01-XX · 「模板管理整合与UI优化」版本 🎨
|
||||
|
||||
本次更新完成了低饱和度炫彩元素设计系统、深浅色主题适配优化,以及用户信息同步素材信息功能。
|
||||
本次更新完成了模板管理整合到上传和分类功能,实现了动态字段渲染,并优化了UI样式系统。
|
||||
|
||||
---
|
||||
|
||||
### 🔗 核心功能:模板管理整合
|
||||
|
||||
- ✨ **上传面板模板化**
|
||||
- 将"素材类型"改为"素材模板",从模板管理拉取启用的模板列表
|
||||
- 选择模板后动态显示对应模板的字段(支持 text、textarea、number、select、checkbox、radio、date 等类型)
|
||||
- 根据字段的 `layoutWidth` 属性支持半行/整行布局
|
||||
- 自动验证必填字段,提升用户体验
|
||||
|
||||
- 📦 **数据存储优化**
|
||||
- 上传时提交模板ID和字段值到数据库
|
||||
- 在 `metadata` 字段中存储模板字段值(JSON格式)
|
||||
- 在 `templateId` 字段中直接存储模板ID,方便查询和统计
|
||||
|
||||
- 🏷️ **分类管理模板化**
|
||||
- 创建模板分类 API,从模板管理中提取所有 `visibleOnHomeFilter=true` 且 `type='select'` 的字段选项
|
||||
- 分类管理界面显示从模板中提取的分类选项
|
||||
- 保留原有分类管理用于向后兼容
|
||||
|
||||
---
|
||||
|
||||
@ -33,6 +53,9 @@
|
||||
- 更新样式组合器,导出 `accentStyles` 供组件使用
|
||||
- 所有组件已添加文件头注释,代码可维护性提升
|
||||
|
||||
- ✅ 修复 Input 组件类型冲突
|
||||
- 使用 `Omit<InputHTMLAttributes<HTMLInputElement>, 'size'>` 解决 size 属性类型冲突
|
||||
|
||||
---
|
||||
|
||||
## 🧾 2025-11-22 · 「首页UI完全统一 & 通用组件库创建」版本 🎨
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user