gongxiangp2/components/upload/UploadForm.tsx

489 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 上传表单字段组件
* 包含 SPU、店铺、场景、平台、品类、渲染器等表单字段
*/
'use client'
import { useState, useEffect, useRef } from 'react'
import { requiresCategoryAndScene, requiresProductCategoryName, requiresRenderer, shouldAutoFillGeneral } from '@/lib/material-validation'
import {
fieldControlClass,
fieldErrorClass,
fieldHintClass,
fieldLabelClass,
inkButtonClass,
outlineButtonClass,
} from '@/components/upload/upload-field-styles'
import { parseSPU, type SPUParseResult } from '@/lib/spu-parser'
import { isVideoMaterialType } from '@/lib/video-material-types'
import {
getSpuProductModelValidationError,
isPlausibleSpuProductModel,
normalizeSpuProductModel,
} from '@/lib/product-model'
/**
* 分类数据项
*/
interface CategoryItem {
id: string
name: string
}
/**
* 表单字段 Props
*/
export interface UploadFormProps {
/** 素材类型 */
materialType: string
/** SPU型号 */
productModel: string
/** 店铺名称 */
categoryName: string
/** 场景名称 */
sceneName: string
/** 平台名称 */
platformName: string
/** 品类名称 */
productCategoryName: string
/** 渲染器 */
renderer: string
/** SKU(视频类素材和任务-详情使用) */
sku?: string
/** 店铺列表 */
categories: CategoryItem[]
/** 场景列表 */
scenes: CategoryItem[]
/** 平台列表 */
platforms: CategoryItem[]
/** 品类列表 */
productCategories: CategoryItem[]
/** 是否批量模式(批量模式下隐藏部分字段) */
isBatchMode?: boolean
/** SPU变更回调 */
onProductModelChange: (value: string) => void
/** 店铺变更回调 */
onCategoryChange: (value: string) => void
/** 场景变更回调 */
onSceneChange: (value: string) => void
/** 平台变更回调 */
onPlatformChange: (value: string) => void
/** 品类变更回调 */
onProductCategoryChange: (value: string) => void
/** 渲染器变更回调 */
onRendererChange: (value: string) => void
/** SKU变更回调 */
onSkuChange?: (value: string) => void
/** 添加店铺回调 */
onAddCategory: (name: string) => Promise<void>
/** 添加场景回调 */
onAddScene: (name: string) => Promise<void>
}
/**
* 上传表单字段组件
*/
export default function UploadForm({
materialType,
productModel,
categoryName,
sceneName,
platformName,
productCategoryName,
renderer,
sku = '',
categories,
scenes,
platforms,
productCategories,
isBatchMode = false,
onProductModelChange,
onCategoryChange,
onSceneChange,
onPlatformChange,
onProductCategoryChange,
onRendererChange,
onSkuChange,
onAddCategory,
onAddScene,
}: UploadFormProps) {
// 新建店铺/场景输入状态
const [showNewCategoryInput, setShowNewCategoryInput] = useState(false)
const [newCategoryName, setNewCategoryName] = useState('')
const [showNewSceneInput, setShowNewSceneInput] = useState(false)
const [newSceneName, setNewSceneName] = useState('')
// SPU 自动识别状态
const [spuParseResult, setSpuParseResult] = useState<SPUParseResult | null>(null)
const [shopAutoFilled, setShopAutoFilled] = useState(false)
const [categoryAutoFilled, setCategoryAutoFilled] = useState(false)
const lastParsedSPU = useRef<string>('')
const isAutoGeneralType = shouldAutoFillGeneral(materialType)
// SPU 变化时自动解析并填充店铺和品类
useEffect(() => {
// 避免重复解析相同的 SPU
if (productModel === lastParsedSPU.current) {
return
}
lastParsedSPU.current = productModel
// 材质类型不需要解析
if (materialType === '材质' || !productModel) {
setSpuParseResult(null)
setShopAutoFilled(false)
setCategoryAutoFilled(false)
return
}
const result = parseSPU(productModel)
setSpuParseResult(result)
// 自动填充店铺(如果识别成功且店铺列表中存在该店铺)
if (result.shopMatched && result.shopName) {
const shopExists = categories.some(c => c.name === result.shopName)
if (shopExists && categoryName !== result.shopName) {
onCategoryChange(result.shopName)
setShopAutoFilled(true)
} else if (!shopExists) {
// 店铺不在列表中,清除自动填充标记
setShopAutoFilled(false)
}
} else {
setShopAutoFilled(false)
}
// 自动填充品类(如果识别成功且品类列表中存在该品类)
if (result.categoryMatched && result.categoryName) {
const categoryExists = productCategories.some(c => c.name === result.categoryName)
if (categoryExists && productCategoryName !== result.categoryName) {
onProductCategoryChange(result.categoryName)
setCategoryAutoFilled(true)
} else if (!categoryExists) {
// 品类不在列表中,清除自动填充标记
setCategoryAutoFilled(false)
}
} else {
setCategoryAutoFilled(false)
}
}, [productModel, materialType, categories, productCategories, categoryName, productCategoryName, onCategoryChange, onProductCategoryChange])
// 处理添加新店铺
const handleAddNewCategory = async () => {
if (!newCategoryName.trim()) return
await onAddCategory(newCategoryName.trim())
setNewCategoryName('')
setShowNewCategoryInput(false)
}
// 处理添加新场景
const handleAddNewScene = async () => {
if (!newSceneName.trim()) return
await onAddScene(newSceneName.trim())
setNewSceneName('')
setShowNewSceneInput(false)
}
// 批量模式下不显示这些字段
if (isBatchMode) {
return null
}
// 品牌VI灵感类型:只显示SPU和品类
const isBrandVI = materialType === '品牌VI灵感'
return (
<>
{/* SPU 或材质名称 */}
<div>
<label htmlFor="productModel" className={fieldLabelClass}>
{materialType === '材质' ? '材质名称 *' : 'SPU型号 *'}
</label>
<input
id="productModel"
type="text"
value={productModel}
onChange={(e) => {
// 材质类型支持中文,其他类型只允许英文、数字和常见符号
if (materialType === '材质') {
onProductModelChange(e.target.value)
} else {
const value = normalizeSpuProductModel(e.target.value)
onProductModelChange(value)
}
}}
maxLength={100}
aria-invalid={isVideoMaterialType(materialType) && productModel.length > 0 && !isPlausibleSpuProductModel(productModel)}
aria-describedby={isVideoMaterialType(materialType) ? 'product-model-hint' : undefined}
className={fieldControlClass}
placeholder="例如:DF-A3DZ0008"
/>
{isVideoMaterialType(materialType) && (
<p
id="product-model-hint"
className={`mt-2 ${
productModel && !isPlausibleSpuProductModel(productModel)
? fieldErrorClass
: fieldHintClass
}`}
role={productModel && !isPlausibleSpuProductModel(productModel) ? 'alert' : undefined}
>
{productModel
? getSpuProductModelValidationError(productModel) || '格式正确;英文字母已自动转成大写。'
: '请填写真实 SPU;英文字母会自动转成大写,纯数字或文件尾号不能作为 SPU。'}
</p>
)}
</div>
{/* SKU字段(视频类素材使用) */}
{isVideoMaterialType(materialType) && (
<div>
<label htmlFor="sku" className={fieldLabelClass}>
SKU <span className={`ml-1 font-normal ${fieldHintClass}`}>(可选,多个用逗号分隔)</span>
</label>
<input
id="sku"
type="text"
value={sku}
onChange={(e) => onSkuChange?.(e.target.value)}
className={fieldControlClass}
placeholder="例如:SKU001,SKU002,SKU003"
/>
</div>
)}
{/* 品类分类(标准模型必填,其他类型可选) */}
{productCategories.length > 0 && (
<div>
<label htmlFor="productCategoryName" className={fieldLabelClass}>
品类{requiresProductCategoryName(materialType) ? ' *' : '(可选)'}
{categoryAutoFilled && (
<span className={`ml-2 font-normal ${fieldHintClass}`}>
已自动识别
</span>
)}
</label>
<select
id="productCategoryName"
value={productCategoryName}
onChange={(e) => {
onProductCategoryChange(e.target.value)
setCategoryAutoFilled(false)
}}
className={fieldControlClass}
>
<option value="">{requiresProductCategoryName(materialType) ? '请选择品类' : '不选择品类'}</option>
{productCategories.map((category) => (
<option key={category.id} value={category.name}>
{category.name}
</option>
))}
</select>
</div>
)}
{/* 使用店铺(品牌VI灵感不显示,其他类型按需显示) */}
{!isBrandVI && requiresCategoryAndScene(materialType) && (
<div>
<label htmlFor="categoryName" className={fieldLabelClass}>
使用店铺 *
{shopAutoFilled && (
<span className={`ml-2 font-normal ${fieldHintClass}`}>
已自动识别
</span>
)}
</label>
{showNewCategoryInput ? (
<div className="flex gap-2">
<input
type="text"
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
className={`flex-1 ${fieldControlClass}`}
placeholder="输入新店铺名称"
/>
<button
type="button"
onClick={handleAddNewCategory}
className={inkButtonClass}
>
添加
</button>
<button
type="button"
onClick={() => {
setShowNewCategoryInput(false)
setNewCategoryName('')
}}
className={outlineButtonClass}
>
取消
</button>
</div>
) : (
<div className="flex gap-2">
<select
id="categoryName"
value={categoryName}
onChange={(e) => {
onCategoryChange(e.target.value)
setShopAutoFilled(false)
}}
disabled={isAutoGeneralType}
className={`flex-1 ${fieldControlClass} ${isAutoGeneralType ? 'cursor-not-allowed opacity-70' : ''}`}
>
{isAutoGeneralType ? (
<option value="通用">通用</option>
) : (
<>
<option value="">选择使用店铺</option>
{categories.map((category) => (
<option key={category.id} value={category.name}>
{category.name}
</option>
))}
</>
)}
</select>
<button
type="button"
onClick={() => setShowNewCategoryInput(true)}
className={`${outlineButtonClass} whitespace-nowrap`}
disabled={isAutoGeneralType}
>
新建店铺
</button>
</div>
)}
</div>
)}
{/* 场景类别(品牌VI灵感、材质类型和标准模型不需要) */}
{!isBrandVI && requiresCategoryAndScene(materialType) && (
<div>
<label htmlFor="sceneName" className={fieldLabelClass}>
场景类别 *
</label>
{showNewSceneInput ? (
<div className="flex gap-2">
<input
type="text"
value={newSceneName}
onChange={(e) => setNewSceneName(e.target.value)}
className={`flex-1 ${fieldControlClass}`}
placeholder="输入新场景类别"
/>
<button
type="button"
onClick={handleAddNewScene}
className={inkButtonClass}
>
添加
</button>
<button
type="button"
onClick={() => {
setShowNewSceneInput(false)
setNewSceneName('')
}}
className={outlineButtonClass}
>
取消
</button>
</div>
) : (
<div className="flex gap-2">
<select
id="sceneName"
value={sceneName}
onChange={(e) => onSceneChange(e.target.value)}
disabled={isAutoGeneralType}
className={`flex-1 ${fieldControlClass} ${isAutoGeneralType ? 'cursor-not-allowed opacity-70' : ''}`}
>
{isAutoGeneralType ? (
<option value="通用">通用</option>
) : (
<>
<option value="">选择场景类别</option>
{scenes.map((scene) => (
<option key={scene.id} value={scene.name}>
{scene.name}
</option>
))}
</>
)}
</select>
<button
type="button"
onClick={() => setShowNewSceneInput(true)}
className={`${outlineButtonClass} whitespace-nowrap`}
disabled={isAutoGeneralType}
>
新建场景
</button>
</div>
)}
</div>
)}
{/* 平台分类(可选,品牌VI灵感不显示,其他类型按需显示) */}
{!isBrandVI && platforms.length > 0 && !requiresProductCategoryName(materialType) && (
<div>
<label htmlFor="platformName" className={fieldLabelClass}>
平台分类(可选)
</label>
<select
id="platformName"
value={platformName}
onChange={(e) => onPlatformChange(e.target.value)}
className={fieldControlClass}
>
<option value="">不选择平台</option>
{platforms.map((platform) => (
<option key={platform.id} value={platform.name}>
{platform.name}
</option>
))}
</select>
</div>
)}
{/* 渲染器(仅3D场景显示) */}
{requiresRenderer(materialType) && (
<div>
<label htmlFor="renderer" className={fieldLabelClass}>
渲染器 *
</label>
<select
id="renderer"
value={renderer}
onChange={(e) => onRendererChange(e.target.value)}
className={fieldControlClass}
>
<option value="CR">CR</option>
<option value="VR">VR</option>
<option value="其它">其它</option>
</select>
</div>
)}
{/* 标准模型说明 */}
{requiresProductCategoryName(materialType) && (
<div className="rounded-[8px] border border-[#e7e8ea] bg-[#fafafa] p-4">
<p className="mb-2 text-[13px] font-medium text-[#1a1c1d]">
标准模型怎么传
</p>
<ul className={`list-inside list-disc space-y-1 ${fieldHintClass}`}>
<li>以产品型号(SPU)为基准创建模型合集</li>
<li>上传时只需填写 SPU 型号和预览图</li>
<li>创建后在详情页添加各个 SKU 的模型文件</li>
<li>每个 SKU 需要单独的预览图和压缩包</li>
</ul>
</div>
)}
</>
)
}