520 lines
17 KiB
TypeScript
520 lines
17 KiB
TypeScript
|
||
/**
|
||
* 上传表单字段组件
|
||
* 包含 SPU、店铺、场景、平台、品类、渲染器等表单字段
|
||
*/
|
||
|
||
'use client'
|
||
|
||
import Select from '@/components/ui/Select'
|
||
|
||
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 { isLiveClipType } from '@/lib/live-clip'
|
||
import LiveClipFields from '@/components/upload/LiveClipFields'
|
||
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
|
||
liveDate?: string
|
||
liveHost?: string
|
||
liveHostUserId?: string
|
||
livePlatform?: string
|
||
onLiveDateChange?: (value: string) => void
|
||
onLiveHostChange?: (value: string) => void
|
||
onLiveHostUserIdChange?: (value: string) => void
|
||
onLivePlatformChange?: (value: string) => void
|
||
/** 店铺列表 */
|
||
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 = '',
|
||
liveDate = '',
|
||
liveHost = '',
|
||
liveHostUserId = '',
|
||
livePlatform = '',
|
||
onLiveDateChange,
|
||
onLiveHostChange,
|
||
onLiveHostUserIdChange,
|
||
onLivePlatformChange,
|
||
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灵感'
|
||
const isLiveClip = isLiveClipType(materialType)
|
||
|
||
return (
|
||
<>
|
||
{/* 标题 / SPU 或材质名称 */}
|
||
<div>
|
||
<label htmlFor="productModel" className={fieldLabelClass}>
|
||
{isLiveClip ? '标题 *' : materialType === '材质' ? '材质名称 *' : 'SPU型号 *'}
|
||
</label>
|
||
<input
|
||
id="productModel"
|
||
type="text"
|
||
value={productModel}
|
||
onChange={(e) => {
|
||
// 材质/直播切片支持中文,其他类型只允许英文、数字和常见符号
|
||
if (materialType === '材质' || isLiveClip) {
|
||
onProductModelChange(e.target.value)
|
||
} else {
|
||
const value = normalizeSpuProductModel(e.target.value)
|
||
onProductModelChange(value)
|
||
}
|
||
}}
|
||
maxLength={100}
|
||
aria-invalid={isVideoMaterialType(materialType) && !isLiveClip && productModel.length > 0 && !isPlausibleSpuProductModel(productModel)}
|
||
aria-describedby={isVideoMaterialType(materialType) && !isLiveClip ? 'product-model-hint' : undefined}
|
||
className={fieldControlClass}
|
||
placeholder={isLiveClip ? '例如:618大促直播精彩切片' : '例如:DF-A3DZ0008'}
|
||
/>
|
||
{isLiveClip && (
|
||
<p className={`mt-2 ${fieldHintClass}`}>用于标识这段直播切片,便于搜索</p>
|
||
)}
|
||
{isVideoMaterialType(materialType) && !isLiveClip && (
|
||
<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>
|
||
|
||
{/* 直播切片专用字段 */}
|
||
{isLiveClip && onLiveDateChange && onLiveHostChange && onLiveHostUserIdChange && onLivePlatformChange && (
|
||
<LiveClipFields
|
||
liveDate={liveDate}
|
||
liveHost={liveHost}
|
||
liveHostUserId={liveHostUserId}
|
||
livePlatform={livePlatform}
|
||
onLiveDateChange={onLiveDateChange}
|
||
onLiveHostChange={onLiveHostChange}
|
||
onLiveHostUserIdChange={onLiveHostUserIdChange}
|
||
onLivePlatformChange={onLivePlatformChange}
|
||
/>
|
||
)}
|
||
|
||
{/* SKU字段(视频类素材使用,直播切片不需要) */}
|
||
{isVideoMaterialType(materialType) && !isLiveClip && (
|
||
<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) && (
|
||
<p className={fieldHintClass}>
|
||
此处创建模型合集;各 SKU 的预览图和压缩包请在创建后到详情页添加。
|
||
</p>
|
||
)}
|
||
</>
|
||
)
|
||
}
|