feat: 完善素材搜索与编辑体验

This commit is contained in:
chenpei 2026-07-30 14:39:18 +08:00
parent 1299357f8e
commit 5202d1240c
17 changed files with 1241 additions and 713 deletions

View File

@ -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;

View File

@ -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;

View File

@ -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

View File

@ -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}
/>

View File

@ -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>
)
}

View File

@ -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 ${

View File

@ -27,7 +27,7 @@ interface Suggestion {
function SearchBarInner({
value: externalValue,
onChange,
placeholder = '搜索素材:SPU型号、任务号、SKU、店铺、场景、作者...',
placeholder = '搜索素材 ID、SPU、任务号、店铺、场景或作者',
isSearching = false,
}: SearchBarProps) {
// 本地状态用于显示(包括 IME 组合中的拼音)

View File

@ -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' } },

View 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)
})
})

View File

@ -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 () => {

View 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 }]))
})
})

View File

@ -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,

View 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')
})
})

View 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')
})
})

View File

@ -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' } },

View File

@ -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 ?? '',