feat: 记录任务详情需求运营

This commit is contained in:
chenpei 2026-07-24 14:47:27 +08:00
parent 770a39b127
commit 7925ca74de
22 changed files with 809 additions and 28 deletions

View File

@ -165,6 +165,8 @@ export async function GET(
materialType: true,
isAiGenerated: true,
taskDetailSourceType: true,
taskDetailRequesterUserId: true,
taskDetailRequesterName: true,
renderer: true,
authorName: true,
previewUrl: true,
@ -234,6 +236,8 @@ export async function GET(
materialType: true,
isAiGenerated: true,
taskDetailSourceType: true,
taskDetailRequesterUserId: true,
taskDetailRequesterName: true,
renderer: true,
authorName: true,
previewUrl: true,
@ -392,6 +396,8 @@ export async function GET(
materialType: materialWithUser.materialType,
isAiGenerated: Boolean(materialWithUser.isAiGenerated),
taskDetailSourceType: materialWithUser.taskDetailSourceType || null,
taskDetailRequesterUserId: materialWithUser.taskDetailRequesterUserId || null,
taskDetailRequesterName: materialWithUser.taskDetailRequesterName || null,
renderer: materialWithUser.renderer,
authorName: displayAuthorName, // 使用处理后的作者名
previewUrl: materialWithUser.previewUrl,
@ -502,6 +508,7 @@ export async function PUT(
userId: formData.get('userId') as string | null,
taskNo: formData.get('taskNo') as string | null,
taskDetailSourceType: formData.get('taskDetailSourceType') as string | null,
taskDetailRequesterUserId: formData.get('taskDetailRequesterUserId') as string | null,
isFullSet: formData.get('isFullSet') as string | null,
};
const formPreview = formData.get('previewImage');
@ -539,6 +546,8 @@ export async function PUT(
authorName: result.material.authorName,
taskNo: result.material.taskNo,
taskDetailSourceType: (result.material as any).taskDetailSourceType || null,
taskDetailRequesterUserId: (result.material as any).taskDetailRequesterUserId || null,
taskDetailRequesterName: (result.material as any).taskDetailRequesterName || null,
taskStatus: result.material.taskStatus,
taskFetchedAt: result.material.taskFetchedAt,
isFullSet: Boolean((result.material as any).isFullSet),

View File

@ -118,6 +118,8 @@ export async function GET(request: NextRequest) {
materialType: true,
isAiGenerated: true,
taskDetailSourceType: true,
taskDetailRequesterUserId: true,
taskDetailRequesterName: true,
renderer: true,
authorName: true,
zipFileName: true,
@ -341,6 +343,8 @@ export async function GET(request: NextRequest) {
materialType: true,
isAiGenerated: true,
taskDetailSourceType: true,
taskDetailRequesterUserId: true,
taskDetailRequesterName: true,
renderer: true,
authorName: true,
zipFileName: true,
@ -444,6 +448,8 @@ export async function GET(request: NextRequest) {
materialType: true,
isAiGenerated: true,
taskDetailSourceType: true,
taskDetailRequesterUserId: true,
taskDetailRequesterName: true,
renderer: true,
authorName: true,
zipFileName: true,
@ -506,6 +512,8 @@ export async function GET(request: NextRequest) {
materialType: true,
isAiGenerated: true,
taskDetailSourceType: true,
taskDetailRequesterUserId: true,
taskDetailRequesterName: true,
renderer: true,
authorName: true,
zipFileName: true,
@ -574,6 +582,8 @@ export async function GET(request: NextRequest) {
materialType: true,
isAiGenerated: true,
taskDetailSourceType: true,
taskDetailRequesterUserId: true,
taskDetailRequesterName: true,
renderer: true,
authorName: true,
zipFileName: true,

View File

@ -33,6 +33,7 @@ export async function PUT(request: NextRequest) {
userId: formData.get('userId') as string | null,
taskNo: formData.get('taskNo') as string | null,
taskDetailSourceType: formData.get('taskDetailSourceType') as string | null,
taskDetailRequesterUserId: formData.get('taskDetailRequesterUserId') as string | null,
isFullSet: formData.get('isFullSet') as string | null,
};
const formPreview = formData.get('previewImage');
@ -65,6 +66,8 @@ export async function PUT(request: NextRequest) {
zipFileSize: result.material.zipFileSize.toString(),
isFullSet: Boolean((result.material as any).isFullSet),
taskDetailSourceType: (result.material as any).taskDetailSourceType || null,
taskDetailRequesterUserId: (result.material as any).taskDetailRequesterUserId || null,
taskDetailRequesterName: (result.material as any).taskDetailRequesterName || null,
};
return NextResponse.json(

View File

@ -52,6 +52,8 @@ export async function POST(request: NextRequest) {
select: {
id: true,
userId: true,
materialType: true,
taskDetailRequesterUserId: true,
},
})
@ -69,6 +71,20 @@ export async function POST(request: NextRequest) {
)
}
if (material.materialType !== '任务-详情') {
return NextResponse.json(
{ success: false, error: '当前素材不是任务-详情,不能使用此完成接口' },
{ status: 400 },
)
}
if (!material.taskDetailRequesterUserId) {
return NextResponse.json(
{ success: false, error: '当前素材缺少需求运营人员,不能完成上传' },
{ status: 400 },
)
}
const result = await completeProductPackageUpload({
materialId: body.materialId,
taskNo: body.taskNo,

View File

@ -22,9 +22,17 @@ export async function POST(request: NextRequest) {
}
const body = await request.json();
const { taskNo, productModel, categoryName, sceneName, platformName, productCategoryName } = body;
const {
taskNo,
productModel,
categoryName,
sceneName,
platformName,
productCategoryName,
} = body;
const rawTaskDetailSourceType = body?.taskDetailSourceType;
const taskDetailSourceType = normalizeTaskDetailSourceType(rawTaskDetailSourceType);
const taskDetailRequesterUserId = String(body?.taskDetailRequesterUserId || '').trim();
if (rawTaskDetailSourceType != null && String(rawTaskDetailSourceType).trim() && !taskDetailSourceType) {
return NextResponse.json(
@ -41,6 +49,33 @@ export async function POST(request: NextRequest) {
);
}
if (!taskDetailRequesterUserId) {
return NextResponse.json(
{ success: false, error: '请选择需求运营人员' },
{ status: 400 }
);
}
const taskDetailRequester = await prisma.user.findFirst({
where: {
id: taskDetailRequesterUserId,
isActive: true,
status: 'active',
},
select: {
id: true,
username: true,
realName: true,
},
});
if (!taskDetailRequester) {
return NextResponse.json(
{ success: false, error: '所选需求运营人员不存在或已停用' },
{ status: 400 }
);
}
// 如果提供了任务号,验证格式和唯一性
if (taskNo && taskNo.trim()) {
// 验证任务号格式
@ -90,6 +125,8 @@ export async function POST(request: NextRequest) {
renderer: '', // 任务-详情不需要渲染器
isFullSet: false,
taskDetailSourceType,
taskDetailRequesterUserId: taskDetailRequester.id,
taskDetailRequesterName: taskDetailRequester.realName || taskDetailRequester.username,
} as any,
});

View File

@ -225,6 +225,14 @@ export async function POST(request: NextRequest) {
const productCategoryName = (formData.get('productCategoryName') as string) || '';
const materialTypeRaw = formData.get('materialType') as string | null;
const materialType = normalizeMaterialType(materialTypeRaw);
if (materialType === '任务-详情') {
return NextResponse.json(
{ error: '任务-详情必须使用专用上传入口,并选择需求运营人员' },
{ status: 400 }
);
}
const isTaskOtherMaterial = materialType === '任务-其他';
const rendererParam = formData.get('renderer') as string | null;
// 统一处理renderer:3D场景必须有renderer,其他类型可选

View File

@ -33,6 +33,8 @@ interface Material {
taskStatus?: string | null
taskFetchedAt?: string | null
isFullSet?: boolean
taskDetailRequesterUserId?: string | null
taskDetailRequesterName?: string | null
}
export default function EditMaterialPage({ params }: { params: { id: string } }) {
@ -59,6 +61,8 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
const [taskStatus, setTaskStatus] = useState('')
const [taskFetchedAt, setTaskFetchedAt] = useState('')
const [isFullSet, setIsFullSet] = useState(false)
const [taskDetailRequesterUserId, setTaskDetailRequesterUserId] = useState('')
const [taskDetailRequesterName, setTaskDetailRequesterName] = useState('')
const [categories, setCategories] = useState<Array<{ id: string; name: string }>>([])
const [scenes, setScenes] = useState<Array<{ id: string; name: string }>>([])
@ -73,6 +77,8 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
const allowPreviewReplacement = canManuallyReplacePreview(materialType)
const isFetchedTask = taskStatus === 'fetched'
const materialTypeOptions = getSelectableMaterialTypeOptions(materialType)
const canKeepRequesterBlank =
material?.materialType === '任务-详情' && !material.taskDetailRequesterUserId
const loadInitialData = useCallback(async () => {
try {
@ -133,6 +139,8 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
setTaskStatus(data.taskStatus || '')
setTaskFetchedAt(data.taskFetchedAt || '')
setIsFullSet(Boolean(data.isFullSet))
setTaskDetailRequesterUserId(data.taskDetailRequesterUserId || '')
setTaskDetailRequesterName(data.taskDetailRequesterName || '')
if (categoriesResponse.ok) {
setCategories(await categoriesResponse.json())
@ -147,7 +155,19 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
setProductCategories(await productCategoriesResponse.json())
}
if (usersResponse.ok) {
setUsers(await usersResponse.json())
const activeUsers = await usersResponse.json()
const normalizedUsers = Array.isArray(activeUsers) ? activeUsers : []
if (
data.taskDetailRequesterUserId &&
!normalizedUsers.some((user) => user.id === data.taskDetailRequesterUserId)
) {
normalizedUsers.push({
id: data.taskDetailRequesterUserId,
username: '已停用',
displayName: data.taskDetailRequesterName || '原需求运营',
})
}
setUsers(normalizedUsers)
}
} catch (loadError: any) {
console.error('加载编辑页失败:', loadError)
@ -211,6 +231,11 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
return
}
if (materialType === '任务-详情' && !taskDetailRequesterUserId && !canKeepRequesterBlank) {
setError('请选择需求运营人员')
return
}
if (isFetchedTask) {
const confirmed = window.confirm('这条任务素材已经被外部系统获取过,继续保存可能影响对方使用,确认继续吗?')
if (!confirmed) {
@ -232,6 +257,10 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
formData.append('authorName', authorName)
formData.append('userId', userId)
formData.append('taskNo', showTaskNo ? taskNo : '')
formData.append(
'taskDetailRequesterUserId',
materialType === '任务-详情' ? taskDetailRequesterUserId : '',
)
if (previewImage && allowPreviewReplacement) {
formData.append('previewImage', previewImage)
@ -463,6 +492,32 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
</div>
)}
{materialType === '任务-详情' && (
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
需求运营{canKeepRequesterBlank ? '' : ' *'}
</label>
<SearchableUserSelect
users={users}
value={taskDetailRequesterUserId}
onChange={(nextUserId, displayName) => {
setTaskDetailRequesterUserId(nextUserId)
setTaskDetailRequesterName(displayName)
}}
placeholder="搜索发起本次出图需求的运营人员"
required={!canKeepRequesterBlank}
clearable={canKeepRequesterBlank}
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{canKeepRequesterBlank
? taskDetailRequesterName
? `原记录为“${taskDetailRequesterName}”,对应账号已不可选;可保持原记录或重新选择。`
: '这是一条历史素材,原来没有需求运营数据,可暂时留空。'
: '记录发起本次出图需求的运营人员,与素材所有者可以不同。'}
</p>
</div>
)}
{showIsFullSet && (
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">

View File

@ -35,6 +35,8 @@ interface EditMaterialModalProps {
taskStatus?: string | null
taskFetchedAt?: string | null
isFullSet?: boolean
taskDetailRequesterUserId?: string | null
taskDetailRequesterName?: string | null
}
onClose: () => void
onSuccess: () => void
@ -55,6 +57,8 @@ export default function EditMaterialModal({ material, onClose, onSuccess }: Edit
const [userId, setUserId] = useState(material.userId)
const [taskNo, setTaskNo] = useState(material.taskNo || '')
const [taskDetailSourceType, setTaskDetailSourceType] = useState(material.taskDetailSourceType || '')
const [taskDetailRequesterUserId, setTaskDetailRequesterUserId] = useState(material.taskDetailRequesterUserId || '')
const [taskDetailRequesterName, setTaskDetailRequesterName] = useState(material.taskDetailRequesterName || '')
const [taskStatus] = useState(material.taskStatus || '')
const [taskFetchedAt] = useState(material.taskFetchedAt || '')
const [isFullSet] = useState(Boolean(material.isFullSet))
@ -74,6 +78,8 @@ export default function EditMaterialModal({ material, onClose, onSuccess }: Edit
const isCertificateReport = isCertificateReportMaterialType(materialType)
const isFetchedTask = taskStatus === 'fetched'
const materialTypeOptions = getSelectableMaterialTypeOptions(materialType)
const canKeepRequesterBlank =
material.materialType === '任务-详情' && !material.taskDetailRequesterUserId
useEffect(() => {
void fetchSelectOptions()
@ -102,7 +108,19 @@ export default function EditMaterialModal({ material, onClose, onSuccess }: Edit
setProductCategories(await productCategoriesResponse.json())
}
if (usersResponse.ok) {
setUsers(await usersResponse.json())
const activeUsers = await usersResponse.json()
const normalizedUsers = Array.isArray(activeUsers) ? activeUsers : []
if (
material.taskDetailRequesterUserId &&
!normalizedUsers.some((user) => user.id === material.taskDetailRequesterUserId)
) {
normalizedUsers.push({
id: material.taskDetailRequesterUserId,
username: '已停用',
displayName: material.taskDetailRequesterName || '原需求运营',
})
}
setUsers(normalizedUsers)
}
} catch (fetchError) {
console.error('加载编辑弹窗数据失败:', fetchError)
@ -159,6 +177,11 @@ export default function EditMaterialModal({ material, onClose, onSuccess }: Edit
return
}
if (materialType === '任务-详情' && !taskDetailRequesterUserId && !canKeepRequesterBlank) {
setError('请选择需求运营人员')
return
}
if (isFetchedTask) {
const confirmed = window.confirm('这条任务素材已经被外部系统获取过,继续保存可能影响对方使用,确认继续吗?')
if (!confirmed) {
@ -183,6 +206,10 @@ export default function EditMaterialModal({ material, onClose, onSuccess }: Edit
formData.append('userId', userId)
formData.append('taskNo', showTaskNo ? taskNo : '')
formData.append('taskDetailSourceType', materialType === '任务-详情' ? taskDetailSourceType : '')
formData.append(
'taskDetailRequesterUserId',
materialType === '任务-详情' ? taskDetailRequesterUserId : '',
)
if (previewImage && allowPreviewReplacement) {
formData.append('previewImage', previewImage)
@ -375,21 +402,47 @@ export default function EditMaterialModal({ material, onClose, onSuccess }: Edit
)}
{materialType === '任务-详情' && (
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">出图方式</label>
<select
value={taskDetailSourceType}
onChange={(event) => setTaskDetailSourceType(event.target.value)}
className="input-apple"
>
<option value="">未设置</option>
{TASK_DETAIL_SOURCE_TYPE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
<>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">出图方式</label>
<select
value={taskDetailSourceType}
onChange={(event) => setTaskDetailSourceType(event.target.value)}
className="input-apple"
>
<option value="">未设置</option>
{TASK_DETAIL_SOURCE_TYPE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
需求运营{canKeepRequesterBlank ? '' : ' *'}
</label>
<SearchableUserSelect
users={users}
value={taskDetailRequesterUserId}
onChange={(nextUserId, displayName) => {
setTaskDetailRequesterUserId(nextUserId)
setTaskDetailRequesterName(displayName)
}}
placeholder="搜索发起本次出图需求的运营人员"
required={!canKeepRequesterBlank}
clearable={canKeepRequesterBlank}
/>
<p className="mt-1 text-xs text-gray-500">
{canKeepRequesterBlank
? taskDetailRequesterName
? `原记录为“${taskDetailRequesterName}”,对应账号已不可选;可保持原记录或重新选择。`
: '这是一条历史素材,原来没有需求运营数据,可暂时留空。'
: '记录发起本次出图需求的运营人员,与素材所有者可以不同。'}
</p>
</div>
</>
)}
{showIsFullSet && (

View File

@ -16,6 +16,8 @@ interface SearchableUserSelectProps {
placeholder?: string
required?: boolean
className?: string
disabled?: boolean
clearable?: boolean
}
export default function SearchableUserSelect({
@ -25,6 +27,8 @@ export default function SearchableUserSelect({
placeholder = '请选择或搜索用户',
required = false,
className = '',
disabled = false,
clearable = true,
}: SearchableUserSelectProps) {
const [searchTerm, setSearchTerm] = useState('')
const [isOpen, setIsOpen] = useState(false)
@ -84,6 +88,10 @@ export default function SearchableUserSelect({
// 键盘导航
const handleKeyDown = (e: React.KeyboardEvent) => {
if (disabled) {
return
}
if (!isOpen) {
if (e.key === 'Enter' || e.key === 'ArrowDown') {
e.preventDefault()
@ -124,6 +132,10 @@ export default function SearchableUserSelect({
// 打开下拉框
const handleFocus = () => {
if (disabled) {
return
}
setIsOpen(true)
setHighlightedIndex(0)
}
@ -131,6 +143,10 @@ export default function SearchableUserSelect({
// 清空选择
const handleClear = (e: React.MouseEvent) => {
e.stopPropagation()
if (disabled || !clearable) {
return
}
onChange('', '')
setSearchTerm('')
setIsOpen(false)
@ -149,12 +165,13 @@ export default function SearchableUserSelect({
onKeyDown={handleKeyDown}
placeholder={placeholder}
required={required}
className="input-apple pr-20"
disabled={disabled}
className="input-apple pr-20 disabled:cursor-not-allowed disabled:opacity-60"
autoComplete="off"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
{value && (
{value && clearable && !disabled && (
<button
type="button"
onClick={handleClear}
@ -169,8 +186,13 @@ export default function SearchableUserSelect({
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors"
onClick={() => {
if (!disabled) {
setIsOpen(!isOpen)
}
}}
disabled={disabled}
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors disabled:cursor-not-allowed"
>
<svg
className={`w-4 h-4 text-gray-500 transition-transform ${isOpen ? 'rotate-180' : ''}`}
@ -184,7 +206,7 @@ export default function SearchableUserSelect({
</div>
</div>
{isOpen && (
{isOpen && !disabled && (
<div className="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-y-auto">
{filteredUsers.length === 0 ? (
<div className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400 text-center">

View File

@ -30,8 +30,20 @@ const defaultProps = {
}
describe('ProductPackageUploader', () => {
const originalFetch = global.fetch
beforeEach(() => {
jest.clearAllMocks()
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => [
{
id: 'requester-1',
username: 'operator-a',
displayName: '运营甲',
},
],
}) as jest.Mock
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
@ -46,9 +58,14 @@ describe('ProductPackageUploader', () => {
})
})
afterAll(() => {
global.fetch = originalFetch
})
test('任务-详情源文件选择框支持 psb/tif/tiff', async () => {
const user = userEvent.setup()
render(<ProductPackageUploader {...defaultProps} />)
await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
const input = screen.getByLabelText(/PSD源文件/) as HTMLInputElement
expect(input.accept).toBe('.psd,.psb,.tif,.tiff,.zip,.rar,.7z')
@ -69,6 +86,7 @@ describe('ProductPackageUploader', () => {
test('空的任务-详情源文件会被直接拦截并提示', async () => {
const user = userEvent.setup()
render(<ProductPackageUploader {...defaultProps} />)
await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
const input = screen.getByLabelText(/PSD源文件/) as HTMLInputElement
const emptyFile = new File([], 'empty.psd', { type: 'application/octet-stream' })
@ -79,8 +97,9 @@ describe('ProductPackageUploader', () => {
expect(screen.queryByText('empty.psd')).not.toBeInTheDocument()
})
test('应提供任务详情出图方式选择项,且默认未设置', () => {
test('应提供任务详情出图方式选择项,且默认未设置', async () => {
render(<ProductPackageUploader {...defaultProps} />)
await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
const select = screen.getByLabelText(/出图方式/) as HTMLSelectElement
expect(select.value).toBe('')
@ -89,4 +108,28 @@ describe('ProductPackageUploader', () => {
expect(screen.getByRole('option', { name: '渲染' })).toBeInTheDocument()
expect(screen.getByRole('option', { name: 'AI' })).toBeInTheDocument()
})
test('应提供必填的需求运营搜索框并允许选择人员', async () => {
const user = userEvent.setup()
render(<ProductPackageUploader {...defaultProps} />)
const input = await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
await user.click(input)
await user.type(input, '运营甲')
await user.click(screen.getByRole('button', { name: /运营甲/ }))
expect(input).toHaveValue('运营甲 (operator-a)')
expect(screen.getByText('记录发起本次出图需求的运营人员,与素材上传人可以不同。')).toBeInTheDocument()
})
test('未选择需求运营时提交应被前端拦截', async () => {
const user = userEvent.setup()
render(<ProductPackageUploader {...defaultProps} />)
await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
await user.click(screen.getByRole('button', { name: '提交' }))
expect(screen.getByText('请选择需求运营人员')).toBeInTheDocument()
expect(global.fetch).toHaveBeenCalledTimes(1)
})
})

View File

@ -4,10 +4,11 @@
'use client';
import { useState, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import DetailImageSorter from './DetailImageSorter';
import UploadForm from './UploadForm';
import { addCategory, addScene } from './category-handlers';
import SearchableUserSelect from '@/components/SearchableUserSelect';
import {
TASK_DETAIL_SOURCE_TYPE_OPTIONS,
type TaskDetailSourceType,
@ -100,6 +101,10 @@ export default function ProductPackageUploader({
const [platformName, setPlatformName] = useState('');
const [productCategoryName, setProductCategoryName] = useState('');
const [taskDetailSourceType, setTaskDetailSourceType] = useState<TaskDetailSourceType | ''>('');
const [taskDetailRequesterUserId, setTaskDetailRequesterUserId] = useState('');
const [users, setUsers] = useState<Array<{ id: string; username: string; displayName: string }>>([]);
const [usersLoading, setUsersLoading] = useState(true);
const [usersLoadError, setUsersLoadError] = useState('');
// 资产文件
const [mainImages, setMainImages] = useState<AssetFile[]>([]);
@ -126,6 +131,40 @@ export default function ProductPackageUploader({
const detailInputRef = useRef<HTMLInputElement>(null);
const psdInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
let active = true;
const loadUsers = async () => {
try {
const response = await fetch('/api/users/list');
if (!response.ok) {
throw new Error('人员列表加载失败');
}
const data = await response.json();
if (active) {
setUsers(Array.isArray(data) ? data : []);
setUsersLoadError('');
}
} catch (loadError) {
console.error('[ProductPackageUploader] 加载需求运营人员失败:', loadError);
if (active) {
setUsersLoadError('人员列表加载失败,请刷新后重试');
}
} finally {
if (active) {
setUsersLoading(false);
}
}
};
void loadUsers();
return () => {
active = false;
};
}, []);
// 验证任务号格式
// 支持格式:TASK + 12-18位数字 + 可选的多个子任务号(-2位数字,可重复)
// 示例:
@ -380,6 +419,11 @@ export default function ProductPackageUploader({
// 提交上传
const handleSubmit = async () => {
if (!taskDetailRequesterUserId) {
setError('请选择需求运营人员');
return;
}
// 验证必填字段(任务号改为可选,不提示)
if (!productModel || !categoryName) {
setError('请填写SPU型号和店铺');
@ -459,6 +503,7 @@ export default function ProductPackageUploader({
platformName: platformName || undefined,
productCategoryName: productCategoryName || undefined,
taskDetailSourceType: taskDetailSourceType || undefined,
taskDetailRequesterUserId,
}),
});
@ -656,6 +701,26 @@ export default function ProductPackageUploader({
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
需求运营 <span className="text-red-500">*</span>
</label>
<SearchableUserSelect
users={users}
value={taskDetailRequesterUserId}
onChange={(userId) => {
setTaskDetailRequesterUserId(userId);
setError('');
}}
placeholder={usersLoading ? '正在加载人员...' : '搜索发起本次出图需求的运营人员'}
required
disabled={uploading || usersLoading || Boolean(usersLoadError)}
/>
<p className={`mt-1 text-xs ${usersLoadError ? 'text-red-500' : 'text-gray-500 dark:text-gray-400'}`}>
{usersLoadError || '记录发起本次出图需求的运营人员,与素材上传人可以不同。'}
</p>
</div>
{/* 使用 UploadForm 组件显示 SPU、店铺、场景字段 */}
<UploadForm
materialType="任务-详情"
@ -1000,7 +1065,7 @@ export default function ProductPackageUploader({
<button
onClick={handleSubmit}
className="btn-primary disabled:opacity-50"
disabled={uploading}
disabled={uploading || usersLoading || Boolean(usersLoadError)}
type="button"
>
{uploading ? '上传中...' : '提交'}

View File

@ -366,6 +366,8 @@ describe('external material import', () => {
materialType: '任务-详情',
userId: 'user-1',
isAiGenerated: true,
taskDetailRequesterUserId: 'user-1',
taskDetailRequesterName: '张三',
}),
})
expect(completeProductPackageUpload).toHaveBeenCalledWith({

View File

@ -83,6 +83,8 @@ describe('material edit routes', () => {
authorName: '测试作者',
taskNo: null,
taskDetailSourceType: null,
taskDetailRequesterUserId: null,
taskDetailRequesterName: null,
taskStatus: null,
taskFetchedAt: null,
isFullSet: false,
@ -117,6 +119,7 @@ describe('material edit routes', () => {
formData.append('userId', 'user-1')
formData.append('taskNo', '')
formData.append('taskDetailSourceType', 'AI')
formData.append('taskDetailRequesterUserId', 'requester-1')
return formData
}
@ -212,6 +215,8 @@ describe('material edit routes', () => {
taskFetchedAt: new Date('2026-04-03T00:00:01.000Z'),
isFullSet: false,
taskDetailSourceType: '渲染',
taskDetailRequesterUserId: 'requester-1',
taskDetailRequesterName: '运营甲',
user: {
id: 'user-1',
username: '测试用户',
@ -242,6 +247,8 @@ describe('material edit routes', () => {
expect(body.zipFileSize).toBe('123')
expect(body.assets[0].fileSize).toBe('456')
expect(body.taskDetailSourceType).toBe('渲染')
expect(body.taskDetailRequesterUserId).toBe('requester-1')
expect(body.taskDetailRequesterName).toBe('运营甲')
expect(body.sourceFileUrl).toBe('/api/materials/download?id=material-1')
})
@ -263,4 +270,39 @@ describe('material edit routes', () => {
}),
)
})
test.each([
['PUT /api/materials/[id]', false],
['PUT /api/materials/update', true],
])('%s 应透传需求运营人员字段', async (_label, legacyRoute) => {
const formData = createFormData()
if (legacyRoute) {
formData.append('id', 'material-1')
}
const request = new NextRequest(
legacyRoute
? 'http://localhost/api/materials/update'
: 'http://localhost/api/materials/material-1',
{
method: 'PUT',
body: formData,
},
)
if (legacyRoute) {
await putMaterialUpdate(request)
} else {
await putMaterialById(request, {
params: { id: 'material-1' },
})
}
expect(updateMaterialWithSharedRules).toHaveBeenCalledWith(
expect.objectContaining({
input: expect.objectContaining({
taskDetailRequesterUserId: 'requester-1',
}),
}),
)
})
})

View File

@ -16,6 +16,7 @@ jest.mock('@/lib/prisma', () => ({
},
user: {
findUnique: jest.fn(),
findFirst: jest.fn(),
},
},
}))
@ -62,6 +63,8 @@ describe('material-update shared rules', () => {
renderer: 'CR',
authorName: '旧作者',
taskNo: 'TASK202601141713525624',
taskDetailRequesterUserId: null,
taskDetailRequesterName: null,
isFullSet: true,
assets: [],
deletedAt: null,
@ -457,6 +460,199 @@ describe('material-update shared rules', () => {
})
})
test('历史任务详情没有需求运营时,编辑其他字段仍可继续留空', async () => {
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
...baseMaterial,
materialType: '任务-详情',
categoryName: '店铺A',
sceneName: '场景A',
taskDetailRequesterUserId: null,
taskDetailRequesterName: null,
})
;(prisma.material.update as jest.Mock).mockImplementation(async ({ data }) => ({
...baseMaterial,
...data,
}))
const result = await updateMaterialWithSharedRules({
materialId: 'material-1',
operatorUserId: 'user-1',
operatorRole: 'USER',
input: {
productModel: 'TASK-SPU-001',
categoryName: '店铺A',
sceneName: '场景A',
materialType: '任务-详情',
renderer: '',
authorName: '任务作者',
userId: 'user-1',
taskDetailRequesterUserId: '',
},
})
expect('error' in result).toBe(false)
expect(prisma.material.update).toHaveBeenCalledWith({
where: { id: 'material-1' },
data: expect.objectContaining({
taskDetailRequesterUserId: null,
taskDetailRequesterName: null,
}),
})
})
test('其他素材切换为任务详情时必须选择需求运营', async () => {
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(baseMaterial)
const result = await updateMaterialWithSharedRules({
materialId: 'material-1',
operatorUserId: 'user-1',
operatorRole: 'USER',
input: {
productModel: 'TASK-SPU-001',
categoryName: '店铺A',
sceneName: '场景A',
materialType: '任务-详情',
renderer: '',
authorName: '任务作者',
userId: 'user-1',
taskDetailRequesterUserId: '',
},
})
expect(result).toEqual({
error: '请选择需求运营人员',
status: 400,
})
expect(prisma.material.update).not.toHaveBeenCalled()
})
test('已有需求运营的任务详情不能清空该人员', async () => {
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
...baseMaterial,
materialType: '任务-详情',
categoryName: '店铺A',
sceneName: '场景A',
taskDetailRequesterUserId: 'requester-old',
taskDetailRequesterName: '运营旧',
})
const result = await updateMaterialWithSharedRules({
materialId: 'material-1',
operatorUserId: 'user-1',
operatorRole: 'USER',
input: {
productModel: 'TASK-SPU-001',
categoryName: '店铺A',
sceneName: '场景A',
materialType: '任务-详情',
renderer: '',
authorName: '任务作者',
userId: 'user-1',
taskDetailRequesterUserId: '',
},
})
expect(result).toEqual({
error: '需求运营人员不能清空,只能更换',
status: 400,
})
expect(prisma.material.update).not.toHaveBeenCalled()
})
test('更换需求运营时应校验有效人员并由服务端保存姓名快照', async () => {
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
...baseMaterial,
materialType: '任务-详情',
categoryName: '店铺A',
sceneName: '场景A',
taskDetailRequesterUserId: 'requester-old',
taskDetailRequesterName: '运营旧',
})
;(prisma.user.findFirst as jest.Mock).mockResolvedValue({
id: 'requester-new',
username: 'new-operator',
realName: '运营新',
})
;(prisma.material.update as jest.Mock).mockImplementation(async ({ data }) => ({
...baseMaterial,
...data,
}))
const result = await updateMaterialWithSharedRules({
materialId: 'material-1',
operatorUserId: 'user-1',
operatorRole: 'USER',
input: {
productModel: 'TASK-SPU-001',
categoryName: '店铺A',
sceneName: '场景A',
materialType: '任务-详情',
renderer: '',
authorName: '任务作者',
userId: 'user-1',
taskDetailRequesterUserId: 'requester-new',
},
})
expect('error' in result).toBe(false)
expect(prisma.user.findFirst).toHaveBeenCalledWith({
where: {
id: 'requester-new',
isActive: true,
status: 'active',
},
select: {
id: true,
username: true,
realName: true,
},
})
expect(prisma.material.update).toHaveBeenCalledWith({
where: { id: 'material-1' },
data: expect.objectContaining({
taskDetailRequesterUserId: 'requester-new',
taskDetailRequesterName: '运营新',
}),
})
})
test('任务详情切换为其他素材类型时应清空需求运营字段', async () => {
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
...baseMaterial,
materialType: '任务-详情',
taskDetailRequesterUserId: 'requester-old',
taskDetailRequesterName: '运营旧',
})
;(prisma.material.update as jest.Mock).mockImplementation(async ({ data }) => ({
...baseMaterial,
...data,
}))
await updateMaterialWithSharedRules({
materialId: 'material-1',
operatorUserId: 'user-1',
operatorRole: 'USER',
input: {
productModel: 'MUSIC-001',
categoryName: '',
sceneName: '',
materialType: '音乐',
renderer: '',
authorName: '任务作者',
userId: 'user-1',
taskDetailRequesterUserId: 'requester-old',
},
})
expect(prisma.material.update).toHaveBeenCalledWith({
where: { id: 'material-1' },
data: expect.objectContaining({
taskDetailRequesterUserId: null,
taskDetailRequesterName: null,
}),
})
})
test('任务-其他编辑时不应强制要求店铺和场景,并应自动回写通用', async () => {
;(prisma.material.findFirst as jest.Mock)
.mockResolvedValueOnce({

View File

@ -156,6 +156,8 @@ describe('materials route ai generated', () => {
select: expect.objectContaining({
isAiGenerated: true,
taskDetailSourceType: true,
taskDetailRequesterUserId: true,
taskDetailRequesterName: true,
}),
}),
)

View File

@ -169,6 +169,18 @@ describe('materials upload route - 任务-其他', () => {
expect(prisma.material.create).not.toHaveBeenCalled()
})
test('普通上传入口必须拒绝任务-详情,避免绕过需求运营必填规则', async () => {
const formData = new FormData()
formData.append('materialType', '任务-详情')
const response = await POST(createRequest(formData))
const body = await response.json()
expect(response.status).toBe(400)
expect(body.error).toContain('必须使用专用上传入口')
expect(prisma.material.create).not.toHaveBeenCalled()
})
test('缺少预览图时应立即返回失败', async () => {
const formData = buildTaskOtherFormData()
formData.delete('previewImage')

View File

@ -3,6 +3,9 @@
import { getServerSession } from 'next-auth'
import { prisma } from '@/lib/prisma'
import { POST } from '@/app/api/materials/upload/product-package/complete/route'
import {
completeProductPackageUpload,
} from '@/lib/product-package-completion'
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
@ -13,10 +16,22 @@ jest.mock('@/lib/prisma', () => ({
},
},
}))
jest.mock('@/lib/product-package-completion', () => {
const actual = jest.requireActual('@/lib/product-package-completion')
return {
...actual,
completeProductPackageUpload: jest.fn(),
}
})
describe('product-package complete public route security', () => {
beforeEach(() => {
jest.clearAllMocks()
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'user-1' } })
;(completeProductPackageUpload as jest.Mock).mockResolvedValue({
materialId: 'material-1',
taskNo: null,
isFullSet: false,
})
})
test.each([
@ -35,4 +50,46 @@ describe('product-package complete public route security', () => {
expect(response.status).toBe(400)
expect(prisma.material.findUnique).not.toHaveBeenCalled()
})
test('非任务详情素材不能使用专用完成接口', async () => {
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
id: 'material-1',
userId: 'user-1',
materialType: '3D场景',
taskDetailRequesterUserId: 'requester-1',
})
const response = await POST({
json: jest.fn().mockResolvedValue({
materialId: 'material-1',
assets: { main: [] },
}),
} as any)
const body = await response.json()
expect(response.status).toBe(400)
expect(body.error).toContain('不是任务-详情')
expect(completeProductPackageUpload).not.toHaveBeenCalled()
})
test('缺少需求运营的新流程素材不能完成上传', async () => {
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
id: 'material-1',
userId: 'user-1',
materialType: '任务-详情',
taskDetailRequesterUserId: null,
})
const response = await POST({
json: jest.fn().mockResolvedValue({
materialId: 'material-1',
assets: { main: [] },
}),
} as any)
const body = await response.json()
expect(response.status).toBe(400)
expect(body.error).toContain('缺少需求运营人员')
expect(completeProductPackageUpload).not.toHaveBeenCalled()
})
})

View File

@ -20,6 +20,9 @@ jest.mock('@/lib/prisma', () => ({
material: {
create: jest.fn(),
},
user: {
findFirst: jest.fn(),
},
},
}))
@ -43,6 +46,11 @@ describe('product-package init route', () => {
;(prisma.material.create as jest.Mock).mockResolvedValue({
id: 'material-1',
})
;(prisma.user.findFirst as jest.Mock).mockResolvedValue({
id: 'requester-1',
username: 'operator',
realName: '运营甲',
})
})
test('未填写出图方式时也应允许初始化任务详情上传', async () => {
@ -52,6 +60,7 @@ describe('product-package init route', () => {
productModel: 'SPU-001',
categoryName: '旗舰店',
sceneName: '客厅',
taskDetailRequesterUserId: 'requester-1',
}),
headers: {
'Content-Type': 'application/json',
@ -100,6 +109,8 @@ describe('product-package init route', () => {
categoryName: '旗舰店',
taskNo: 'TASK202604230001',
taskDetailSourceType: '渲染',
taskDetailRequesterUserId: 'requester-1',
taskDetailRequesterName: '伪造姓名',
}),
headers: {
'Content-Type': 'application/json',
@ -115,7 +126,64 @@ describe('product-package init route', () => {
expect(prisma.material.create).toHaveBeenCalledWith({
data: expect.objectContaining({
taskDetailSourceType: '渲染',
taskDetailRequesterUserId: 'requester-1',
taskDetailRequesterName: '运营甲',
}),
})
})
test('未选择需求运营人员时应返回 400', async () => {
const request = new NextRequest('http://localhost/api/materials/upload/product-package/init', {
method: 'POST',
body: JSON.stringify({
productModel: 'SPU-001',
categoryName: '旗舰店',
}),
headers: {
'Content-Type': 'application/json',
},
})
const response = await POST(request)
const body = await response.json()
expect(response.status).toBe(400)
expect(body.error).toBe('请选择需求运营人员')
expect(prisma.user.findFirst).not.toHaveBeenCalled()
expect(prisma.material.create).not.toHaveBeenCalled()
})
test('需求运营人员不存在或已停用时应返回 400', async () => {
;(prisma.user.findFirst as jest.Mock).mockResolvedValue(null)
const request = new NextRequest('http://localhost/api/materials/upload/product-package/init', {
method: 'POST',
body: JSON.stringify({
productModel: 'SPU-001',
categoryName: '旗舰店',
taskDetailRequesterUserId: 'inactive-user',
}),
headers: {
'Content-Type': 'application/json',
},
})
const response = await POST(request)
const body = await response.json()
expect(response.status).toBe(400)
expect(body.error).toContain('不存在或已停用')
expect(prisma.user.findFirst).toHaveBeenCalledWith({
where: {
id: 'inactive-user',
isActive: true,
status: 'active',
},
select: {
id: true,
username: true,
realName: true,
},
})
expect(prisma.material.create).not.toHaveBeenCalled()
})
})

View File

@ -619,6 +619,8 @@ async function createImportedProductPackageMaterial(payload: z.infer<typeof crea
renderer: '',
isFullSet: false,
isAiGenerated: true,
taskDetailRequesterUserId: user.id,
taskDetailRequesterName: user.realName || user.username,
} as any,
})
}

View File

@ -37,6 +37,7 @@ export interface MaterialUpdateInput {
userId?: string | null
taskNo?: string | null
taskDetailSourceType?: string | null
taskDetailRequesterUserId?: string | null
// 兼容旧调用方传参,实际保存时会按资产自动计算
isFullSet?: boolean | string | null
}
@ -172,6 +173,7 @@ export async function updateMaterialWithSharedRules(options: UpdateMaterialOptio
const material = editable.material as any
const hasTaskNoField = Object.prototype.hasOwnProperty.call(input, 'taskNo')
const hasTaskDetailSourceTypeField = Object.prototype.hasOwnProperty.call(input, 'taskDetailSourceType')
const hasTaskDetailRequesterField = Object.prototype.hasOwnProperty.call(input, 'taskDetailRequesterUserId')
const productModel = normalizeString(input.productModel)
const reportTitle = normalizeReportTitle(input.reportTitle)
const materialType = normalizeString(input.materialType)
@ -184,6 +186,7 @@ export async function updateMaterialWithSharedRules(options: UpdateMaterialOptio
const taskNo = normalizeTaskNo(input.taskNo)
const rawTaskDetailSourceType = input.taskDetailSourceType
const taskDetailSourceType = normalizeTaskDetailSourceType(rawTaskDetailSourceType)
const requestedTaskDetailRequesterUserId = normalizeOptionalString(input.taskDetailRequesterUserId)
if (!materialType || !authorName) {
return { error: '缺少必填字段', status: 400 as const }
@ -257,6 +260,52 @@ export async function updateMaterialWithSharedRules(options: UpdateMaterialOptio
}
}
const currentTaskDetailRequesterUserId = normalizeOptionalString(material.taskDetailRequesterUserId)
const currentTaskDetailRequesterName = normalizeOptionalString(material.taskDetailRequesterName)
let nextTaskDetailRequesterUserId = currentTaskDetailRequesterUserId
let nextTaskDetailRequesterName = currentTaskDetailRequesterName
if (materialType !== '任务-详情') {
nextTaskDetailRequesterUserId = null
nextTaskDetailRequesterName = null
} else if (!hasTaskDetailRequesterField) {
if (material.materialType !== '任务-详情') {
return { error: '请选择需求运营人员', status: 400 as const }
}
} else if (!requestedTaskDetailRequesterUserId) {
if (material.materialType !== '任务-详情') {
return { error: '请选择需求运营人员', status: 400 as const }
}
if (currentTaskDetailRequesterUserId) {
return { error: '需求运营人员不能清空,只能更换', status: 400 as const }
}
// 历史任务详情没有运营字段时允许继续留空;若原人员已删除,则保留姓名快照。
nextTaskDetailRequesterUserId = null
nextTaskDetailRequesterName = currentTaskDetailRequesterName
} else if (requestedTaskDetailRequesterUserId !== currentTaskDetailRequesterUserId) {
const requester = await prisma.user.findFirst({
where: {
id: requestedTaskDetailRequesterUserId,
isActive: true,
status: 'active',
},
select: {
id: true,
username: true,
realName: true,
},
})
if (!requester) {
return { error: '所选需求运营人员不存在或已停用', status: 400 as const }
}
nextTaskDetailRequesterUserId = requester.id
nextTaskDetailRequesterName = requester.realName || requester.username
}
let previewUrl = material.previewUrl
try {
previewUrl = await resolvePreviewUrl(previewImage, materialType, material.previewUrl)
@ -285,6 +334,8 @@ export async function updateMaterialWithSharedRules(options: UpdateMaterialOptio
materialType === '任务-详情'
? (hasTaskDetailSourceTypeField ? taskDetailSourceType : material.taskDetailSourceType ?? null)
: null,
taskDetailRequesterUserId: nextTaskDetailRequesterUserId,
taskDetailRequesterName: nextTaskDetailRequesterName,
isFullSet: supportsIsFullSet(materialType)
? deriveTaskDetailIsFullSet(materialType, material.assets, Boolean(material.isFullSet))
: false,

View File

@ -0,0 +1,23 @@
ALTER TABLE "materials"
ADD COLUMN IF NOT EXISTS "task_detail_requester_user_id" TEXT,
ADD COLUMN IF NOT EXISTS "task_detail_requester_name" TEXT;
CREATE INDEX IF NOT EXISTS "materials_task_detail_requester_user_id_idx"
ON "materials" ("task_detail_requester_user_id");
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'materials_task_detail_requester_user_id_fkey'
) THEN
ALTER TABLE "materials"
ADD CONSTRAINT "materials_task_detail_requester_user_id_fkey"
FOREIGN KEY ("task_detail_requester_user_id")
REFERENCES "users"("id")
ON DELETE SET NULL
ON UPDATE CASCADE;
END IF;
END
$$;

View File

@ -49,7 +49,8 @@ model User {
materialTransfersFrom MaterialTransfer[] @relation("MaterialTransferFromUser")
materialTransfersTo MaterialTransfer[] @relation("MaterialTransferToUser")
materialTransfersAsOperator MaterialTransfer[] @relation("MaterialTransferOperator")
materials Material[]
materials Material[] @relation("MaterialOwner")
taskDetailRequestedMaterials Material[] @relation("TaskDetailRequester")
departmentRef Department? @relation("UserDepartment", fields: [departmentId], references: [id])
transferToDept Department? @relation("UserTransferDepartment", fields: [transferToDeptId], references: [id])
@ -102,6 +103,8 @@ model Material {
isFullSet Boolean @default(false) @map("is_full_set")
isAiGenerated Boolean @default(false) @map("is_ai_generated")
taskDetailSourceType String? @map("task_detail_source_type")
taskDetailRequesterUserId String? @map("task_detail_requester_user_id")
taskDetailRequesterName String? @map("task_detail_requester_name")
assets MaterialAsset[]
downloadLogs MaterialDownloadLog[]
favorites MaterialFavorite[]
@ -110,9 +113,11 @@ model Material {
skus MaterialSku[]
transfers MaterialTransfer[]
transcodeProgresses VideoTranscodeProgress[] @relation("MaterialTranscodeProgress")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
user User @relation("MaterialOwner", fields: [userId], references: [id], onDelete: Cascade)
taskDetailRequester User? @relation("TaskDetailRequester", fields: [taskDetailRequesterUserId], references: [id], onDelete: SetNull)
@@index([userId])
@@index([taskDetailRequesterUserId])
@@index([createdAt])
@@index([categoryName])
@@index([sceneName])