// 添加藏品页面 - 支持 AI 识别/手工录入/批量录入 import React, { useState, useRef } from 'react' import { APP_VERSION } from '../config/version' const numberCategoryOptions = [{value:'无2347',label:'无2347'},{value:'无347',label:'无347'},{value:'无247',label:'无247'},{value:'无47',label:'无47'},{value:'无4',label:'无4'},{value:'带4',label:'带4'},{value:'其他',label:'其他'}]; // 字段转换函数 const convertField = (obj) => { const map = { id: 'f99_90_id', userId: 'f99_91_user_id', name: 'f01_01_name', code: 'f01_02_code', category: 'f01_03_category', status: 'f01_04_status', remark: 'f01_05_remark', prefixSerial: 'f02_10_prefix_serial', version: 'f02_11_version', packaging: 'f02_12_packaging', rarity: 'f02_13_rarity', isGraded: 'f03_20_is_graded', gradingCompany: 'f03_21_grading_company', gradingScore: 'f03_22_grading_score', threeStar: 'f03_23_three_star', specialMark: 'f04_30_special_mark', serialFeature: 'f04_31_serial_feature', numberCategory: 'f02_14_number_category', issuer: 'f04_32_issuer', issueYear: 'f04_33_issue_year', material: 'f04_34_material', denomination: 'f04_35_denomination', issueQuantity: 'f04_36_issue_quantity', costPrice: 'f05_40_cost_price', targetPrice: 'f05_41_target_price', goalPrice: 'f05_42_goal_price', repairFee: 'f05_43_repair_fee', gradingFee: 'f05_44_grading_fee', purpose: 'f06_50_purpose' } const result = {} const numberFields = ['costPrice', 'targetPrice', 'goalPrice', 'repairFee', 'gradingFee'] for (const key in obj) { let value = obj[key] if (value === '' || value === null) value = null else if (numberFields.includes(key)) { value = parseFloat(value) if (isNaN(value)) value = null } result[map[key] || key] = value } return result } // 通用 Input 组件 const Input = ({ form, handleChange, label, field, type = 'text', options = null }) => { const onChange = (e) => { const value = e.target.value if (type === 'number' && value !== '') { const num = parseFloat(value) handleChange(field, isNaN(num) ? '' : num) } else handleChange(field, value) } return (
{label}
{options ? ( ) : ( )}
) } const getDefaultForm = () => ({ name: '藏品', code: '', category: '自持', rarity: '通货', prefixSerial: '', version: '2024 龙', denomination: '', status: 'in_collection', packaging: '单张', material: '塑料钞', issueQuantity: '1 亿', purpose: '收藏', isGraded: false, gradingCompany: '', gradingScore: '', threeStar: false, specialMark: '', serialFeature: '', numberCategory: '', issuer: '中国人民银行', issueYear: '2024', costPrice: '', targetPrice: '', goalPrice: '', repairFee: '', gradingFee: '', remark: '' }) export default function Add() { // 根据 URL 参数确定默认标签 const params = new URLSearchParams(window.location.hash.split('?')[1] || '') const modeParam = params.get('mode') const defaultTab = modeParam === 'manual' ? 'manual' : 'ai' const [activeTab, setActiveTab] = useState(defaultTab) // ai, manual, batch const [form, setForm] = useState(getDefaultForm()) const [saving, setSaving] = useState(false) const [error, setError] = useState('') const [recognizing, setRecognizing] = useState(false) const [selectedImage, setSelectedImage] = useState(null) const [imagePreview, setImagePreview] = useState(null) const [recognizedImage, setRecognizedImage] = useState(null) const fileInputRef = useRef(null) const imageFileInputRef = useRef(null) const [uploadImages, setUploadImages] = useState([]) const [tempImage, setTempImage] = useState(null) // OCR识别后的临时图片 const maxImages = 1 const handleUploadImage = (e) => { const files = Array.from(e.target.files) if (files.length === 0) return if (files.length + uploadImages.length > maxImages) { alert(`最多只能上传${maxImages}张图片`) return } const newImages = files.map(file => ({ file, preview: URL.createObjectURL(file), name: file.name, size: file.size })) setUploadImages([...uploadImages, ...newImages]) } const statusOptions = [ { value: 'in_collection', label: '收藏中' }, { value: 'selling', label: '出售中' }, { value: 'sold', label: '已售' }, { value: 'grading', label: '送评中' }, { value: 'repairing', label: '修复中' }, { value: 'transit', label: '在途中' }, { value: 'seeking', label: '寻号中' }, { value: 'other', label: '其他' } ] const categoryOptions = [ { value: '自持', label: '自持' }, { value: '寄存', label: '寄存' }, { value: '寄售', label: '寄售' }, { value: '共有', label: '共有' }, { value: '其他', label: '其他' } ] const rarityOptions = [ { value: '通货', label: '通货' }, { value: '特色', label: '特色' }, { value: '少见', label: '少见' }, { value: '稀有', label: '稀有' }, { value: '珍品', label: '珍品' }, { value: '孤品', label: '孤品' } ] const packagingOptions = [ { value: '标十', label: '标十' }, { value: '标百', label: '标百' }, { value: '单张', label: '单张' }, { value: '裸钞', label: '裸钞' } ] const handleChange = (key, value) => { setForm({ ...form, [key]: value }) // 只有设置了出售价(goalPrice)才自动设置为"已售" if (key === 'goalPrice' && value) { setForm(prev => ({ ...prev, status: 'sold' })) } // 冠字号变化时自动分类 if (key === 'prefixSerial') { const serial = value let cat = '' const match = serial.match(/J(\d{9})/) const digits = match ? match[1] : serial.replace(/\D/g, '').slice(0, 9) if (digits) { if (!digits.includes('2') && !digits.includes('3') && !digits.includes('4') && !digits.includes('7')) cat = '无2347' else if (!digits.includes('3') && !digits.includes('4') && !digits.includes('7')) cat = '无347' else if (!digits.includes('2') && !digits.includes('4') && !digits.includes('7')) cat = '无247' else if (!digits.includes('4') && !digits.includes('7') && digits.includes('2') && digits.includes('3')) cat = '无47' else if (digits.includes('7') && !digits.includes('4')) cat = '无4' else if (digits.includes('4')) cat = '带4' else cat = '其他' } setForm(prev => ({ ...prev, numberCategory: cat })) } // 版别变化时同步发行年份 if (key === 'version') { const yearMatch = value.match(/(20\d{2})/) if (yearMatch) { setForm(prev => ({ ...prev, issueYear: yearMatch[1] })) } } } const handleSelectImage = (e) => { const file = e.target.files[0] if (!file) return setSelectedImage(file) const reader = new FileReader() reader.onload = (e) => setImagePreview(e.target.result) reader.readAsDataURL(file) } const handleRecognize = async () => { if (!selectedImage) { setError('请先选择图片'); return } setRecognizing(true) setError('') const token = localStorage.getItem('token') const formData = new FormData() formData.append('image', selectedImage) // 使用 XMLHttpRequest 替代 fetch const data = await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest() xhr.open('POST', '/api/ocr/recognize') xhr.setRequestHeader('Authorization', 'Bearer ' + token) xhr.onload = function() { if (xhr.status >= 200 && xhr.status < 300) { try { const data = JSON.parse(xhr.responseText) resolve(data) } catch (e) { reject(new Error('JSON解析失败: ' + xhr.responseText.substring(0, 100))) } } else { try { const data = JSON.parse(xhr.responseText) reject(new Error(data.error?.message || data.detail || '识别失败')) } catch (e) { reject(new Error('请求失败: ' + xhr.status)) } } } xhr.onerror = function() { reject(new Error('网络错误')) } xhr.send(formData) }) try { if (data.fields) { const recognizedForm = { ...getDefaultForm() } // 直接对应 AI 返回的字段,不需要二次解析 if (data.fields.issuer) recognizedForm.issuer = data.fields.issuer if (data.fields.version) recognizedForm.version = data.fields.version if (data.fields.denomination) recognizedForm.denomination = data.fields.denomination if (data.fields.prefix_serial) recognizedForm.prefixSerial = data.fields.prefix_serial if (data.fields.packaging) recognizedForm.packaging = data.fields.packaging if (data.fields.grading_company) recognizedForm.gradingCompany = data.fields.grading_company if (data.fields.grading_score) recognizedForm.gradingScore = data.fields.grading_score if (data.fields.special_mark && data.fields.special_mark !== '无') recognizedForm.specialMark = data.fields.special_mark if (data.fields.serial_feature && data.fields.serial_feature !== '无') recognizedForm.serialFeature = data.fields.serial_feature // 布尔字段 if (data.fields.is_graded !== undefined) recognizedForm.isGraded = data.fields.is_graded if (data.fields.three_star !== undefined) recognizedForm.threeStar = data.fields.three_star // 保存识别的图片信息(包含服务器临时路径) const newImage = { file: selectedImage, preview: URL.createObjectURL(selectedImage), name: selectedImage.name, size: selectedImage.size, temp_image: data.temp_image // 保存临时图片信息 } setUploadImages([newImage]) setTempImage(data.temp_image) // 保存临时图片信息 setForm(recognizedForm) setActiveTab('manual') console.log('AI 识别结果:', recognizedForm) console.log('识别图片:', newImage) } else setError('识别结果为空') } catch (e) { setError('识别失败:' + e.message) } finally { setRecognizing(false) } } const handleSave = async () => { if (!form.name || !form.version) { setError('名称和版别为必填项'); return } setSaving(true) setError('') const token = localStorage.getItem('token') // 保存前自动根据冠字号设置号码分类 const formWithCategory = { ...form } if (formWithCategory.prefixSerial && !formWithCategory.numberCategory) { const serial = formWithCategory.prefixSerial const match = serial.match(/J(\d{9})/) const digits = match ? match[1] : serial.replace(/\D/g, '').slice(0, 9) if (digits) { let cat = '' if (!digits.includes('2') && !digits.includes('3') && !digits.includes('4') && !digits.includes('7')) cat = '无2347' else if (!digits.includes('3') && !digits.includes('4') && !digits.includes('7')) cat = '无347' else if (!digits.includes('2') && !digits.includes('4') && !digits.includes('7')) cat = '无247' else if (!digits.includes('4') && !digits.includes('7') && digits.includes('2') && digits.includes('3')) cat = '无47' else if (digits.includes('7') && !digits.includes('4')) cat = '无4' else if (digits.includes('4')) cat = '带4' else cat = '其他' formWithCategory.numberCategory = cat } } const formData = convertField(formWithCategory) try { // 1. 保存藏品信息(先检查是否重复) const res = await fetch('/api/collections', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }, body: JSON.stringify(formData) }) if (!res.ok) { const data = await res.json() throw new Error(data.error?.message || data.detail || '保存失败') } const result = await res.json() let collectionId = result.f99_90_id || result.id // 检查是否有错误(重复编号等) if (result.error) { throw new Error(result.error.message || '保存失败') } // 检查是否有重复警告 if (result.warning && result.warning.code === 'DUPLICATE_SERIAL') { const { warning } = result const confirmed = window.confirm( `⚠️ 发现重复冠字号!\n\n` + `冠字号:${warning.existing_collection.prefix_serial}\n` + `已存在于:${warning.existing_collection.name} (编号:${warning.existing_collection.code})\n\n` + `是否继续保存?` ) if (!confirmed) { setSaving(false) return } // 用户确认继续,再次调用 API(添加 force=true 参数) const forceRes = await fetch('/api/collections?force=true', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }, body: JSON.stringify(formData) }) if (!forceRes.ok) { const forceData = await forceRes.json() throw new Error(forceData.error?.message || '保存失败') } const forceResult = await forceRes.json() collectionId = forceResult.f99_90_id || forceResult.id console.log('藏品保存成功(确认重复),ID:', collectionId) } else if (collectionId) { console.log('藏品保存成功,ID:', collectionId) } else { throw new Error('保存失败:未返回藏品 ID') } // 上传图片(无论是否重复都执行) let totalUploadCount = 0 // 2. 处理识别的图片(如果有临时图片,直接认领;否则上传) if (uploadImages.length > 0 && collectionId) { console.log('处理图片,数量:', uploadImages.length) for (const img of uploadImages) { // 检查是否有临时图片(OCR识别后保存的) if (img.temp_image && img.temp_image.id) { // 认领临时图片 try { const claimRes = await fetch(`/api/ocr/claim-temp-image?temp_id=${img.temp_image.id}&collection_id=${collectionId}`, { method: 'POST', headers: { 'Authorization': 'Bearer ' + token } }) if (claimRes.ok) { totalUploadCount++ console.log('✅ 临时图片认领成功') } else { console.error('临时图片认领失败:', await claimRes.text()) } } catch (claimErr) { console.error('临时图片认领异常:', claimErr) } } else { // 普通上传 const imgFormData = new FormData() imgFormData.append('file', img.file) try { const uploadRes = await fetch(`/api/collections/upload-image?collection_id=${collectionId}`, { method: 'POST', headers: { 'Authorization': 'Bearer ' + token }, body: imgFormData }) if (uploadRes.ok) { totalUploadCount++ console.log(`图片上传成功`) } else { console.error('图片上传失败:', await uploadRes.text()) } } catch (uploadErr) { console.error('图片上传异常:', uploadErr) } } } console.log(`✅ 共处理 ${totalUploadCount} 张图片`) } alert('保存成功!' + (totalUploadCount > 0 ? `已上传${totalUploadCount}张图片` : '')) window.location.hash = '#/list' window.refreshList?.() window.refreshHome?.() } catch (e) { console.error('保存失败:', e) alert('保存失败:' + e.message) } finally { setSaving(false) } } return (
{/* 顶部标签切换 */}
{error && (
⚠️ {error}
)} {/* AI 识别模式 */} {activeTab === 'ai' && (
{imagePreview ? (
已选择图片
) : (
{ fileInputRef.current.setAttribute('capture', 'environment'); fileInputRef.current.click(); }} style={{ padding: '24px', background: 'rgba(59, 130, 246, 0.1)', border: '2px dashed rgba(59, 130, 246, 0.5)', borderRadius: '12px', cursor: 'pointer', marginBottom: '16px', textAlign: 'center' }}>
📷
拍照识别
使用相机拍照并识别
{ fileInputRef.current.removeAttribute('capture'); fileInputRef.current.click(); }} style={{ padding: '24px', background: 'rgba(34, 197, 94, 0.1)', border: '2px dashed rgba(34, 197, 94, 0.5)', borderRadius: '12px', cursor: 'pointer', textAlign: 'center' }}>
🖼️
从相册选择
从相册选择已有图片
)}
💡 识别说明
  • 支持拍照或从相册选择图片
  • 自动识别名称、版别、冠字序号等字段
  • 识别结果可手动修改完善
  • 建议拍摄清晰、光线充足的正面照片
)} {/* 手工录入模式 - 完整表单 */} {activeTab === 'manual' && (
{/* 图片上传区域 */}
藏品图片 ({uploadImages.length}/1)
{uploadImages.length > 0 ? (
{uploadImages.map((img, index) => (
{img.name}
{img.name}
{index + 1}/{uploadImages.length}
))} {uploadImages.length < 1 && (
imageFileInputRef.current?.click()} style={{ aspectRatio: '1.5', background: 'rgba(255,255,255,0.05)', border: '2px dashed rgba(255,255,255,0.3)', borderRadius: '12px', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: '#94a3b8', fontSize: '13px' }}>
📷
添加图片
最多可上传 1 张
)}
) : (
imageFileInputRef.current?.click()} style={{ aspectRatio: '1.5', background: 'rgba(255,255,255,0.05)', border: '2px dashed #fbbf24', borderRadius: '12px', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: '#fbbf24', fontSize: '13px' }}>
📷
点击上传图片
最多可上传 1 张
)}
{/* 基本信息 */}
基本信息
{/* 评级信息 */}
评级信息
handleChange('isGraded', e.target.checked)} style={{ width: '18px', height: '18px', cursor: 'pointer', accentColor: '#22c55e' }} />
handleChange('threeStar', e.target.checked)} style={{ width: '18px', height: '18px', cursor: 'pointer', accentColor: '#22c55e' }} />
{/* 特殊信息 */}
特殊信息
{/* 价格信息 */}
价格信息
{/* 备注 */}
备注