520 lines
17 KiB
TypeScript
Raw Permalink 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 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>
)}
</>
)
}