feat: 记录任务详情需求运营
This commit is contained in:
parent
770a39b127
commit
7925ca74de
@ -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),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
|
||||
@ -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,其他类型可选
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 ? '上传中...' : '提交'}
|
||||
|
||||
@ -366,6 +366,8 @@ describe('external material import', () => {
|
||||
materialType: '任务-详情',
|
||||
userId: 'user-1',
|
||||
isAiGenerated: true,
|
||||
taskDetailRequesterUserId: 'user-1',
|
||||
taskDetailRequesterName: '张三',
|
||||
}),
|
||||
})
|
||||
expect(completeProductPackageUpload).toHaveBeenCalledWith({
|
||||
|
||||
@ -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',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -156,6 +156,8 @@ describe('materials route ai generated', () => {
|
||||
select: expect.objectContaining({
|
||||
isAiGenerated: true,
|
||||
taskDetailSourceType: true,
|
||||
taskDetailRequesterUserId: true,
|
||||
taskDetailRequesterName: true,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
$$;
|
||||
@ -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])
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user