From d8d843e6adc32298e489f14c43c200dae9da1dca Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Tue, 22 Sep 2026 18:34:04 +0800 Subject: [PATCH] feat: add live clip material type with date host and platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/api/materials/[id]/route.ts | 8 + app/api/materials/route.ts | 8 + app/api/materials/share/route.ts | 4 + app/api/materials/update/route.ts | 12 ++ app/api/materials/upload/route.ts | 48 +++++- app/edit/[id]/page.tsx | 62 ++++++- components/UploadModal.tsx | 20 ++- components/upload/LiveClipFields.tsx | 151 ++++++++++++++++++ components/upload/UploadForm.tsx | 56 +++++-- components/upload/file-upload-handlers.ts | 24 +++ lib/__tests__/live-clip.test.ts | 48 ++++++ lib/live-clip.ts | 97 +++++++++++ lib/material-type-options.ts | 1 + lib/material-update.ts | 20 +++ lib/material-validation.ts | 2 +- lib/video-material-types.ts | 2 + .../migration.sql | 6 + prisma/schema.prisma | 6 + 18 files changed, 557 insertions(+), 18 deletions(-) create mode 100644 components/upload/LiveClipFields.tsx create mode 100644 lib/__tests__/live-clip.test.ts create mode 100644 lib/live-clip.ts create mode 100644 prisma/migrations/20260922_add_live_clip_fields/migration.sql diff --git a/app/api/materials/[id]/route.ts b/app/api/materials/[id]/route.ts index 45e0e34..764a39e 100644 --- a/app/api/materials/[id]/route.ts +++ b/app/api/materials/[id]/route.ts @@ -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, diff --git a/app/api/materials/route.ts b/app/api/materials/route.ts index d5936a1..1c5c474 100644 --- a/app/api/materials/route.ts +++ b/app/api/materials/route.ts @@ -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, diff --git a/app/api/materials/share/route.ts b/app/api/materials/share/route.ts index 695ee84..fae9b9a 100644 --- a/app/api/materials/share/route.ts +++ b/app/api/materials/share/route.ts @@ -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, diff --git a/app/api/materials/update/route.ts b/app/api/materials/update/route.ts index 999abbe..b2e7e20 100644 --- a/app/api/materials/update/route.ts +++ b/app/api/materials/update/route.ts @@ -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 } : {}), diff --git a/app/api/materials/upload/route.ts b/app/api/materials/upload/route.ts index a14fcbb..4ad2f2f 100644 --- a/app/api/materials/upload/route.ts +++ b/app/api/materials/upload/route.ts @@ -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: 不写入,上传完成后再写入 diff --git a/app/edit/[id]/page.tsx b/app/edit/[id]/page.tsx index 1281328..5652e1d 100644 --- a/app/edit/[id]/page.tsx +++ b/app/edit/[id]/page.tsx @@ -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 === '直播切片' ? '标题 *' : '产品型号 *'} + {materialType === '直播切片' && ( +
+
+ + setLiveDate(e.target.value)} + className="input-apple" + /> +
+
+ + setLiveHost(e.target.value)} + maxLength={50} + className="input-apple" + /> +
+
+ + +
+
+ )} + {isCertificateReport && (