Register 直播切片 as a video-only material type. Upload and edit flows collect required title, live date (defaults to today), optional host with user link, and live platform presets. Persist the fields on Material and skip SPU formatting for free-text titles.
525 lines
17 KiB
TypeScript
525 lines
17 KiB
TypeScript
/**
|
||
* 上传表单字段组件
|
||
* 包含 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 { 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) && (
|
||
<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>
|
||
)}
|
||
</>
|
||
)
|
||
}
|