feat: add live clip batch upload and date platform filters

Allow multi-file live clip uploads with shared date/host/platform and
per-clip titles, filter materials by live platform and date range, and
fix the migration to alter the mapped materials table.
This commit is contained in:
WuYongJie 2026-09-22 18:55:41 +08:00
parent be43f1a6a5
commit bea82d67b0
9 changed files with 156 additions and 11 deletions

View File

@ -107,7 +107,10 @@ export default function MaterialLibraryPage() {
const [selectedScene, setSelectedScene] = useState('')
const [selectedMaterialType, setSelectedMaterialType] = useState('') // 素材类型筛选,默认展示全部
// const [selectedMaterialType, setSelectedMaterialType] = useState('3D场景') // 素材类型筛选,默认展示3D场景(已注释,改为默认展示全部)
const [selectedPlatform, setSelectedPlatform] = useState('') // 平台分类筛选
const [selectedPlatform, setSelectedPlatform] = useState('')
const [selectedLivePlatform, setSelectedLivePlatform] = useState('')
const [liveDateFrom, setLiveDateFrom] = useState('')
const [liveDateTo, setLiveDateTo] = useState('') // 平台分类筛选
const [selectedProductCategory, setSelectedProductCategory] = useState('') // 品类筛选
const [categories, setCategories] = useState<FacetItem[]>([])
const [scenes, setScenes] = useState<FacetItem[]>([])
@ -231,6 +234,9 @@ export default function MaterialLibraryPage() {
if (selectedScene) params.append('scene', selectedScene)
if (selectedMaterialType) params.append('materialType', selectedMaterialType)
if (selectedPlatform) params.append('platform', selectedPlatform)
if (selectedLivePlatform) params.append('livePlatform', selectedLivePlatform)
if (liveDateFrom) params.append('liveDateFrom', liveDateFrom)
if (liveDateTo) params.append('liveDateTo', liveDateTo)
if (selectedProductCategory) params.append('productCategory', selectedProductCategory)
if (selectedMonth) params.append('month', selectedMonth)
if (includeSort) {
@ -315,6 +321,9 @@ export default function MaterialLibraryPage() {
if (selectedCategory) params.set('category', selectedCategory)
if (selectedScene) params.set('scene', selectedScene)
if (selectedPlatform) params.set('platform', selectedPlatform)
if (selectedLivePlatform) params.set('livePlatform', selectedLivePlatform)
if (liveDateFrom) params.set('liveDateFrom', liveDateFrom)
if (liveDateTo) params.set('liveDateTo', liveDateTo)
if (selectedProductCategory) params.set('productCategory', selectedProductCategory)
if (selectedMonth) params.set('month', selectedMonth)
if (sortBy && sortBy !== 'createdAt') params.set('sortBy', sortBy)
@ -1386,6 +1395,12 @@ export default function MaterialLibraryPage() {
setSelectedScene={setSelectedScene}
selectedPlatform={selectedPlatform}
setSelectedPlatform={setSelectedPlatform}
selectedLivePlatform={selectedLivePlatform}
setSelectedLivePlatform={setSelectedLivePlatform}
liveDateFrom={liveDateFrom}
setLiveDateFrom={setLiveDateFrom}
liveDateTo={liveDateTo}
setLiveDateTo={setLiveDateTo}
selectedProductCategory={selectedProductCategory}
setSelectedProductCategory={setSelectedProductCategory}
selectedMonth={selectedMonth}

View File

@ -120,6 +120,9 @@ export default function Home() {
const [selectedMaterialType, setSelectedMaterialType] = useState('') // 素材类型筛选,默认展示全部
// const [selectedMaterialType, setSelectedMaterialType] = useState('3D场景') // 素材类型筛选,默认展示3D场景(已注释,改为默认展示全部)
const [selectedPlatform, setSelectedPlatform] = useState('') // 平台分类筛选
const [selectedLivePlatform, setSelectedLivePlatform] = useState('')
const [liveDateFrom, setLiveDateFrom] = useState('')
const [liveDateTo, setLiveDateTo] = useState('')
const [selectedProductCategory, setSelectedProductCategory] = useState('') // 品类筛选
const [categories, setCategories] = useState<FacetItem[]>([])
const [scenes, setScenes] = useState<FacetItem[]>([])
@ -205,6 +208,9 @@ export default function Home() {
if (selectedScene) params.append('scene', selectedScene)
if (selectedMaterialType) params.append('materialType', selectedMaterialType)
if (selectedPlatform) params.append('platform', selectedPlatform)
if (selectedLivePlatform) params.append('livePlatform', selectedLivePlatform)
if (liveDateFrom) params.append('liveDateFrom', liveDateFrom)
if (liveDateTo) params.append('liveDateTo', liveDateTo)
if (selectedProductCategory) params.append('productCategory', selectedProductCategory)
if (selectedMonth) params.append('month', selectedMonth)
if (includeSort) {
@ -287,6 +293,9 @@ export default function Home() {
if (selectedCategory) params.set('category', selectedCategory)
if (selectedScene) params.set('scene', selectedScene)
if (selectedPlatform) params.set('platform', selectedPlatform)
if (selectedLivePlatform) params.set('livePlatform', selectedLivePlatform)
if (liveDateFrom) params.set('liveDateFrom', liveDateFrom)
if (liveDateTo) params.set('liveDateTo', liveDateTo)
if (selectedProductCategory) params.set('productCategory', selectedProductCategory)
if (selectedMonth) params.set('month', selectedMonth)
if (sortBy && sortBy !== 'createdAt') params.set('sortBy', sortBy)
@ -1417,6 +1426,12 @@ export default function Home() {
setSelectedScene={setSelectedScene}
selectedPlatform={selectedPlatform}
setSelectedPlatform={setSelectedPlatform}
selectedLivePlatform={selectedLivePlatform}
setSelectedLivePlatform={setSelectedLivePlatform}
liveDateFrom={liveDateFrom}
setLiveDateFrom={setLiveDateFrom}
liveDateTo={liveDateTo}
setLiveDateTo={setLiveDateTo}
selectedProductCategory={selectedProductCategory}
setSelectedProductCategory={setSelectedProductCategory}
selectedMonth={selectedMonth}

View File

@ -1044,6 +1044,23 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
const handleBatchFilesUpload = async () => {
if (batchFiles.length === 0) return
if (materialType === '直播切片') {
const missingTitle = batchFiles.some((item) => !item.productModel.trim())
if (missingTitle) {
setError('请为每个切片填写标题')
return
}
const liveErr: string[] = []
if (!liveDate.trim()) liveErr.push('请选择直播日期')
if (livePlatform.trim() && !['抖音', '快手', '视频号', '淘宝直播', 'B站'].includes(livePlatform.trim())) {
liveErr.push('请选择有效的直播平台')
}
if (liveErr.length > 0) {
setError(liveErr.join(';'))
return
}
}
setUploading(true)
setIsProcessing(false)
setUploadProgress(0)
@ -1115,6 +1132,9 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
: item.sceneName,
platformName: item.platformName || '',
productCategoryName: item.productCategoryName || '',
...(materialType === '直播切片'
? { liveDate, liveHost, liveHostUserId, livePlatform }
: {}),
}
// 添加渲染器(3D场景)
@ -2653,7 +2673,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
disabled={interactionLocked}
/>
{/* 批量上传文件选择 */}
{!isTaskOtherMaterial && (materialType === '音乐' || isBatchVideoMaterialType(materialType) || materialType === '3D场景' || materialType === '品牌VI灵感') && (
{!isTaskOtherMaterial && (materialType === '音乐' || materialType === '直播切片' || isBatchVideoMaterialType(materialType) || materialType === '3D场景' || materialType === '品牌VI灵感') && (
<>
<input
ref={batchFileInputRef}
@ -2671,7 +2691,8 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
className={`${outlineButtonClass} mr-2`}
>
{materialType === '音乐' && '选择音乐文件(可多选)'}
{isBatchVideoMaterialType(materialType) && '选择视频文件(可多选,最多8个)'}
{materialType === '直播切片' && '选择视频文件(可多选,最多20个)'}
{isBatchVideoMaterialType(materialType) && '选择视频文件(可多选,最多8个)'}
{materialType === '3D场景' && '选择3D文件或压缩包(可多选,最多5个)'}
{materialType === '品牌VI灵感' && '选择PDF文件(可多选,最多50个)'}
</button>

View File

@ -32,6 +32,12 @@ interface FilterPanelProps {
setSelectedScene: (value: string) => void
selectedPlatform: string
setSelectedPlatform: (value: string) => void
selectedLivePlatform?: string
setSelectedLivePlatform?: (value: string) => void
liveDateFrom?: string
setLiveDateFrom?: (value: string) => void
liveDateTo?: string
setLiveDateTo?: (value: string) => void
selectedProductCategory: string
setSelectedProductCategory: (value: string) => void
selectedMonth: string
@ -61,6 +67,9 @@ function FilterPanel(props: FilterPanelProps) {
selectedCategory, setSelectedCategory,
selectedScene, setSelectedScene,
selectedPlatform, setSelectedPlatform,
selectedLivePlatform = '', setSelectedLivePlatform,
liveDateFrom = '', setLiveDateFrom,
liveDateTo = '', setLiveDateTo,
selectedProductCategory, setSelectedProductCategory,
selectedMonth, setSelectedMonth,
sortBy, setSortBy, sortOrder, setSortOrder,
@ -328,6 +337,45 @@ function FilterPanel(props: FilterPanelProps) {
{rankedPlatforms.map((item) => <button key={item.id} onClick={() => setSelectedPlatform(item.name === selectedPlatform ? '' : item.name)} className={`${chipBase} ${selectedPlatform === item.name ? chipActive : chipInactive}`}>{item.name}<span className={`ml-1.5 ${countClass}`}>{countText(item.count || 0)}</span></button>)}
</div>
</div>}
{setSelectedLivePlatform && (
<div className="mb-6">
<h2 className="mb-3 text-[13px] text-[#6b7075]">直播平台</h2>
<div className="flex flex-wrap gap-x-5 gap-y-2">
{['', '抖音', '快手', '视频号', '淘宝直播', 'B站'].map((name) => (
<button
key={name || 'all'}
type="button"
onClick={() => setSelectedLivePlatform(name)}
className={`${chipBase} ${selectedLivePlatform === name ? chipActive : chipInactive}`}
>
{name || '全部'}
</button>
))}
</div>
</div>
)}
{(setLiveDateFrom || setLiveDateTo) && (
<div className="mb-6">
<h2 className="mb-3 text-[13px] text-[#6b7075]">直播日期</h2>
<div className="flex flex-wrap items-center gap-2">
<input
type="date"
aria-label="直播日期起"
value={liveDateFrom}
onChange={(e) => setLiveDateFrom?.(e.target.value)}
className={`${chipBase} cursor-text`}
/>
<span className="text-[12px] text-[#86898e]">至</span>
<input
type="date"
aria-label="直播日期止"
value={liveDateTo}
onChange={(e) => setLiveDateTo?.(e.target.value)}
className={`${chipBase} cursor-text`}
/>
</div>
</div>
)}
{(rankedProductCategories.length > 0 || facetsLoading) && <div className="mb-6">
<h2 className="mb-3 text-[13px] text-[#6b7075]">产品类别</h2>
<div className="flex flex-wrap gap-x-5 gap-y-2">

View File

@ -4,12 +4,14 @@ import { BATCH_LIMITS } from './upload-constants'
import { isValidMainFileType, extractSpuFromFileName } from './upload-utils'
import { requiresRenderer, shouldAutoFillGeneral } from '@/lib/material-validation'
import { isBatchVideoMaterialType } from '@/lib/video-material-types'
import { isLiveClipType } from '@/lib/live-clip'
/**
* 获取批量上传限制
*/
export function getBatchLimit(materialType: string): number {
if (materialType === '音乐') return BATCH_LIMITS.music
if (isLiveClipType(materialType)) return 20
if (isBatchVideoMaterialType(materialType)) return BATCH_LIMITS.video
if (materialType === '3D场景') return BATCH_LIMITS['3d']
if (materialType === '品牌VI灵感') return BATCH_LIMITS.brandVI
@ -28,9 +30,12 @@ export function createBatchFileItems(
productCategoryName: string,
renderer: string
): BatchFileItem[] {
const isLiveClip = isLiveClipType(materialType)
return files.map((file, index) => {
const extractedSpu = extractSpuFromFileName(file.name)
const extractedSpu = isLiveClip
? file.name.replace(/\.[^.]+$/, '')
: extractSpuFromFileName(file.name)
return {
id: `${Date.now()}-${index}`,
file,

View File

@ -307,6 +307,10 @@ export async function completeBatchUpload(uploadId: string, metadata: Record<str
formData.append('sceneName', metadata.sceneName || '通用')
formData.append('platformName', metadata.platformName || '')
formData.append('productCategoryName', metadata.productCategoryName || '')
if (metadata.liveDate) formData.append('liveDate', String(metadata.liveDate))
if (metadata.liveHost) formData.append('liveHost', String(metadata.liveHost))
if (metadata.liveHostUserId) formData.append('liveHostUserId', String(metadata.liveHostUserId))
if (metadata.livePlatform) formData.append('livePlatform', String(metadata.livePlatform))
const materialType = metadata.materialType || '3D场景'
formData.append('materialType', materialType)

View File

@ -86,6 +86,12 @@ export interface UseHomeStateReturn {
setSelectedMaterialType: React.Dispatch<React.SetStateAction<string>>
selectedPlatform: string
setSelectedPlatform: React.Dispatch<React.SetStateAction<string>>
selectedLivePlatform: string
setSelectedLivePlatform: React.Dispatch<React.SetStateAction<string>>
liveDateFrom: string
setLiveDateFrom: React.Dispatch<React.SetStateAction<string>>
liveDateTo: string
setLiveDateTo: React.Dispatch<React.SetStateAction<string>>
selectedProductCategory: string
setSelectedProductCategory: React.Dispatch<React.SetStateAction<string>>
selectedMonth: string
@ -149,6 +155,9 @@ export function useHomeState(): UseHomeStateReturn {
const [selectedScene, setSelectedScene] = useState('')
const [selectedMaterialType, setSelectedMaterialType] = useState('')
const [selectedPlatform, setSelectedPlatform] = useState('')
const [selectedLivePlatform, setSelectedLivePlatform] = useState('')
const [liveDateFrom, setLiveDateFrom] = useState('')
const [liveDateTo, setLiveDateTo] = useState('')
const [selectedProductCategory, setSelectedProductCategory] = useState('')
const [selectedMonth, setSelectedMonth] = useState('')
@ -234,6 +243,9 @@ export function useHomeState(): UseHomeStateReturn {
if (selectedCategory) params.set('category', selectedCategory)
if (selectedScene) params.set('scene', selectedScene)
if (selectedPlatform) params.set('platform', selectedPlatform)
if (selectedLivePlatform) params.set('livePlatform', selectedLivePlatform)
if (liveDateFrom) params.set('liveDateFrom', liveDateFrom)
if (liveDateTo) params.set('liveDateTo', liveDateTo)
if (selectedProductCategory) params.set('productCategory', selectedProductCategory)
if (selectedMonth) params.set('month', selectedMonth)
if (sortBy && sortBy !== 'createdAt') params.set('sortBy', sortBy)
@ -241,7 +253,7 @@ export function useHomeState(): UseHomeStateReturn {
const newUrl = params.toString() ? `${window.location.pathname}?${params.toString()}` : window.location.pathname
window.history.replaceState({}, '', newUrl)
}, [searchKeyword, selectedMaterialType, selectedCategory, selectedScene, selectedPlatform, selectedProductCategory, selectedMonth, sortBy, sortOrder])
}, [searchKeyword, selectedMaterialType, selectedCategory, selectedScene, selectedPlatform, selectedLivePlatform, liveDateFrom, liveDateTo, selectedProductCategory, selectedMonth, sortBy, sortOrder])
return {
// 素材数据
@ -263,6 +275,12 @@ export function useHomeState(): UseHomeStateReturn {
setSelectedMaterialType,
selectedPlatform,
setSelectedPlatform,
selectedLivePlatform,
setSelectedLivePlatform,
liveDateFrom,
setLiveDateFrom,
liveDateTo,
setLiveDateTo,
selectedProductCategory,
setSelectedProductCategory,
selectedMonth,

View File

@ -400,6 +400,10 @@ export async function buildMaterialWhereFromSearchParams(
const materialType = searchParams.get('materialType') || ''
const platform = searchParams.get('platform') || ''
const productCategory = searchParams.get('productCategory') || ''
const livePlatform = searchParams.get('livePlatform') || ''
const liveDateFrom = searchParams.get('liveDateFrom') || ''
const liveDateTo = searchParams.get('liveDateTo') || ''
const liveHost = searchParams.get('liveHost') || ''
const month = searchParams.get('month') || ''
if (context.searchOrConditions) {
@ -415,6 +419,21 @@ export async function buildMaterialWhereFromSearchParams(
if (platform && options.exclude !== 'platform') {
where.platformName = platform
}
if (livePlatform) {
where.livePlatform = livePlatform
}
if (liveHost) {
where.liveHost = { contains: liveHost, mode: 'insensitive' }
}
if (liveDateFrom || liveDateTo) {
where.liveDate = {}
if (liveDateFrom) {
where.liveDate.gte = new Date(`${liveDateFrom}T00:00:00.000Z`)
}
if (liveDateTo) {
where.liveDate.lte = new Date(`${liveDateTo}T23:59:59.999Z`)
}
}
if (productCategory && options.exclude !== 'productCategory') {
where.productCategoryName = productCategory
}

View File

@ -1,6 +1,6 @@
-- 直播切片:直播日期 / 主播 / 可选关联用户 / 直播平台
ALTER TABLE "Material" ADD COLUMN IF NOT EXISTS "live_date" TIMESTAMP(3);
ALTER TABLE "Material" ADD COLUMN IF NOT EXISTS "live_host" TEXT;
ALTER TABLE "Material" ADD COLUMN IF NOT EXISTS "live_host_user_id" TEXT;
ALTER TABLE "Material" ADD COLUMN IF NOT EXISTS "live_platform" TEXT;
CREATE INDEX IF NOT EXISTS "Material_live_date_idx" ON "Material"("live_date");
ALTER TABLE "materials" ADD COLUMN IF NOT EXISTS "live_date" TIMESTAMP(3);
ALTER TABLE "materials" ADD COLUMN IF NOT EXISTS "live_host" TEXT;
ALTER TABLE "materials" ADD COLUMN IF NOT EXISTS "live_host_user_id" TEXT;
ALTER TABLE "materials" ADD COLUMN IF NOT EXISTS "live_platform" TEXT;
CREATE INDEX IF NOT EXISTS "materials_live_date_idx" ON "materials"("live_date");