gongxiangp2/components/EditMaterialModal.tsx
chenpei cd6e40d9d2 优化:网络传输性能和稳定性 - TCP缓冲区+并发控制优化
主要改动:
1. TCP缓冲区优化(16MB)- 解决公网上传速度慢问题
2. 并发数降低到1-3 - 避免4并发导致ERR_CONNECTION_RESET
3. 动态并发控制 - 根据网络环境自动调整
4. Nginx超时增加到120s - 支持大分片上传
5. 上传进度修复 - 修复剩余时间计算
6. 新增任务-页面上传器和详情组件
7. 统一部署文档为deploy-remote-build.ps1

问题修复:
- 公网用户上传从200-900KB/s提升到预期3-5MB/s
- 内网用户上传更稳定,无连接重置错误
- UploadProgress组件剩余时间计算修正

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-27 09:33:09 +08:00

566 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useState, useRef, useEffect } from 'react'
import Image from 'next/image'
import SearchableUserSelect from '@/components/SearchableUserSelect'
import { requiresCategoryAndScene, requiresRenderer, requiresProductCategoryName, shouldPassRenderer } from '@/lib/material-validation'
interface EditMaterialModalProps {
material: {
id: string
previewUrl: string
productModel: string
categoryName: string
sceneName: string
platformName?: string | null
productCategoryName?: string | null
materialType: string
renderer: string
authorName: string
userId: string // 添加userId字段
}
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 [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) // 添加userId状态
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 [showNewCategoryInput, setShowNewCategoryInput] = useState(false)
const [newCategoryName, setNewCategoryName] = useState('')
const [showNewSceneInput, setShowNewSceneInput] = useState(false)
const [newSceneName, setNewSceneName] = useState('')
const [updating, setUpdating] = useState(false)
const [error, setError] = useState('')
const previewInputRef = useRef<HTMLInputElement>(null)
// 加载分类和场景列表
useEffect(() => {
fetchCategories()
fetchScenes()
fetchPlatforms()
fetchProductCategories()
fetchUsers() // 添加获取用户列表
}, [])
const fetchCategories = async () => {
try {
const response = await fetch('/api/categories')
if (response.ok) {
const data = await response.json()
setCategories(data)
}
} catch (error) {
console.error('Error fetching categories:', error)
}
}
const fetchScenes = async () => {
try {
const response = await fetch('/api/scenes')
if (response.ok) {
const data = await response.json()
setScenes(data)
}
} catch (error) {
console.error('Error fetching scenes:', error)
}
}
const fetchPlatforms = async () => {
try {
const response = await fetch('/api/platforms')
if (response.ok) {
const data = await response.json()
setPlatforms(data)
}
} catch (error) {
console.error('Error fetching platforms:', error)
}
}
const fetchProductCategories = async () => {
try {
const response = await fetch('/api/product-categories')
if (response.ok) {
const data = await response.json()
setProductCategories(data)
}
} catch (error) {
console.error('Error fetching product categories:', error)
}
}
const fetchUsers = async () => {
try {
const response = await fetch('/api/users/list')
if (response.ok) {
const data = await response.json()
setUsers(data)
}
} catch (error) {
console.error('Error fetching users:', error)
}
}
const handleAddNewCategory = async () => {
if (!newCategoryName.trim()) {
setError('请输入一级分类名称')
return
}
try {
const response = await fetch('/api/categories', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: newCategoryName.trim() }),
})
if (response.ok) {
const newCategory = await response.json()
await fetchCategories()
setCategoryName(newCategory.name)
setNewCategoryName('')
setShowNewCategoryInput(false)
setError('')
} else {
const data = await response.json()
setError(data.error || '添加一级分类失败')
}
} catch (error) {
console.error('Error adding category:', error)
const { parseApiError } = await import('@/lib/error-handler')
const { message } = parseApiError(error)
setError(message)
}
}
const handleAddNewScene = async () => {
if (!newSceneName.trim()) {
setError('请输入场景名称')
return
}
try {
const response = await fetch('/api/scenes', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: newSceneName.trim() }),
})
if (response.ok) {
const newScene = await response.json()
await fetchScenes()
setSceneName(newScene.name)
setNewSceneName('')
setShowNewSceneInput(false)
setError('')
} else {
const data = await response.json()
setError(data.error || '添加场景失败')
}
} catch (error) {
console.error('Error adding scene:', error)
setError('添加场景失败')
}
}
const handlePreviewChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
if (!file.type.startsWith('image/')) {
setError('请选择图片文件')
return
}
setPreviewImage(file)
setPreviewUrl(URL.createObjectURL(file))
setError('')
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
// 基础必填字段验证
if (!productModel || !materialType || !authorName) {
setError('请填写所有必填字段')
return
}
// 使用统一的验证逻辑
if (requiresCategoryAndScene(materialType) && (!categoryName || !sceneName)) {
setError('请填写所有必填字段(店铺、场景)')
return
}
if (requiresProductCategoryName(materialType) && !productCategoryName) {
setError('标准模型类型必须选择品类')
return
}
if (requiresRenderer(materialType) && !renderer) {
setError('3D场景类型必须指定渲染器')
return
}
setUpdating(true)
try {
const formData = new FormData()
formData.append('id', material.id)
formData.append('productModel', productModel)
formData.append('categoryName', categoryName)
formData.append('sceneName', sceneName)
if (platformName) formData.append('platformName', platformName)
if (productCategoryName) formData.append('productCategoryName', productCategoryName)
formData.append('materialType', materialType)
// 统一处理renderer字段:根据素材类型判断是否需要传递
const rendererInfo = shouldPassRenderer(materialType, renderer)
if (rendererInfo.shouldPass) {
formData.append('renderer', rendererInfo.value)
}
formData.append('authorName', authorName)
formData.append('userId', userId) // 添加userId字段
if (previewImage) {
formData.append('previewImage', previewImage)
}
const response = await fetch('/api/materials/update', {
method: 'PUT',
body: formData,
})
if (response.ok) {
onSuccess()
} else {
const data = await response.json()
setError(data.error || '更新失败')
}
} catch (error) {
console.error('Update error:', error)
setError('更新失败,请稍后再试')
} finally {
setUpdating(false)
}
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-apple max-w-2xl w-full max-h-[90vh] overflow-y-auto shadow-apple-lg">
<div className="p-6 border-b border-gray-200">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-gray-900">编辑素材</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 text-2xl leading-none"
>
×
</button>
</div>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{error && (
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-apple">
{error}
</div>
)}
{/* Preview Image */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
预览图
</label>
<div
onClick={() => previewInputRef.current?.click()}
className="border-2 border-dashed border-gray-300 rounded-apple p-4 text-center cursor-pointer hover:border-primary-500 transition-colors"
>
<div className="relative w-full h-48">
<Image
src={previewUrl}
alt="预览"
fill
unoptimized
className="object-contain"
/>
</div>
<p className="text-xs text-gray-400 mt-2">
点击更换预览图(可选)
</p>
</div>
<input
ref={previewInputRef}
type="file"
accept="image/*"
onChange={handlePreviewChange}
className="hidden"
/>
</div>
{/* Product Model */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
商品型号 *
</label>
<input
type="text"
value={productModel}
onChange={(e) => {
// 只允许英文、数字和常见符号(- _ .),过滤中文,自动转大写
const value = e.target.value.replace(/[^a-zA-Z0-9\-_.]/g, '').toUpperCase()
setProductModel(value)
}}
className="input-apple"
placeholder="例如:XY-2024-001"
required
/>
</div>
{/* Category Name (一级分类) */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
一级分类(使用店铺) *
</label>
{!showNewCategoryInput ? (
<div className="space-y-2">
<select
value={categoryName}
onChange={(e) => {
if (e.target.value === '__add_new__') {
setShowNewCategoryInput(true)
setCategoryName('')
} else {
setCategoryName(e.target.value)
}
}}
className="input-apple"
required
>
<option value="">请选择一级分类</option>
{categories.map((category) => (
<option key={category.id} value={category.name}>
{category.name}
</option>
))}
<option value="__add_new__">➕ 添加新分类</option>
</select>
</div>
) : (
<div className="space-y-2">
<div className="flex gap-2">
<input
type="text"
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
className="input-apple flex-1"
placeholder="输入新的一级分类名称"
autoFocus
/>
<button
type="button"
onClick={handleAddNewCategory}
className="px-4 py-2 bg-blue-600 text-white rounded-apple hover:bg-blue-700 transition-colors"
>
确认
</button>
<button
type="button"
onClick={() => {
setShowNewCategoryInput(false)
setNewCategoryName('')
setError('')
}}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-apple hover:bg-gray-200 transition-colors"
>
取消
</button>
</div>
</div>
)}
</div>
{/* Scene Name (二级分类) */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
二级分类(场景名称) *
</label>
{!showNewSceneInput ? (
<div className="space-y-2">
<select
value={sceneName}
onChange={(e) => {
if (e.target.value === '__add_new__') {
setShowNewSceneInput(true)
setSceneName('')
} else {
setSceneName(e.target.value)
}
}}
className="input-apple"
required
>
<option value="">请选择场景</option>
{scenes.map((scene) => (
<option key={scene.id} value={scene.name}>
{scene.name}
</option>
))}
<option value="__add_new__">+ 添加新场景</option>
</select>
</div>
) : (
<div className="space-y-2">
<div className="flex space-x-2">
<input
type="text"
value={newSceneName}
onChange={(e) => setNewSceneName(e.target.value)}
className="input-apple"
placeholder="输入新场景名称"
autoFocus
/>
<button
type="button"
onClick={handleAddNewScene}
className="btn-primary whitespace-nowrap"
>
确定
</button>
<button
type="button"
onClick={() => {
setShowNewSceneInput(false)
setNewSceneName('')
setError('')
}}
className="btn-secondary whitespace-nowrap"
>
取消
</button>
</div>
<p className="text-xs text-gray-500">
添加后将自动加入到场景列表中
</p>
</div>
)}
</div>
{/* Material Type */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
素材类型 *
</label>
<select
value={materialType}
onChange={(e) => setMaterialType(e.target.value)}
className="input-apple"
required
>
<option value="3D场景">3D场景</option>
<option value="详情模板">详情模板</option>
<option value="主图模版">主图模版</option>
<option value="SKU模版">SKU模版</option>
<option value="主图视频">主图视频</option>
<option value="安装视频">安装视频</option>
<option value="种草视频">种草视频</option>
<option value="模特">模特</option>
<option value="实拍图片">实拍图片</option>
<option value="买家秀图片">买家秀图片</option>
<option value="小红书种草">小红书种草</option>
<option value="音乐">音乐</option>
<option value="C4D模型">C4D模型</option>
<option value="标准饰品">标准饰品</option>
<option value="品牌VI灵感">品牌VI灵感</option>
<option value="矢量文件">矢量文件</option>
<option value="CAD">CAD</option>
<option value="标准模型">标准模型</option>
</select>
</div>
{/* Renderer (仅3D场景显示) */}
{requiresRenderer(materialType) && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
渲染器 *
</label>
<select
value={renderer}
onChange={(e) => setRenderer(e.target.value)}
className="input-apple"
required
>
<option value="CR">CR</option>
<option value="VR">VR</option>
<option value="其它">其它</option>
</select>
</div>
)}
{/* Author Name */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
作者(素材所有者) *
</label>
<SearchableUserSelect
users={users}
value={userId}
onChange={(newUserId, displayName) => {
setUserId(newUserId)
setAuthorName(displayName)
}}
placeholder="请输入或搜索用户(支持拼音)"
required
/>
<p className="text-xs text-gray-500 mt-1">
修改作者将改变素材的实际所有者。支持拼音搜索,例如输入&quot;zs&quot;可以找到&quot;张三&quot;
</p>
</div>
{/* Actions */}
<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>
)
}