feat: 完善素材搜索与编辑体验
This commit is contained in:
parent
1299357f8e
commit
5202d1240c
@ -341,6 +341,9 @@ export async function GET(
|
||||
}
|
||||
|
||||
const canBypassVideoPreviewGate = isSystemAdmin(userRole) || userIsSuperAdmin;
|
||||
const canEdit =
|
||||
(material as any).userId === userId ||
|
||||
canBypassVideoPreviewGate;
|
||||
if (videoPreviewPolicy.videoPreviewBlocked && !canBypassVideoPreviewGate) {
|
||||
return buildVideoPreviewBlockedResponse();
|
||||
}
|
||||
@ -427,6 +430,7 @@ export async function GET(
|
||||
),
|
||||
assets: formattedAssets,
|
||||
canDownloadSource: canAccessSource,
|
||||
canEdit,
|
||||
user: {
|
||||
username: materialWithUser.user?.username || '',
|
||||
},
|
||||
@ -500,7 +504,9 @@ export async function PUT(
|
||||
const formData = await request.formData();
|
||||
input = {
|
||||
productModel: String(formData.get('productModel') || ''),
|
||||
reportTitle: formData.get('reportTitle') as string | null,
|
||||
...(formData.has('reportTitle')
|
||||
? { reportTitle: formData.get('reportTitle') as string | null }
|
||||
: {}),
|
||||
categoryName: formData.get('categoryName') as string | null,
|
||||
sceneName: formData.get('sceneName') as string | null,
|
||||
platformName: formData.get('platformName') as string | null,
|
||||
@ -508,14 +514,24 @@ export async function PUT(
|
||||
materialType: String(formData.get('materialType') || ''),
|
||||
renderer: formData.get('renderer') as string | null,
|
||||
authorName: String(formData.get('authorName') || ''),
|
||||
userId: formData.get('userId') as string | null,
|
||||
taskNo: formData.get('taskNo') as string | null,
|
||||
...(formData.has('userId')
|
||||
? { userId: formData.get('userId') as string | null }
|
||||
: {}),
|
||||
...(formData.has('taskNo')
|
||||
? { taskNo: formData.get('taskNo') as string | null }
|
||||
: {}),
|
||||
...(formData.has('dingtalkApprovalNo')
|
||||
? { dingtalkApprovalNo: formData.get('dingtalkApprovalNo') as string | null }
|
||||
: {}),
|
||||
taskDetailSourceType: formData.get('taskDetailSourceType') as string | null,
|
||||
taskDetailRequesterUserId: formData.get('taskDetailRequesterUserId') as string | null,
|
||||
isFullSet: formData.get('isFullSet') as string | null,
|
||||
...(formData.has('taskDetailSourceType')
|
||||
? { taskDetailSourceType: formData.get('taskDetailSourceType') as string | null }
|
||||
: {}),
|
||||
...(formData.has('taskDetailRequesterUserId')
|
||||
? { taskDetailRequesterUserId: formData.get('taskDetailRequesterUserId') as string | null }
|
||||
: {}),
|
||||
...(formData.has('isFullSet')
|
||||
? { isFullSet: formData.get('isFullSet') as string | null }
|
||||
: {}),
|
||||
};
|
||||
const formPreview = formData.get('previewImage');
|
||||
previewImage = isWebFileLike(formPreview) ? formPreview : null;
|
||||
|
||||
@ -22,7 +22,9 @@ export async function PUT(request: NextRequest) {
|
||||
id = String(formData.get('id') || '');
|
||||
input = {
|
||||
productModel: String(formData.get('productModel') || ''),
|
||||
reportTitle: formData.get('reportTitle') as string | null,
|
||||
...(formData.has('reportTitle')
|
||||
? { reportTitle: formData.get('reportTitle') as string | null }
|
||||
: {}),
|
||||
categoryName: formData.get('categoryName') as string | null,
|
||||
sceneName: formData.get('sceneName') as string | null,
|
||||
platformName: formData.get('platformName') as string | null,
|
||||
@ -30,14 +32,24 @@ export async function PUT(request: NextRequest) {
|
||||
materialType: String(formData.get('materialType') || ''),
|
||||
renderer: formData.get('renderer') as string | null,
|
||||
authorName: String(formData.get('authorName') || ''),
|
||||
userId: formData.get('userId') as string | null,
|
||||
taskNo: formData.get('taskNo') as string | null,
|
||||
...(formData.has('userId')
|
||||
? { userId: formData.get('userId') as string | null }
|
||||
: {}),
|
||||
...(formData.has('taskNo')
|
||||
? { taskNo: formData.get('taskNo') as string | null }
|
||||
: {}),
|
||||
...(formData.has('dingtalkApprovalNo')
|
||||
? { dingtalkApprovalNo: formData.get('dingtalkApprovalNo') as string | null }
|
||||
: {}),
|
||||
taskDetailSourceType: formData.get('taskDetailSourceType') as string | null,
|
||||
taskDetailRequesterUserId: formData.get('taskDetailRequesterUserId') as string | null,
|
||||
isFullSet: formData.get('isFullSet') as string | null,
|
||||
...(formData.has('taskDetailSourceType')
|
||||
? { taskDetailSourceType: formData.get('taskDetailSourceType') as string | null }
|
||||
: {}),
|
||||
...(formData.has('taskDetailRequesterUserId')
|
||||
? { taskDetailRequesterUserId: formData.get('taskDetailRequesterUserId') as string | null }
|
||||
: {}),
|
||||
...(formData.has('isFullSet')
|
||||
? { isFullSet: formData.get('isFullSet') as string | null }
|
||||
: {}),
|
||||
};
|
||||
const formPreview = formData.get('previewImage');
|
||||
previewImage = isWebFileLike(formPreview) ? formPreview : null;
|
||||
|
||||
@ -11,17 +11,17 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: '请先登录' }, { status: 401 });
|
||||
}
|
||||
|
||||
// 只获取激活的用户
|
||||
// 只返回人员选择器实际需要的最小字段,避免向普通登录用户暴露邮箱等信息。
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
status: 'active',
|
||||
archivedAt: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
realName: true,
|
||||
email: true,
|
||||
},
|
||||
orderBy: {
|
||||
username: 'asc',
|
||||
@ -33,7 +33,6 @@ export async function GET(request: NextRequest) {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.realName || user.username,
|
||||
email: user.email,
|
||||
}));
|
||||
|
||||
return NextResponse.json(userList, { status: 200 });
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -588,13 +588,12 @@ export default function Home() {
|
||||
}
|
||||
if (requestId === materialsRequestIdRef.current) {
|
||||
const data = await response.json()
|
||||
// 素材ID验证和previewUrl fallback
|
||||
// 清理素材 ID,并为预览图补齐统一 fallback
|
||||
const materialsList = ((data.materials || data) as Material[])
|
||||
.map((e) => {
|
||||
// 验证ID
|
||||
if (!e.id || typeof e.id !== 'string') return null
|
||||
const trimmedId = e.id.trim()
|
||||
if (!trimmedId || trimmedId.length < 10 || trimmedId.length > 100) return null
|
||||
if (!trimmedId) return null
|
||||
|
||||
// previewUrl fallback逻辑
|
||||
const previewUrl = e.previewUrl ||
|
||||
@ -1346,7 +1345,7 @@ export default function Home() {
|
||||
<SearchBar
|
||||
value={searchKeyword}
|
||||
onChange={handleSearchChange}
|
||||
placeholder="搜索SPU、店铺、场景类别、作者..."
|
||||
placeholder="搜索素材 ID、SPU、任务号、店铺、场景或作者"
|
||||
isSearching={isSearchPending}
|
||||
/>
|
||||
|
||||
|
||||
@ -1,514 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import SearchableUserSelect from '@/components/SearchableUserSelect'
|
||||
import {
|
||||
canManuallyReplacePreview,
|
||||
getSelectableMaterialTypeOptions,
|
||||
supportsIsFullSet,
|
||||
supportsTaskNo,
|
||||
} from '@/lib/material-type-options'
|
||||
import { isCertificateReportMaterialType } from '@/lib/certificate-report'
|
||||
import { TASK_DETAIL_SOURCE_TYPE_OPTIONS } from '@/lib/task-detail-source-type'
|
||||
import TaskReferenceFields from '@/components/upload/TaskReferenceFields'
|
||||
import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no'
|
||||
import {
|
||||
requiresCategoryAndScene,
|
||||
requiresProductCategoryName,
|
||||
requiresRenderer,
|
||||
} from '@/lib/material-validation'
|
||||
|
||||
interface EditMaterialModalProps {
|
||||
material: {
|
||||
id: string
|
||||
previewUrl: string
|
||||
productModel: string
|
||||
reportTitle?: string | null
|
||||
categoryName: string
|
||||
sceneName: string
|
||||
platformName?: string | null
|
||||
productCategoryName?: string | null
|
||||
materialType: string
|
||||
renderer: string
|
||||
authorName: string
|
||||
userId: string
|
||||
taskNo?: string | null
|
||||
dingtalkApprovalNo?: string | null
|
||||
taskDetailSourceType?: string | null
|
||||
taskStatus?: string | null
|
||||
taskFetchedAt?: string | null
|
||||
isFullSet?: boolean
|
||||
taskDetailRequesterUserId?: string | null
|
||||
taskDetailRequesterName?: string | null
|
||||
}
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
export default function EditMaterialModal({ material, onClose, onSuccess }: EditMaterialModalProps) {
|
||||
const [previewImage, setPreviewImage] = useState<File | null>(null)
|
||||
const [previewUrl, setPreviewUrl] = useState<string>(material.previewUrl || '')
|
||||
const [productModel, setProductModel] = useState(material.productModel)
|
||||
const [reportTitle, setReportTitle] = useState(material.reportTitle || '')
|
||||
const [categoryName, setCategoryName] = useState(material.categoryName)
|
||||
const [sceneName, setSceneName] = useState(material.sceneName)
|
||||
const [platformName, setPlatformName] = useState(material.platformName || '')
|
||||
const [productCategoryName, setProductCategoryName] = useState(material.productCategoryName || '')
|
||||
const [materialType, setMaterialType] = useState(material.materialType || '3D场景')
|
||||
const [renderer, setRenderer] = useState(material.renderer || 'CR')
|
||||
const [authorName, setAuthorName] = useState(material.authorName)
|
||||
const [userId, setUserId] = useState(material.userId)
|
||||
const [taskNo, setTaskNo] = useState(material.taskNo || '')
|
||||
const [dingtalkApprovalNo, setDingtalkApprovalNo] = useState(material.dingtalkApprovalNo || '')
|
||||
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))
|
||||
const [users, setUsers] = useState<Array<{ id: string; displayName: string; username: string }>>([])
|
||||
const [categories, setCategories] = useState<Array<{ id: string; name: string }>>([])
|
||||
const [scenes, setScenes] = useState<Array<{ id: string; name: string }>>([])
|
||||
const [platforms, setPlatforms] = useState<Array<{ id: string; name: string; count: number }>>([])
|
||||
const [productCategories, setProductCategories] = useState<Array<{ id: string; name: string; count: number }>>([])
|
||||
const [updating, setUpdating] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const showCategoryScene = requiresCategoryAndScene(materialType)
|
||||
const showRenderer = requiresRenderer(materialType)
|
||||
const showTaskNo = supportsTaskNo(materialType)
|
||||
const showIsFullSet = supportsIsFullSet(materialType)
|
||||
const allowPreviewReplacement = canManuallyReplacePreview(materialType)
|
||||
const isCertificateReport = isCertificateReportMaterialType(materialType)
|
||||
const isFetchedTask = taskStatus === 'fetched'
|
||||
const materialTypeOptions = getSelectableMaterialTypeOptions(materialType)
|
||||
const canKeepRequesterBlank =
|
||||
material.materialType === '任务-详情' && !material.taskDetailRequesterUserId
|
||||
|
||||
useEffect(() => {
|
||||
void fetchSelectOptions()
|
||||
}, [])
|
||||
|
||||
const fetchSelectOptions = async () => {
|
||||
try {
|
||||
const [categoriesResponse, scenesResponse, platformsResponse, productCategoriesResponse, usersResponse] = await Promise.all([
|
||||
fetch('/api/categories'),
|
||||
fetch('/api/scenes'),
|
||||
fetch('/api/platforms'),
|
||||
fetch('/api/product-categories'),
|
||||
fetch('/api/users/list'),
|
||||
])
|
||||
|
||||
if (categoriesResponse.ok) {
|
||||
setCategories(await categoriesResponse.json())
|
||||
}
|
||||
if (scenesResponse.ok) {
|
||||
setScenes(await scenesResponse.json())
|
||||
}
|
||||
if (platformsResponse.ok) {
|
||||
setPlatforms(await platformsResponse.json())
|
||||
}
|
||||
if (productCategoriesResponse.ok) {
|
||||
setProductCategories(await productCategoriesResponse.json())
|
||||
}
|
||||
if (usersResponse.ok) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePreviewChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
setError('请选择图片文件')
|
||||
return
|
||||
}
|
||||
|
||||
setPreviewImage(file)
|
||||
setPreviewUrl(URL.createObjectURL(file))
|
||||
setError('')
|
||||
}
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (!materialType || !authorName) {
|
||||
setError('请填写所有必填字段')
|
||||
return
|
||||
}
|
||||
|
||||
if (isCertificateReport) {
|
||||
if (!productModel.trim() && !reportTitle.trim()) {
|
||||
setError('SPU 和 报告名称/检测对象 至少填写一个')
|
||||
return
|
||||
}
|
||||
} else if (!productModel) {
|
||||
setError('请填写所有必填字段')
|
||||
return
|
||||
}
|
||||
|
||||
if (showCategoryScene && (!categoryName || !sceneName)) {
|
||||
setError('请填写店铺和场景')
|
||||
return
|
||||
}
|
||||
|
||||
if (requiresProductCategoryName(materialType) && !productCategoryName) {
|
||||
setError('标准模型类型必须选择品类')
|
||||
return
|
||||
}
|
||||
|
||||
if (showRenderer && !renderer) {
|
||||
setError('3D场景类型必须指定渲染器')
|
||||
return
|
||||
}
|
||||
|
||||
if (materialType === '任务-详情' && !taskDetailRequesterUserId && !canKeepRequesterBlank) {
|
||||
setError('请选择需求运营人员')
|
||||
return
|
||||
}
|
||||
|
||||
if (showTaskNo) {
|
||||
const dingtalkApprovalNoError = getDingtalkApprovalNoError(dingtalkApprovalNo)
|
||||
if (dingtalkApprovalNoError) {
|
||||
setError(dingtalkApprovalNoError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (isFetchedTask) {
|
||||
const confirmed = window.confirm('这条任务素材已经被外部系统获取过,继续保存可能影响对方使用,确认继续吗?')
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setUpdating(true)
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('id', material.id)
|
||||
formData.append('productModel', productModel)
|
||||
formData.append('reportTitle', reportTitle)
|
||||
formData.append('categoryName', categoryName)
|
||||
formData.append('sceneName', sceneName)
|
||||
formData.append('platformName', platformName)
|
||||
formData.append('productCategoryName', productCategoryName)
|
||||
formData.append('materialType', materialType)
|
||||
formData.append('renderer', renderer)
|
||||
formData.append('authorName', authorName)
|
||||
formData.append('userId', userId)
|
||||
formData.append('taskNo', showTaskNo ? taskNo : '')
|
||||
formData.append('dingtalkApprovalNo', showTaskNo ? dingtalkApprovalNo : '')
|
||||
formData.append('taskDetailSourceType', materialType === '任务-详情' ? taskDetailSourceType : '')
|
||||
formData.append(
|
||||
'taskDetailRequesterUserId',
|
||||
materialType === '任务-详情' ? taskDetailRequesterUserId : '',
|
||||
)
|
||||
|
||||
if (previewImage && allowPreviewReplacement) {
|
||||
formData.append('previewImage', previewImage)
|
||||
}
|
||||
|
||||
const response = await fetch('/api/materials/update', {
|
||||
method: 'PUT',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
setError(data.error || '更新失败')
|
||||
return
|
||||
}
|
||||
|
||||
onSuccess()
|
||||
} catch (submitError) {
|
||||
console.error('更新失败:', submitError)
|
||||
setError('更新失败,请稍后再试')
|
||||
} finally {
|
||||
setUpdating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4">
|
||||
<div className="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-apple bg-white shadow-apple-lg dark:bg-gray-800">
|
||||
<div className="border-b border-gray-200 p-6 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">编辑素材</h2>
|
||||
<button onClick={onClose} className="text-2xl leading-none text-gray-400 hover:text-gray-600">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6 p-6">
|
||||
{isFetchedTask && (
|
||||
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-4 text-sm text-yellow-800">
|
||||
这条任务素材已被外部系统获取。
|
||||
{taskFetchedAt ? ` 获取时间:${new Date(taskFetchedAt).toLocaleString('zh-CN')}` : ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!allowPreviewReplacement && (
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 text-sm text-blue-700">
|
||||
当前素材类型的预览以系统生成结果为准,这里不支持手工替换封面。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-apple border border-red-200 bg-red-50 px-4 py-3 text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{allowPreviewReplacement && (
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">预览图</label>
|
||||
<div className="space-y-3">
|
||||
{previewUrl ? (
|
||||
<div className="overflow-hidden rounded-apple border border-gray-200 bg-gray-50 p-3">
|
||||
<img src={previewUrl} alt="预览图" className="mx-auto max-h-72 rounded object-contain" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-apple border border-dashed border-gray-300 p-6 text-center text-sm text-gray-500">
|
||||
当前没有预览图
|
||||
</div>
|
||||
)}
|
||||
<input type="file" accept="image/*" onChange={handlePreviewChange} className="block w-full text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">
|
||||
{isCertificateReport ? 'SPU' : '产品型号 *'}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={productModel}
|
||||
onChange={(event) => setProductModel(event.target.value)}
|
||||
className="input-apple"
|
||||
required={!isCertificateReport}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isCertificateReport && (
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">报告名称 / 检测对象</label>
|
||||
<input
|
||||
type="text"
|
||||
value={reportTitle}
|
||||
onChange={(event) => setReportTitle(event.target.value)}
|
||||
className="input-apple"
|
||||
placeholder="SPU 和这个字段至少填写一个"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">素材类型 *</label>
|
||||
<select value={materialType} onChange={(event) => setMaterialType(event.target.value)} className="input-apple" required>
|
||||
{materialTypeOptions.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{showCategoryScene && (
|
||||
<>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">店铺 *</label>
|
||||
<select value={categoryName} onChange={(event) => setCategoryName(event.target.value)} className="input-apple" required>
|
||||
<option value="">请选择店铺</option>
|
||||
{categories.map((category) => (
|
||||
<option key={category.id} value={category.name}>
|
||||
{category.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">场景 *</label>
|
||||
<select value={sceneName} onChange={(event) => setSceneName(event.target.value)} className="input-apple" required>
|
||||
<option value="">请选择场景</option>
|
||||
{scenes.map((scene) => (
|
||||
<option key={scene.id} value={scene.name}>
|
||||
{scene.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">平台分类</label>
|
||||
<select value={platformName} onChange={(event) => setPlatformName(event.target.value)} className="input-apple">
|
||||
<option value="">不选择</option>
|
||||
{platforms.map((platform) => (
|
||||
<option key={platform.id} value={platform.name}>
|
||||
{platform.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">
|
||||
品类{requiresProductCategoryName(materialType) ? ' *' : ''}
|
||||
</label>
|
||||
<select value={productCategoryName} onChange={(event) => setProductCategoryName(event.target.value)} className="input-apple">
|
||||
<option value="">不选择</option>
|
||||
{productCategories.map((category) => (
|
||||
<option key={category.id} value={category.name}>
|
||||
{category.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{showRenderer && (
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">渲染器 *</label>
|
||||
<select value={renderer} onChange={(event) => setRenderer(event.target.value)} className="input-apple" required>
|
||||
<option value="">请选择渲染器</option>
|
||||
<option value="CR">CR</option>
|
||||
<option value="VR">VR</option>
|
||||
<option value="其它">其它</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showTaskNo && (
|
||||
<TaskReferenceFields
|
||||
idPrefix={`edit-material-${material.id}`}
|
||||
taskNo={taskNo}
|
||||
onTaskNoChange={setTaskNo}
|
||||
dingtalkApprovalNo={dingtalkApprovalNo}
|
||||
onDingtalkApprovalNoChange={setDingtalkApprovalNo}
|
||||
disabled={updating}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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">
|
||||
需求运营{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 && (
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">整套判定</label>
|
||||
<div className="rounded-apple border border-gray-300 px-4 py-3 text-sm text-gray-700">
|
||||
<p>
|
||||
当前结果:<span className="font-medium">{isFullSet ? '是' : '否'}</span>
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
系统会根据是否同时存在主图、SKU 图、详情图自动判定,这里不允许手工修改。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">作者(素材所有者) *</label>
|
||||
<SearchableUserSelect
|
||||
users={users}
|
||||
value={userId}
|
||||
onChange={(nextUserId, displayName) => {
|
||||
setUserId(nextUserId)
|
||||
setAuthorName(displayName)
|
||||
}}
|
||||
placeholder="请输入或搜索用户(支持拼音)"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(showTaskNo || showIsFullSet) && (
|
||||
<div className="grid gap-4 rounded-apple border border-gray-200 bg-gray-50 p-4">
|
||||
<div>
|
||||
<p className="mb-1 text-sm text-gray-500">任务状态</p>
|
||||
<p className="text-sm font-medium text-gray-900">{taskStatus || '未记录'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 text-sm text-gray-500">外部获取时间</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{taskFetchedAt ? new Date(taskFetchedAt).toLocaleString('zh-CN') : '未记录'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<button type="button" onClick={onClose} className="btn-secondary" disabled={updating}>
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" className="btn-primary" disabled={updating}>
|
||||
{updating ? '更新中...' : '保存修改'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||
import { pinyin } from 'pinyin-pro'
|
||||
|
||||
interface User {
|
||||
@ -10,6 +10,7 @@ interface User {
|
||||
}
|
||||
|
||||
interface SearchableUserSelectProps {
|
||||
id?: string
|
||||
users: User[]
|
||||
value: string
|
||||
onChange: (userId: string, displayName: string) => void
|
||||
@ -18,9 +19,11 @@ interface SearchableUserSelectProps {
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
clearable?: boolean
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export default function SearchableUserSelect({
|
||||
id,
|
||||
users,
|
||||
value,
|
||||
onChange,
|
||||
@ -29,50 +32,55 @@ export default function SearchableUserSelect({
|
||||
className = '',
|
||||
disabled = false,
|
||||
clearable = true,
|
||||
loading = false,
|
||||
}: SearchableUserSelectProps) {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(0)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const listboxId = useId()
|
||||
const isDisabled = disabled || loading
|
||||
|
||||
// 获取选中用户的显示名称
|
||||
const selectedUser = users.find(u => u.id === value)
|
||||
const displayValue = selectedUser ? `${selectedUser.displayName} (${selectedUser.username})` : ''
|
||||
const selectedUser = useMemo(() => users.find((user) => user.id === value), [users, value])
|
||||
const displayValue = selectedUser
|
||||
? selectedUser.displayName === selectedUser.username
|
||||
? selectedUser.displayName
|
||||
: `${selectedUser.displayName} (${selectedUser.username})`
|
||||
: ''
|
||||
|
||||
// 拼音搜索匹配函数
|
||||
const matchesSearch = (user: User, search: string): boolean => {
|
||||
if (!search) return true
|
||||
// 人员数量较多时,预先生成一次搜索索引,避免每次按键都重复计算全量拼音。
|
||||
const indexedUsers = useMemo(() => users.map((user) => {
|
||||
const fullPinyin = pinyin(user.displayName, { toneType: 'none', type: 'array' }).join('')
|
||||
const firstLetters = pinyin(user.displayName, {
|
||||
pattern: 'first',
|
||||
toneType: 'none',
|
||||
type: 'array',
|
||||
}).join('')
|
||||
|
||||
const searchLower = search.toLowerCase()
|
||||
|
||||
// 1. 匹配显示名称
|
||||
if (user.displayName.toLowerCase().includes(searchLower)) {
|
||||
return true
|
||||
return {
|
||||
user,
|
||||
searchText: [
|
||||
user.displayName,
|
||||
user.username,
|
||||
fullPinyin,
|
||||
firstLetters,
|
||||
].join(' ').toLowerCase(),
|
||||
}
|
||||
}), [users])
|
||||
|
||||
// 2. 匹配用户名
|
||||
if (user.username.toLowerCase().includes(searchLower)) {
|
||||
return true
|
||||
}
|
||||
const filteredUsers = useMemo(() => {
|
||||
const keyword = searchTerm.trim().toLowerCase()
|
||||
if (!keyword) return indexedUsers.map((item) => item.user)
|
||||
return indexedUsers
|
||||
.filter((item) => item.searchText.includes(keyword))
|
||||
.map((item) => item.user)
|
||||
}, [indexedUsers, searchTerm])
|
||||
|
||||
// 3. 匹配拼音全拼
|
||||
const fullPinyin = pinyin(user.displayName, { toneType: 'none', type: 'array' }).join('').toLowerCase()
|
||||
if (fullPinyin.includes(searchLower)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 4. 匹配拼音首字母
|
||||
const firstLetters = pinyin(user.displayName, { pattern: 'first', toneType: 'none', type: 'array' }).join('').toLowerCase()
|
||||
if (firstLetters.includes(searchLower)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// 过滤用户列表
|
||||
const filteredUsers = users.filter(user => matchesSearch(user, searchTerm))
|
||||
useEffect(() => {
|
||||
setHighlightedIndex((current) => Math.min(current, Math.max(filteredUsers.length - 1, 0)))
|
||||
}, [filteredUsers.length])
|
||||
|
||||
// 点击外部关闭下拉框
|
||||
useEffect(() => {
|
||||
@ -88,7 +96,7 @@ export default function SearchableUserSelect({
|
||||
|
||||
// 键盘导航
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (disabled) {
|
||||
if (isDisabled) {
|
||||
return
|
||||
}
|
||||
|
||||
@ -132,7 +140,7 @@ export default function SearchableUserSelect({
|
||||
|
||||
// 打开下拉框
|
||||
const handleFocus = () => {
|
||||
if (disabled) {
|
||||
if (isDisabled) {
|
||||
return
|
||||
}
|
||||
|
||||
@ -143,7 +151,7 @@ export default function SearchableUserSelect({
|
||||
// 清空选择
|
||||
const handleClear = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (disabled || !clearable) {
|
||||
if (isDisabled || !clearable) {
|
||||
return
|
||||
}
|
||||
|
||||
@ -157,26 +165,38 @@ export default function SearchableUserSelect({
|
||||
<div ref={containerRef} className={`relative ${className}`}>
|
||||
<div className="relative">
|
||||
<input
|
||||
id={id}
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={isOpen ? searchTerm : displayValue}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
placeholder={loading ? '正在加载人员...' : placeholder}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
disabled={isDisabled}
|
||||
className="input-apple pr-20 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
autoComplete="off"
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={isOpen && !isDisabled}
|
||||
aria-controls={listboxId}
|
||||
aria-activedescendant={
|
||||
isOpen && filteredUsers[highlightedIndex]
|
||||
? `${listboxId}-option-${filteredUsers[highlightedIndex].id}`
|
||||
: undefined
|
||||
}
|
||||
aria-busy={loading}
|
||||
/>
|
||||
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
||||
{value && clearable && !disabled && (
|
||||
{value && clearable && !isDisabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors"
|
||||
className="flex h-9 w-9 items-center justify-center rounded transition-colors hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:hover:bg-gray-600"
|
||||
title="清空"
|
||||
aria-label="清空已选人员"
|
||||
>
|
||||
<svg className="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
@ -187,12 +207,13 @@ export default function SearchableUserSelect({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
if (!isDisabled) {
|
||||
setIsOpen(!isOpen)
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors disabled:cursor-not-allowed"
|
||||
disabled={isDisabled}
|
||||
className="flex h-9 w-9 items-center justify-center rounded transition-colors hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-200 disabled:cursor-not-allowed dark:hover:bg-gray-600"
|
||||
aria-label={isOpen ? '收起人员列表' : '展开人员列表'}
|
||||
>
|
||||
<svg
|
||||
className={`w-4 h-4 text-gray-500 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||
@ -206,8 +227,12 @@ export default function SearchableUserSelect({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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">
|
||||
{isOpen && !isDisabled && (
|
||||
<div
|
||||
id={listboxId}
|
||||
role="listbox"
|
||||
className="absolute z-50 mt-1 max-h-60 w-full overflow-y-auto rounded-lg border border-gray-300 bg-white shadow-lg dark:border-gray-600 dark:bg-gray-800"
|
||||
>
|
||||
{filteredUsers.length === 0 ? (
|
||||
<div className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400 text-center">
|
||||
未找到匹配的用户
|
||||
@ -216,6 +241,9 @@ export default function SearchableUserSelect({
|
||||
filteredUsers.map((user, index) => (
|
||||
<button
|
||||
key={user.id}
|
||||
id={`${listboxId}-option-${user.id}`}
|
||||
role="option"
|
||||
aria-selected={user.id === value}
|
||||
type="button"
|
||||
onClick={() => handleSelect(user)}
|
||||
className={`w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ${
|
||||
|
||||
@ -27,7 +27,7 @@ interface Suggestion {
|
||||
function SearchBarInner({
|
||||
value: externalValue,
|
||||
onChange,
|
||||
placeholder = '搜索素材:SPU型号、任务号、SKU、店铺、场景、作者...',
|
||||
placeholder = '搜索素材 ID、SPU、任务号、店铺、场景或作者',
|
||||
isSearching = false,
|
||||
}: SearchBarProps) {
|
||||
// 本地状态用于显示(包括 IME 组合中的拼音)
|
||||
|
||||
@ -155,6 +155,7 @@ describe('external maixiang-spu-assets route', () => {
|
||||
deletedAt: null,
|
||||
materialType: expect.any(Object),
|
||||
OR: expect.arrayContaining([
|
||||
{ id: 'GBSFY-AD0297+GBSFY-CZ0102' },
|
||||
{ productModel: { contains: 'GBSFY-AD0297+GBSFY-CZ0102', mode: 'insensitive' } },
|
||||
{ reportTitle: { contains: 'GBSFY-AD0297+GBSFY-CZ0102', mode: 'insensitive' } },
|
||||
{ taskNo: { contains: 'GBSFY-AD0297+GBSFY-CZ0102', mode: 'insensitive' } },
|
||||
|
||||
179
lib/__tests__/material-edit-page.test.tsx
Normal file
179
lib/__tests__/material-edit-page.test.tsx
Normal file
@ -0,0 +1,179 @@
|
||||
/** @jest-environment jsdom */
|
||||
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import EditMaterialPage from '@/app/edit/[id]/page'
|
||||
|
||||
const mockPush = jest.fn()
|
||||
const mockBack = jest.fn()
|
||||
const mockRouter = {
|
||||
push: mockPush,
|
||||
back: mockBack,
|
||||
}
|
||||
|
||||
jest.mock('next-auth/react', () => ({
|
||||
useSession: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('next/navigation', () => ({
|
||||
useRouter: () => mockRouter,
|
||||
}))
|
||||
|
||||
jest.mock('@/components/Header', () => ({
|
||||
__esModule: true,
|
||||
default: () => <a href="/">返回素材库</a>,
|
||||
}))
|
||||
|
||||
jest.mock('@/components/upload/TaskReferenceFields', () => ({
|
||||
__esModule: true,
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
const material = {
|
||||
id: 'material-1',
|
||||
userId: 'user-1',
|
||||
productModel: 'SPU-001',
|
||||
reportTitle: null,
|
||||
categoryName: '店铺A',
|
||||
sceneName: '场景A',
|
||||
platformName: 'Amazon',
|
||||
productCategoryName: '',
|
||||
materialType: '3D场景',
|
||||
renderer: 'CR',
|
||||
authorName: '测试作者',
|
||||
previewUrl: null,
|
||||
taskNo: null,
|
||||
dingtalkApprovalNo: null,
|
||||
taskStatus: null,
|
||||
taskFetchedAt: null,
|
||||
isFullSet: false,
|
||||
taskDetailSourceType: null,
|
||||
taskDetailRequesterUserId: null,
|
||||
taskDetailRequesterName: null,
|
||||
canEdit: true,
|
||||
}
|
||||
|
||||
function jsonResponse(payload: unknown, status = 200) {
|
||||
return Promise.resolve({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => payload,
|
||||
} as Response)
|
||||
}
|
||||
|
||||
function referencePayload(url: string) {
|
||||
if (url.endsWith('/api/categories')) {
|
||||
return [{ id: 'category-1', name: '店铺A' }]
|
||||
}
|
||||
if (url.endsWith('/api/scenes')) {
|
||||
return [{ id: 'scene-1', name: '场景A' }]
|
||||
}
|
||||
if (url.endsWith('/api/platforms')) {
|
||||
return [{ id: 'platform-1', name: 'Amazon', count: 1 }]
|
||||
}
|
||||
if (url.endsWith('/api/product-categories')) {
|
||||
return []
|
||||
}
|
||||
return [{ id: 'user-1', username: 'author', displayName: '测试作者' }]
|
||||
}
|
||||
|
||||
describe('EditMaterialPage', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
;(useSession as jest.Mock).mockReturnValue({
|
||||
data: { user: { id: 'user-1', role: 'USER' } },
|
||||
status: 'authenticated',
|
||||
})
|
||||
Element.prototype.scrollIntoView = jest.fn()
|
||||
})
|
||||
|
||||
test('素材本体先于可选项显示,选项完成前禁止保存', async () => {
|
||||
let releaseReferences: (() => void) | undefined
|
||||
const referenceGate = new Promise<void>((resolve) => {
|
||||
releaseReferences = resolve
|
||||
})
|
||||
global.fetch = jest.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input)
|
||||
if (url.includes('/api/materials/material-1')) {
|
||||
return jsonResponse(material)
|
||||
}
|
||||
return referenceGate.then(() => jsonResponse(referencePayload(url)))
|
||||
}) as jest.Mock
|
||||
|
||||
render(<EditMaterialPage params={{ id: 'material-1' }} />)
|
||||
|
||||
expect(await screen.findByLabelText('产品型号 *')).toHaveValue('SPU-001')
|
||||
expect(screen.getByText('正在加载店铺、场景和人员选项…')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '保存修改' })).toBeDisabled()
|
||||
|
||||
await act(async () => {
|
||||
releaseReferences?.()
|
||||
await referenceGate
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('正在加载店铺、场景和人员选项…')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByRole('button', { name: '保存修改' })).toBeEnabled()
|
||||
})
|
||||
|
||||
test('部分选项失败时保留当前值、显示重试并禁止保存', async () => {
|
||||
global.fetch = jest.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input)
|
||||
if (url.includes('/api/materials/material-1')) {
|
||||
return jsonResponse(material)
|
||||
}
|
||||
if (url.endsWith('/api/categories')) {
|
||||
return jsonResponse({ error: '店铺读取失败' }, 500)
|
||||
}
|
||||
return jsonResponse(referencePayload(url))
|
||||
}) as jest.Mock
|
||||
|
||||
render(<EditMaterialPage params={{ id: 'material-1' }} />)
|
||||
|
||||
const categorySelect = await screen.findByLabelText('店铺 *')
|
||||
await screen.findByText('店铺加载失败,当前值已保留。')
|
||||
|
||||
expect(categorySelect).toHaveValue('店铺A')
|
||||
expect(categorySelect).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: '重新加载' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '保存修改' })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('修改后取消会使用统一弹窗,刷新保护与移动端粘性操作区同时生效', async () => {
|
||||
const user = userEvent.setup()
|
||||
global.fetch = jest.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input)
|
||||
if (url.includes('/api/materials/material-1')) {
|
||||
return jsonResponse(material)
|
||||
}
|
||||
return jsonResponse(referencePayload(url))
|
||||
}) as jest.Mock
|
||||
|
||||
render(<EditMaterialPage params={{ id: 'material-1' }} />)
|
||||
|
||||
const productModelInput = await screen.findByLabelText('产品型号 *')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: '保存修改' })).toBeEnabled()
|
||||
})
|
||||
await user.clear(productModelInput)
|
||||
await user.type(productModelInput, 'SPU-002')
|
||||
|
||||
const beforeUnloadEvent = new Event('beforeunload', { cancelable: true })
|
||||
window.dispatchEvent(beforeUnloadEvent)
|
||||
expect(beforeUnloadEvent.defaultPrevented).toBe(true)
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: '保存修改' })
|
||||
expect(saveButton.closest('.sticky')).not.toBeNull()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '取消' }))
|
||||
expect(await screen.findByText('放弃未保存的修改?')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: '继续编辑' }))
|
||||
expect(mockBack).not.toHaveBeenCalled()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '取消' }))
|
||||
await user.click(await screen.findByRole('button', { name: '放弃修改' }))
|
||||
expect(mockBack).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -166,6 +166,52 @@ describe('material edit routes', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test.each([
|
||||
{
|
||||
name: '/api/materials/[id]',
|
||||
put: async (formData: FormData) =>
|
||||
putMaterialById(
|
||||
new NextRequest('http://localhost/api/materials/material-1', {
|
||||
method: 'PUT',
|
||||
body: formData,
|
||||
}),
|
||||
{ params: { id: 'material-1' } },
|
||||
),
|
||||
},
|
||||
{
|
||||
name: '/api/materials/update',
|
||||
put: async (formData: FormData) => {
|
||||
formData.append('id', 'material-1')
|
||||
return putMaterialUpdate(
|
||||
new NextRequest('http://localhost/api/materials/update', {
|
||||
method: 'PUT',
|
||||
body: formData,
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
])('$name 不应把未提交的可选字段合成为空值', async ({ put }) => {
|
||||
const formData = new FormData()
|
||||
formData.append('productModel', 'SPU-001')
|
||||
formData.append('categoryName', '店铺A')
|
||||
formData.append('sceneName', '场景A')
|
||||
formData.append('materialType', '3D场景')
|
||||
formData.append('renderer', 'CR')
|
||||
formData.append('authorName', '测试作者')
|
||||
|
||||
const response = await put(formData)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const call = (updateMaterialWithSharedRules as jest.Mock).mock.calls[0][0]
|
||||
expect(call.input).not.toHaveProperty('reportTitle')
|
||||
expect(call.input).not.toHaveProperty('userId')
|
||||
expect(call.input).not.toHaveProperty('taskNo')
|
||||
expect(call.input).not.toHaveProperty('dingtalkApprovalNo')
|
||||
expect(call.input).not.toHaveProperty('taskDetailSourceType')
|
||||
expect(call.input).not.toHaveProperty('taskDetailRequesterUserId')
|
||||
expect(call.input).not.toHaveProperty('isFullSet')
|
||||
})
|
||||
|
||||
test.each(['share=1', 'shareId=anything'])(
|
||||
'GET /api/materials/[id] 不能通过 %s 绕过登录校验',
|
||||
async (shareParam) => {
|
||||
@ -250,6 +296,7 @@ describe('material edit routes', () => {
|
||||
expect(body.taskDetailRequesterUserId).toBe('requester-1')
|
||||
expect(body.taskDetailRequesterName).toBe('运营甲')
|
||||
expect(body.sourceFileUrl).toBe('/api/materials/download?id=material-1')
|
||||
expect(body.canEdit).toBe(true)
|
||||
})
|
||||
|
||||
test('PUT 路由应透传任务详情出图方式字段', async () => {
|
||||
|
||||
42
lib/__tests__/material-query.test.ts
Normal file
42
lib/__tests__/material-query.test.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { buildMaterialSearchOrConditions } from '@/lib/material-query'
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
materialSku: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
user: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/department-permission', () => ({
|
||||
getUserViewableMaterialTypes: jest.fn(),
|
||||
isSuperAdmin: jest.fn(),
|
||||
isSystemAdmin: jest.fn(),
|
||||
}))
|
||||
|
||||
describe('material search conditions', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
;(prisma.materialSku.findMany as jest.Mock).mockResolvedValue([])
|
||||
;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
|
||||
})
|
||||
|
||||
test('完整素材 UUID 应使用精确匹配', async () => {
|
||||
const materialId = '954554fb-50b4-45c4-9234-9e94b6948953'
|
||||
|
||||
const conditions = await buildMaterialSearchOrConditions(materialId)
|
||||
|
||||
expect(conditions).toEqual(expect.arrayContaining([{ id: materialId }]))
|
||||
})
|
||||
|
||||
test('非 UUID 的历史素材 ID 也应使用精确匹配', async () => {
|
||||
const materialId = 'legacy-material-1'
|
||||
const conditions = await buildMaterialSearchOrConditions(materialId)
|
||||
|
||||
expect(conditions).toEqual(expect.arrayContaining([{ id: materialId }]))
|
||||
})
|
||||
})
|
||||
@ -237,6 +237,48 @@ describe('material-update shared rules', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('旧编辑调用未提交报告名称时应保留证书原值', async () => {
|
||||
const existingCertificate = {
|
||||
...baseMaterial,
|
||||
materialType: '证书-检测报告',
|
||||
categoryName: '通用',
|
||||
sceneName: '通用',
|
||||
productCategoryName: '板材',
|
||||
productModel: '',
|
||||
reportTitle: '原检测报告',
|
||||
renderer: '',
|
||||
}
|
||||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(existingCertificate)
|
||||
;(prisma.material.update as jest.Mock).mockImplementation(async ({ data }) => ({
|
||||
...existingCertificate,
|
||||
...data,
|
||||
}))
|
||||
|
||||
const result = await updateMaterialWithSharedRules({
|
||||
materialId: 'material-1',
|
||||
operatorUserId: 'user-1',
|
||||
operatorRole: 'USER',
|
||||
input: {
|
||||
productModel: '',
|
||||
categoryName: '',
|
||||
sceneName: '',
|
||||
platformName: '',
|
||||
productCategoryName: '板材',
|
||||
materialType: '证书-检测报告',
|
||||
renderer: '',
|
||||
authorName: '测试作者',
|
||||
},
|
||||
})
|
||||
|
||||
expect('error' in result).toBe(false)
|
||||
expect(prisma.material.update).toHaveBeenCalledWith({
|
||||
where: { id: 'material-1' },
|
||||
data: expect.objectContaining({
|
||||
reportTitle: '原检测报告',
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
test('证书-检测报告同时缺少 SPU 和报告名称时应返回 400', async () => {
|
||||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
|
||||
...baseMaterial,
|
||||
@ -461,6 +503,44 @@ describe('material-update shared rules', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('旧编辑调用未提交出图方式时应保留任务详情原值', async () => {
|
||||
const existingTaskDetail = {
|
||||
...baseMaterial,
|
||||
materialType: '任务-详情',
|
||||
taskDetailSourceType: '实拍',
|
||||
categoryName: '店铺A',
|
||||
sceneName: '场景A',
|
||||
}
|
||||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(existingTaskDetail)
|
||||
;(prisma.material.update as jest.Mock).mockImplementation(async ({ data }) => ({
|
||||
...existingTaskDetail,
|
||||
...data,
|
||||
}))
|
||||
|
||||
await updateMaterialWithSharedRules({
|
||||
materialId: 'material-1',
|
||||
operatorUserId: 'user-1',
|
||||
operatorRole: 'USER',
|
||||
input: {
|
||||
productModel: 'TASK-SPU-001',
|
||||
categoryName: '店铺A',
|
||||
sceneName: '场景A',
|
||||
platformName: '',
|
||||
productCategoryName: '',
|
||||
materialType: '任务-详情',
|
||||
renderer: '',
|
||||
authorName: '任务作者',
|
||||
},
|
||||
})
|
||||
|
||||
expect(prisma.material.update).toHaveBeenCalledWith({
|
||||
where: { id: 'material-1' },
|
||||
data: expect.objectContaining({
|
||||
taskDetailSourceType: '实拍',
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
test('历史任务详情没有需求运营时,编辑其他字段仍可继续留空', async () => {
|
||||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
|
||||
...baseMaterial,
|
||||
|
||||
54
lib/__tests__/searchable-user-select.test.tsx
Normal file
54
lib/__tests__/searchable-user-select.test.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
/** @jest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import SearchableUserSelect from '@/components/SearchableUserSelect'
|
||||
|
||||
describe('SearchableUserSelect', () => {
|
||||
const users = [
|
||||
{ id: 'user-1', username: 'moli', displayName: '魔礼红' },
|
||||
{ id: 'user-2', username: 'wuxie', displayName: '无邪' },
|
||||
]
|
||||
|
||||
test('支持拼音搜索、键盘选择与标准 combobox 语义', async () => {
|
||||
const onChange = jest.fn()
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<SearchableUserSelect
|
||||
users={users}
|
||||
value=""
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const combobox = screen.getByRole('combobox')
|
||||
expect(combobox).toHaveAttribute('aria-expanded', 'false')
|
||||
|
||||
await user.click(combobox)
|
||||
await user.type(combobox, 'molihong')
|
||||
|
||||
expect(combobox).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getByRole('option', { name: /魔礼红/ })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('option', { name: /无邪/ })).not.toBeInTheDocument()
|
||||
|
||||
await user.keyboard('{Enter}')
|
||||
expect(onChange).toHaveBeenCalledWith('user-1', '魔礼红')
|
||||
})
|
||||
|
||||
test('加载期间保留已选人员并禁用交互', () => {
|
||||
render(
|
||||
<SearchableUserSelect
|
||||
users={users}
|
||||
value="user-2"
|
||||
onChange={jest.fn()}
|
||||
loading
|
||||
/>,
|
||||
)
|
||||
|
||||
const combobox = screen.getByRole('combobox')
|
||||
expect(combobox).toBeDisabled()
|
||||
expect(combobox).toHaveValue('无邪 (wuxie)')
|
||||
expect(combobox).toHaveAttribute('aria-busy', 'true')
|
||||
})
|
||||
})
|
||||
89
lib/__tests__/users-list-route.test.ts
Normal file
89
lib/__tests__/users-list-route.test.ts
Normal file
@ -0,0 +1,89 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { NextRequest } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { GET } from '@/app/api/users/list/route'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
jest.mock('next-auth', () => ({
|
||||
getServerSession: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
authOptions: {},
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
describe('GET /api/users/list', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
test('未登录时返回 401 且不读取人员数据', async () => {
|
||||
;(getServerSession as jest.Mock).mockResolvedValue(null)
|
||||
|
||||
const response = await GET(new NextRequest('http://localhost/api/users/list'))
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(prisma.user.findMany).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('只读取并返回人员选择器需要的最小字段', async () => {
|
||||
;(getServerSession as jest.Mock).mockResolvedValue({
|
||||
user: { id: 'user-1' },
|
||||
})
|
||||
;(prisma.user.findMany as jest.Mock).mockResolvedValue([
|
||||
{
|
||||
id: 'user-1',
|
||||
username: 'moli',
|
||||
realName: '魔礼红',
|
||||
},
|
||||
{
|
||||
id: 'user-2',
|
||||
username: 'wuxie',
|
||||
realName: null,
|
||||
},
|
||||
])
|
||||
|
||||
const response = await GET(new NextRequest('http://localhost/api/users/list'))
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(prisma.user.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
isActive: true,
|
||||
status: 'active',
|
||||
archivedAt: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
realName: true,
|
||||
},
|
||||
orderBy: {
|
||||
username: 'asc',
|
||||
},
|
||||
})
|
||||
expect(body).toEqual([
|
||||
{
|
||||
id: 'user-1',
|
||||
username: 'moli',
|
||||
displayName: '魔礼红',
|
||||
},
|
||||
{
|
||||
id: 'user-2',
|
||||
username: 'wuxie',
|
||||
displayName: 'wuxie',
|
||||
},
|
||||
])
|
||||
expect(body[0]).not.toHaveProperty('email')
|
||||
expect(body[0]).not.toHaveProperty('realName')
|
||||
})
|
||||
})
|
||||
@ -166,6 +166,7 @@ export async function buildMaterialSearchOrConditions(search: string): Promise<a
|
||||
const userIds = userMatches.map((item) => item.id)
|
||||
|
||||
const orConditions: any[] = [
|
||||
{ id: keyword },
|
||||
{ productModel: { contains: keyword, mode: 'insensitive' } },
|
||||
{ reportTitle: { contains: keyword, mode: 'insensitive' } },
|
||||
{ taskNo: { contains: keyword, mode: 'insensitive' } },
|
||||
|
||||
@ -176,12 +176,16 @@ export async function updateMaterialWithSharedRules(options: UpdateMaterialOptio
|
||||
}
|
||||
|
||||
const material = editable.material as any
|
||||
const hasReportTitleField = Object.prototype.hasOwnProperty.call(input, 'reportTitle')
|
||||
const hasTaskNoField = Object.prototype.hasOwnProperty.call(input, 'taskNo')
|
||||
const hasDingtalkApprovalNoField = Object.prototype.hasOwnProperty.call(input, 'dingtalkApprovalNo')
|
||||
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 effectiveReportTitle = hasReportTitleField
|
||||
? reportTitle
|
||||
: normalizeReportTitle(material.reportTitle)
|
||||
const materialType = normalizeString(input.materialType)
|
||||
const authorName = normalizeString(input.authorName)
|
||||
const shouldUseGeneralCategoryScene = shouldAutoFillGeneral(materialType)
|
||||
@ -200,7 +204,7 @@ export async function updateMaterialWithSharedRules(options: UpdateMaterialOptio
|
||||
}
|
||||
|
||||
if (isCertificateReportMaterialType(materialType)) {
|
||||
if (!productModel && !reportTitle) {
|
||||
if (!productModel && !effectiveReportTitle) {
|
||||
return { error: 'SPU 和 报告名称/检测对象 至少填写一个', status: 400 as const }
|
||||
}
|
||||
} else if (!productModel) {
|
||||
@ -341,7 +345,7 @@ export async function updateMaterialWithSharedRules(options: UpdateMaterialOptio
|
||||
const updateData: Record<string, any> = {
|
||||
previewUrl,
|
||||
productModel,
|
||||
reportTitle: isCertificateReportMaterialType(materialType) ? reportTitle : null,
|
||||
reportTitle: isCertificateReportMaterialType(materialType) ? effectiveReportTitle : null,
|
||||
categoryName: shouldUseGeneralCategoryScene ? '通用' : (categoryName ?? material.categoryName),
|
||||
sceneName: shouldUseGeneralCategoryScene ? '通用' : (sceneName ?? material.sceneName),
|
||||
platformName: platformName ?? '',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user