feat: add live clip material type with date host and platform

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.
This commit is contained in:
WuYongJie 2026-09-22 18:34:04 +08:00
parent 5891627dec
commit d8d843e6ad
18 changed files with 557 additions and 18 deletions

View File

@ -166,6 +166,10 @@ export async function GET(
userId: true,
productModel: true,
reportTitle: true,
liveDate: true,
liveHost: true,
liveHostUserId: true,
livePlatform: true,
categoryName: true,
sceneName: true,
platformName: true,
@ -239,6 +243,10 @@ export async function GET(
userId: true,
productModel: true,
reportTitle: true,
liveDate: true,
liveHost: true,
liveHostUserId: true,
livePlatform: true,
categoryName: true,
sceneName: true,
platformName: true,

View File

@ -108,6 +108,10 @@ export async function GET(request: NextRequest) {
previewUrl: true,
productModel: true,
reportTitle: true,
liveDate: true,
liveHost: true,
liveHostUserId: true,
livePlatform: true,
categoryName: true,
sceneName: true,
platformName: true,
@ -351,6 +355,10 @@ export async function GET(request: NextRequest) {
previewUrl: true,
productModel: true,
reportTitle: true,
liveDate: true,
liveHost: true,
liveHostUserId: true,
livePlatform: true,
categoryName: true,
sceneName: true,
platformName: true,

View File

@ -125,6 +125,10 @@ export async function GET(request: NextRequest) {
previewUrl: true,
productModel: true,
reportTitle: true,
liveDate: true,
liveHost: true,
liveHostUserId: true,
livePlatform: true,
categoryName: true,
sceneName: true,
platformName: true,

View File

@ -67,6 +67,18 @@ export async function PUT(request: NextRequest) {
...(formData.has('taskDetailRequesterUserId')
? { taskDetailRequesterUserId: formData.get('taskDetailRequesterUserId') as string | null }
: {}),
...(formData.has('liveDate')
? { liveDate: formData.get('liveDate') as string | null }
: {}),
...(formData.has('liveHost')
? { liveHost: formData.get('liveHost') as string | null }
: {}),
...(formData.has('liveHostUserId')
? { liveHostUserId: formData.get('liveHostUserId') as string | null }
: {}),
...(formData.has('livePlatform')
? { livePlatform: formData.get('livePlatform') as string | null }
: {}),
...(formData.has('isFullSet')
? { isFullSet: formData.get('isFullSet') as string | null }
: {}),

View File

@ -61,6 +61,11 @@ import {
isPlausibleSpuProductModel,
normalizeSpuProductModel,
} from '@/lib/product-model';
import {
isLiveClipType,
parseLiveDate,
validateLiveClipFields,
} from '@/lib/live-clip';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
@ -485,15 +490,36 @@ export async function POST(request: NextRequest) {
const rawDingtalkApprovalNo = (formData.get('dingtalkApprovalNo') as string) || '';
const dingtalkApprovalNo = normalizeDingtalkApprovalNo(rawDingtalkApprovalNo);
const rawProductModel = productModelInput || (isTaskOtherMaterial ? taskNo : '');
const productModel = VIDEO_TYPES.has(materialType)
const isLiveClip = isLiveClipType(materialType);
// 直播切片的 productModel 存「标题」(自由文本),不做 SPU 规范化/校验
const productModel = VIDEO_TYPES.has(materialType) && !isLiveClip
? normalizeSpuProductModel(rawProductModel)
: rawProductModel;
: rawProductModel.trim();
const shouldAutoFillGeneralCategoryScene =
AUTO_GENERAL_TYPES.includes(materialType) || materialType === '标准模型';
const resolvedCategoryName = shouldAutoFillGeneralCategoryScene ? '通用' : categoryName;
const resolvedSceneName = shouldAutoFillGeneralCategoryScene ? '通用' : sceneName;
if (VIDEO_TYPES.has(materialType) && !isPlausibleSpuProductModel(productModel)) {
const liveDateInput = (formData.get('liveDate') as string) || '';
const liveHostInput = (formData.get('liveHost') as string) || '';
const liveHostUserIdInput = (formData.get('liveHostUserId') as string) || '';
const livePlatformInput = (formData.get('livePlatform') as string) || '';
if (isLiveClip) {
const liveClipErrors = validateLiveClipFields({
title: productModel,
liveDate: liveDateInput,
livePlatform: livePlatformInput,
});
if (liveClipErrors.length > 0) {
return NextResponse.json(
{ error: liveClipErrors.map((e) => e.message).join(';') },
{ status: 400 },
);
}
}
if (VIDEO_TYPES.has(materialType) && !isLiveClip && !isPlausibleSpuProductModel(productModel)) {
return NextResponse.json(
{
error: `视频素材 SPU 不合规:${getSpuProductModelValidationError(productModel) || '请检查后重试'}`,
@ -639,7 +665,13 @@ export async function POST(request: NextRequest) {
// 使用统一的验证逻辑验证必填字段
if (!productModel) {
return NextResponse.json(
{ error: isTaskOtherMaterial ? '任务-其他必须填写任务号' : '产品型号是必填的' },
{
error: isTaskOtherMaterial
? '任务-其他必须填写任务号'
: isLiveClip
? '请填写标题'
: '产品型号是必填的',
},
{ status: 400 }
);
}
@ -1191,6 +1223,14 @@ export async function POST(request: NextRequest) {
videoSourceUrl, // 视频类型时保存视频URL
videoCoverUrl,
videoProcessingStatus: videoSourceUrl ? 'pending' : null, // 视频类型时初始化为pending状态
...(isLiveClip
? {
liveDate: parseLiveDate(liveDateInput) || parseLiveDate(new Date().toISOString().slice(0, 10)),
liveHost: liveHostInput.trim() || null,
liveHostUserId: liveHostUserIdInput.trim() || null,
livePlatform: livePlatformInput.trim() || null,
}
: {}),
dingtalkApprovalNo,
// taskNo: 不写入,上传完成后再写入
// taskStatus: 不写入,上传完成后再写入

View File

@ -28,6 +28,10 @@ interface Material {
userId: string
productModel: string
reportTitle?: string | null
liveDate?: Date | string | null
liveHost?: string | null
liveHostUserId?: string | null
livePlatform?: string | null
categoryName: string
sceneName: string
platformName?: string | null
@ -92,6 +96,10 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
const [previewUrl, setPreviewUrl] = useState('')
const [productModel, setProductModel] = useState('')
const [reportTitle, setReportTitle] = useState('')
const [liveDate, setLiveDate] = useState('')
const [liveHost, setLiveHost] = useState('')
const [liveHostUserId, setLiveHostUserId] = useState('')
const [livePlatform, setLivePlatform] = useState('')
const [categoryName, setCategoryName] = useState('')
const [sceneName, setSceneName] = useState('')
const [platformName, setPlatformName] = useState('')
@ -268,6 +276,10 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
setPreviewUrl(data.previewUrl || '')
setProductModel(data.productModel || '')
setReportTitle(data.reportTitle || '')
setLiveDate(data.liveDate ? String(data.liveDate).slice(0, 10) : '')
setLiveHost(data.liveHost || '')
setLiveHostUserId(data.liveHostUserId || '')
setLivePlatform(data.livePlatform || '')
setCategoryName(data.categoryName || '')
setSceneName(data.sceneName || '')
setPlatformName(data.platformName || '')
@ -525,6 +537,12 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
const formData = new FormData()
formData.append('productModel', productModel)
formData.append('reportTitle', reportTitle)
if (materialType === '直播切片') {
formData.append('liveDate', liveDate)
formData.append('liveHost', liveHost)
formData.append('liveHostUserId', liveHostUserId)
formData.append('livePlatform', livePlatform)
}
formData.append('categoryName', categoryName)
formData.append('sceneName', sceneName)
formData.append('platformName', platformName)
@ -725,7 +743,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
htmlFor={`edit-material-${params.id}-product-model`}
className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{isCertificateReport ? 'SPU' : '产品型号 *'}
{isCertificateReport ? 'SPU' : materialType === '直播切片' ? '标题 *' : '产品型号 *'}
</label>
<input
id={`edit-material-${params.id}-product-model`}
@ -737,6 +755,48 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
/>
</div>
{materialType === '直播切片' && (
<div className="space-y-4">
<div>
<label htmlFor={`edit-material-${params.id}-live-date`} className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">直播日期</label>
<input
id={`edit-material-${params.id}-live-date`}
type="date"
value={liveDate}
onChange={(e) => setLiveDate(e.target.value)}
className="input-apple"
/>
</div>
<div>
<label htmlFor={`edit-material-${params.id}-live-host`} className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">主播(可选)</label>
<input
id={`edit-material-${params.id}-live-host`}
type="text"
value={liveHost}
onChange={(e) => setLiveHost(e.target.value)}
maxLength={50}
className="input-apple"
/>
</div>
<div>
<label htmlFor={`edit-material-${params.id}-live-platform`} className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">直播平台</label>
<select
id={`edit-material-${params.id}-live-platform`}
value={livePlatform}
onChange={(e) => setLivePlatform(e.target.value)}
className="input-apple"
>
<option value="">请选择平台</option>
<option value="抖音">抖音</option>
<option value="快手">快手</option>
<option value="视频号">视频号</option>
<option value="淘宝直播">淘宝直播</option>
<option value="B站">B站</option>
</select>
</div>
</div>
)}
{isCertificateReport && (
<div>
<label

View File

@ -185,6 +185,10 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
// SKU号(视频类素材支持)
const [sku, setSku] = useState('')
const [liveDate, setLiveDate] = useState('')
const [liveHost, setLiveHost] = useState('')
const [liveHostUserId, setLiveHostUserId] = useState('')
const [livePlatform, setLivePlatform] = useState('')
const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
@ -1579,7 +1583,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
// 新的批量上传模式(可折叠文件列表)
if (isBatchMode && batchFiles.length > 0) {
const invalidVideoSpu = isVideoMaterialType(materialType)
const invalidVideoSpu = isVideoMaterialType(materialType) && materialType !== '直播切片'
? batchFiles.find(item => !isPlausibleSpuProductModel(item.productModel))
: undefined
if (invalidVideoSpu) {
@ -1705,7 +1709,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
return
}
if (isVideoMaterialType(materialType) && !isPlausibleSpuProductModel(resolvedProductModel)) {
if (isVideoMaterialType(materialType) && materialType !== '直播切片' && !isPlausibleSpuProductModel(resolvedProductModel)) {
setError(`视频 SPU 不合规:${getSpuProductModelValidationError(resolvedProductModel)}`)
return
}
@ -1832,6 +1836,10 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
taskNo: resolvedTaskNo,
dingtalkApprovalNo: dingtalkApprovalNo.trim(),
completedChunkUpload: chunkResult || undefined,
liveDate,
liveHost,
liveHostUserId,
livePlatform,
})
await submitFormData(formData, {
@ -2332,6 +2340,14 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
onPlatformChange={setPlatformName}
onProductCategoryChange={setProductCategoryName}
onRendererChange={setRenderer}
liveDate={liveDate}
liveHost={liveHost}
liveHostUserId={liveHostUserId}
livePlatform={livePlatform}
onLiveDateChange={setLiveDate}
onLiveHostChange={setLiveHost}
onLiveHostUserIdChange={setLiveHostUserId}
onLivePlatformChange={setLivePlatform}
onSkuChange={setSku}
onAddCategory={async (name) => {
await addCategory(

View File

@ -0,0 +1,151 @@
/**
* 直播切片专用字段:直播日期 / 主播(可关联用户)/ 直播平台
*/
'use client'
import { useEffect, useState } from 'react'
import {
fieldControlClass,
fieldHintClass,
fieldLabelClass,
} from '@/components/upload/upload-field-styles'
import {
getDefaultLiveDate,
LIVE_CLIP_PLATFORMS,
} from '@/lib/live-clip'
export interface LiveClipFieldsProps {
liveDate: string
liveHost: string
liveHostUserId: string
livePlatform: string
onLiveDateChange: (value: string) => void
onLiveHostChange: (value: string) => void
onLiveHostUserIdChange: (value: string) => void
onLivePlatformChange: (value: string) => void
}
interface UserOption {
id: string
displayName: string
username: string
}
export default function LiveClipFields({
liveDate,
liveHost,
liveHostUserId,
livePlatform,
onLiveDateChange,
onLiveHostChange,
onLiveHostUserIdChange,
onLivePlatformChange,
}: LiveClipFieldsProps) {
const [users, setUsers] = useState<UserOption[]>([])
const [usersLoaded, setUsersLoaded] = useState(false)
useEffect(() => {
if (!liveDate) {
onLiveDateChange(getDefaultLiveDate())
}
// 仅在日期为空时补默认值,避免依赖回调导致循环
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
let cancelled = false
fetch('/api/users/list')
.then((res) => (res.ok ? res.json() : []))
.then((data: UserOption[]) => {
if (!cancelled && Array.isArray(data)) {
setUsers(data)
setUsersLoaded(true)
}
})
.catch(() => {
if (!cancelled) setUsersLoaded(true)
})
return () => {
cancelled = true
}
}, [])
return (
<>
<div>
<label htmlFor="liveDate" className={fieldLabelClass}>
直播日期
</label>
<input
id="liveDate"
type="date"
value={liveDate}
onChange={(e) => onLiveDateChange(e.target.value)}
className={fieldControlClass}
max={getDefaultLiveDate()}
/>
<p className={`mt-2 ${fieldHintClass}`}>默认上传当天,可按日历修改</p>
</div>
<div>
<label htmlFor="liveHost" className={fieldLabelClass}>
主播 <span className={`ml-1 font-normal ${fieldHintClass}`}>(可选)</span>
</label>
<input
id="liveHost"
type="text"
value={liveHost}
onChange={(e) => onLiveHostChange(e.target.value)}
className={fieldControlClass}
maxLength={50}
placeholder="填写主播昵称"
/>
<label htmlFor="liveHostUserId" className={`${fieldLabelClass} mt-4 block`}>
关联用户 <span className={`ml-1 font-normal ${fieldHintClass}`}>(可选,便于按人筛选)</span>
</label>
<select
id="liveHostUserId"
value={liveHostUserId}
onChange={(e) => {
const userId = e.target.value
onLiveHostUserIdChange(userId)
if (userId) {
const matched = users.find((u) => u.id === userId)
if (matched && !liveHost.trim()) {
onLiveHostChange(matched.displayName)
}
}
}}
className={fieldControlClass}
>
<option value="">{usersLoaded ? '不关联用户' : '加载用户…'}</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.displayName}
</option>
))}
</select>
</div>
<div>
<label htmlFor="livePlatform" className={fieldLabelClass}>
直播平台
</label>
<select
id="livePlatform"
value={livePlatform}
onChange={(e) => onLivePlatformChange(e.target.value)}
className={fieldControlClass}
>
<option value="">请选择平台</option>
{LIVE_CLIP_PLATFORMS.map((platform) => (
<option key={platform} value={platform}>
{platform}
</option>
))}
</select>
</div>
</>
)
}

View File

@ -17,6 +17,8 @@ import {
} 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,
@ -51,6 +53,14 @@ export interface UploadFormProps {
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[]
/** 场景列表 */
@ -93,6 +103,14 @@ export default function UploadForm({
productCategoryName,
renderer,
sku = '',
liveDate = '',
liveHost = '',
liveHostUserId = '',
livePlatform = '',
onLiveDateChange,
onLiveHostChange,
onLiveHostUserIdChange,
onLivePlatformChange,
categories,
scenes,
platforms,
@ -193,21 +211,22 @@ export default function UploadForm({
// 品牌VI灵感类型:只显示SPU和品类
const isBrandVI = materialType === '品牌VI灵感'
const isLiveClip = isLiveClipType(materialType)
return (
<>
{/* SPU 或材质名称 */}
{/* 标题 / SPU 或材质名称 */}
<div>
<label htmlFor="productModel" className={fieldLabelClass}>
{materialType === '材质' ? '材质名称 *' : 'SPU型号 *'}
{isLiveClip ? '标题 *' : materialType === '材质' ? '材质名称 *' : 'SPU型号 *'}
</label>
<input
id="productModel"
type="text"
value={productModel}
onChange={(e) => {
// 材质类型支持中文,其他类型只允许英文、数字和常见符号
if (materialType === '材质') {
// 材质/直播切片支持中文,其他类型只允许英文、数字和常见符号
if (materialType === '材质' || isLiveClip) {
onProductModelChange(e.target.value)
} else {
const value = normalizeSpuProductModel(e.target.value)
@ -215,12 +234,15 @@ export default function UploadForm({
}
}}
maxLength={100}
aria-invalid={isVideoMaterialType(materialType) && productModel.length > 0 && !isPlausibleSpuProductModel(productModel)}
aria-describedby={isVideoMaterialType(materialType) ? 'product-model-hint' : undefined}
aria-invalid={isVideoMaterialType(materialType) && !isLiveClip && productModel.length > 0 && !isPlausibleSpuProductModel(productModel)}
aria-describedby={isVideoMaterialType(materialType) && !isLiveClip ? 'product-model-hint' : undefined}
className={fieldControlClass}
placeholder="例如:DF-A3DZ0008"
placeholder={isLiveClip ? '例如:618大促直播精彩切片' : '例如:DF-A3DZ0008'}
/>
{isVideoMaterialType(materialType) && (
{isLiveClip && (
<p className={`mt-2 ${fieldHintClass}`}>用于标识这段直播切片,便于搜索</p>
)}
{isVideoMaterialType(materialType) && !isLiveClip && (
<p
id="product-model-hint"
className={`mt-2 ${
@ -237,8 +259,22 @@ export default function UploadForm({
)}
</div>
{/* SKU字段(视频类素材使用) */}
{isVideoMaterialType(materialType) && (
{/* 直播切片专用字段 */}
{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>

View File

@ -31,6 +31,10 @@ export function buildFormData(params: {
taskNo?: string
dingtalkApprovalNo?: string
completedChunkUpload?: CompletedChunkUploadReference
liveDate?: string
liveHost?: string
liveHostUserId?: string
livePlatform?: string
}): FormData {
const {
previewImages,
@ -47,6 +51,10 @@ export function buildFormData(params: {
taskNo,
dingtalkApprovalNo,
completedChunkUpload,
liveDate,
liveHost,
liveHostUserId,
livePlatform,
} = params
const formData = new FormData()
@ -90,6 +98,18 @@ export function buildFormData(params: {
if (dingtalkApprovalNo) {
formData.append('dingtalkApprovalNo', dingtalkApprovalNo)
}
if (liveDate) {
formData.append('liveDate', liveDate)
}
if (liveHost) {
formData.append('liveHost', liveHost)
}
if (liveHostUserId) {
formData.append('liveHostUserId', liveHostUserId)
}
if (livePlatform) {
formData.append('livePlatform', livePlatform)
}
// 统一处理renderer字段:根据素材类型判断是否需要传递
const rendererInfo = shouldPassRenderer(materialType, renderer)
@ -115,6 +135,10 @@ export async function uploadSingleFile(params: {
platformName: string
productCategoryName: string
renderer: string
liveDate?: string
liveHost?: string
liveHostUserId?: string
livePlatform?: string
onProgress: (progress: number, speed?: number) => void
onStageChange?: (stage: string) => void
isCancelled?: () => boolean // 取消检查回调

View File

@ -0,0 +1,48 @@
import {
formatLiveDate,
getDefaultLiveDate,
isLiveClipType,
isLiveClipPlatform,
parseLiveDate,
validateLiveClipFields,
LIVE_CLIP_PLATFORMS,
} from '../live-clip'
import { isVideoMaterialType } from '../video-material-types'
import { MATERIAL_TYPE_OPTIONS } from '../material-type-options'
describe('live-clip', () => {
test('类型注册为视频素材', () => {
expect(MATERIAL_TYPE_OPTIONS).toContain('直播切片')
expect(isLiveClipType('直播切片')).toBe(true)
expect(isLiveClipType('淘系-主图视频')).toBe(false)
expect(isVideoMaterialType('直播切片')).toBe(true)
})
test('平台预置主流直播平台', () => {
expect([...LIVE_CLIP_PLATFORMS]).toEqual(['抖音', '快手', '视频号', '淘宝直播', 'B站'])
expect(isLiveClipPlatform('抖音')).toBe(true)
expect(isLiveClipPlatform('淘宝')).toBe(false)
})
test('直播日期默认上传当天且可解析', () => {
const today = getDefaultLiveDate(new Date('2026-09-22T10:00:00'))
expect(today).toBe('2026-09-22')
expect(formatLiveDate(parseLiveDate('2026-09-22'))).toBe('2026-09-22')
expect(parseLiveDate('2026-13-01')).toBeNull()
expect(parseLiveDate('not-a-date')).toBeNull()
})
test('标题必填,平台可选但必须合法', () => {
expect(validateLiveClipFields({ title: '' })[0]?.field).toBe('title')
expect(
validateLiveClipFields({
title: '618直播切片',
liveDate: '2026-09-22',
livePlatform: '抖音',
}),
).toEqual([])
expect(
validateLiveClipFields({ title: 'ok', livePlatform: '未知平台' })[0]?.field,
).toBe('livePlatform')
})
})

97
lib/live-clip.ts Normal file
View File

@ -0,0 +1,97 @@
/**
* 直播切片素材:类型判断、平台预置与字段校验
*/
import { LIVE_CLIP_VIDEO_TYPE } from './video-material-types'
export { LIVE_CLIP_VIDEO_TYPE }
export const LIVE_CLIP_PLATFORMS = [
'抖音',
'快手',
'视频号',
'淘宝直播',
'B站',
] as const
export type LiveClipPlatform = (typeof LIVE_CLIP_PLATFORMS)[number]
export function isLiveClipType(materialType?: string | null): boolean {
return (materialType || '').trim() === LIVE_CLIP_VIDEO_TYPE
}
export function isLiveClipPlatform(value?: string | null): value is LiveClipPlatform {
return LIVE_CLIP_PLATFORMS.includes((value || '') as LiveClipPlatform)
}
/** 上传日期当天(本地日历日,YYYY-MM-DD) */
export function getDefaultLiveDate(now: Date = new Date()): string {
const y = now.getFullYear()
const m = String(now.getMonth() + 1).padStart(2, '0')
const d = String(now.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
export function parseLiveDate(value?: string | null): Date | null {
const trimmed = (value || '').trim()
if (!trimmed) return null
if (!/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) return null
const [y, m, d] = trimmed.split('-').map(Number)
const date = new Date(y, m - 1, d)
if (
date.getFullYear() !== y ||
date.getMonth() !== m - 1 ||
date.getDate() !== d
) {
return null
}
// 存库用 UTC 当天 0 点,避免时区偏移串日
return new Date(Date.UTC(y, m - 1, d))
}
export function formatLiveDate(value?: Date | string | null): string {
if (!value) return ''
const date = value instanceof Date ? value : new Date(value)
if (Number.isNaN(date.getTime())) return ''
const y = date.getUTCFullYear()
const m = String(date.getUTCMonth() + 1).padStart(2, '0')
const d = String(date.getUTCDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
export interface LiveClipFieldInput {
title?: string | null
liveDate?: string | null
liveHost?: string | null
liveHostUserId?: string | null
livePlatform?: string | null
}
export interface LiveClipFieldError {
field: 'title' | 'liveDate' | 'livePlatform'
message: string
}
export function validateLiveClipFields(input: LiveClipFieldInput): LiveClipFieldError[] {
const errors: LiveClipFieldError[] = []
const title = (input.title || '').trim()
if (!title) {
errors.push({ field: 'title', message: '请填写标题' })
} else if (title.length > 100) {
errors.push({ field: 'title', message: '标题不能超过 100 个字符' })
}
const liveDateRaw = (input.liveDate || '').trim()
if (liveDateRaw) {
if (!parseLiveDate(liveDateRaw)) {
errors.push({ field: 'liveDate', message: '直播日期格式无效' })
}
}
const platform = (input.livePlatform || '').trim()
if (platform && !isLiveClipPlatform(platform)) {
errors.push({ field: 'livePlatform', message: '请选择有效的直播平台' })
}
return errors
}

View File

@ -21,6 +21,7 @@ export const MATERIAL_TYPE_OPTIONS = [
TAOXI_GUANGGUANG_VIDEO_TYPE,
DOUYIN_QIANCHUAN_VIDEO_TYPE,
DOUYIN_ACCOUNT_MAINTENANCE_VIDEO_TYPE,
'直播切片',
'音乐',
'标准模型',
'品牌VI灵感',

View File

@ -26,6 +26,7 @@ import { deriveTaskDetailIsFullSet } from '@/lib/task-detail-full-set'
import { queueTaskDetailFullSetNotification } from '@/lib/external-auto-listing'
import { normalizeTaskDetailSourceType } from '@/lib/task-detail-source-type'
import { isCertificateReportMaterialType, normalizeReportTitle } from '@/lib/certificate-report'
import { isLiveClipType, parseLiveDate, validateLiveClipFields } from '@/lib/live-clip'
import {
getDingtalkApprovalNoError,
normalizeDingtalkApprovalNo,
@ -55,6 +56,10 @@ export interface MaterialUpdateInput {
taskDetailRequesterUserId?: string | null
// 兼容旧调用方传参,实际保存时会按资产自动计算
isFullSet?: boolean | string | null
liveDate?: string | null
liveHost?: string | null
liveHostUserId?: string | null
livePlatform?: string | null
}
interface UpdateMaterialOptions {
@ -370,6 +375,21 @@ async function updateMaterialWithSharedRulesLocked(options: UpdateMaterialOption
: false,
}
if (isLiveClipType(materialType)) {
const liveClipErrors = validateLiveClipFields({
title: productModel,
liveDate: input.liveDate,
livePlatform: input.livePlatform,
})
if (liveClipErrors.length > 0) {
return { error: liveClipErrors.map((e) => e.message).join(';'), status: 400 as const }
}
updateData.liveDate = parseLiveDate(input.liveDate) || material.liveDate || null
updateData.liveHost = normalizeOptionalString(input.liveHost)
updateData.liveHostUserId = normalizeOptionalString(input.liveHostUserId)
updateData.livePlatform = normalizeOptionalString(input.livePlatform)
}
// 更换归属人按正式转移处理:在素材上落下最初上传者与本次转移信息,
// 让"改作者"这个动作不再是一次无据可查的静默覆盖。
const isOwnershipTransfer = Boolean(nextUserId && nextUserId !== material.userId)

View File

@ -3,7 +3,7 @@
*/
// 这些类型不需要店铺和场景,系统会自动填充为“通用”
export const AUTO_GENERAL_TYPES: string[] = ['材质', '音乐', '品牌VI灵感', '任务-其他', '证书-检测报告'];
export const AUTO_GENERAL_TYPES: string[] = ['材质', '音乐', '品牌VI灵感', '任务-其他', '证书-检测报告', '直播切片'];
/**
* 根据素材类型判断是否需要renderer字段

View File

@ -3,6 +3,7 @@ export const TAOXI_GUANGGUANG_VIDEO_TYPE = '淘系-逛逛视频'
export const DOUYIN_QIANCHUAN_VIDEO_TYPE = '抖音-千川素材'
export const DOUYIN_ACCOUNT_MAINTENANCE_VIDEO_TYPE = '抖音-账号维护'
export const INSTALL_VIDEO_TYPE = '安装视频'
export const LIVE_CLIP_VIDEO_TYPE = '直播切片'
const MATERIAL_TYPE_ALIASES: Record<string, string> = {
主图视频: TAOXI_MAIN_VIDEO_TYPE,
@ -15,6 +16,7 @@ export const CANONICAL_VIDEO_MATERIAL_TYPES = [
TAOXI_GUANGGUANG_VIDEO_TYPE,
DOUYIN_QIANCHUAN_VIDEO_TYPE,
DOUYIN_ACCOUNT_MAINTENANCE_VIDEO_TYPE,
LIVE_CLIP_VIDEO_TYPE,
] as const
export const LEGACY_COMPATIBLE_VIDEO_MATERIAL_TYPES = [

View File

@ -0,0 +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");

View File

@ -103,6 +103,11 @@ model Material {
transcodeLeaseExpiresAt DateTime? @map("transcode_lease_expires_at")
deletedAt DateTime?
productCategoryName String @default("")
/// 直播切片专用:直播日期 / 主播 / 可选关联用户 / 直播平台
liveDate DateTime? @map("live_date")
liveHost String? @map("live_host")
liveHostUserId String? @map("live_host_user_id")
livePlatform String? @map("live_platform")
originalOwnerId String?
transferredAt DateTime?
transferredBy String?
@ -149,6 +154,7 @@ model Material {
@@index([platformName])
@@index([productCategoryName])
@@index([materialType])
@@index([liveDate])
@@index([downloadCount])
@@index([likeCount])
@@index([deletedAt])