diff --git a/config/VERSION b/config/VERSION index 532db09..3df742a 100644 --- a/config/VERSION +++ b/config/VERSION @@ -1 +1 @@ -VERSION=1.2.50 +VERSION=1.2.51 diff --git a/frontend/src/pages/Add.jsx.bak b/frontend/src/pages/Add.jsx.bak new file mode 100644 index 0000000..f09e0cb --- /dev/null +++ b/frontend/src/pages/Add.jsx.bak @@ -0,0 +1,673 @@ +// 添加藏品页面 - 支持 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' }} /> + +
+
+
+ + +
+
+ + {/* 特殊信息 */} +
+
特殊信息
+
+ + + + + + + +
+
+ + {/* 价格信息 */} +
+
价格信息
+
+ + + + + + +
+
+ + {/* 备注 */} +
+
备注
+