import React, { useState, useRef } from 'react' // 字段名转换函数:驼峰转下划线 const convertField = (obj) => { const map = { prefixSerial: 'prefix_serial', isGraded: 'is_graded', gradingCompany: 'grading_company', gradingScore: 'grading_score', threeStar: 'three_star', specialMark: 'special_mark', serialFeature: 'serial_feature', issueYear: 'issue_year', issueQuantity: 'issue_quantity', costPrice: 'cost_price', targetPrice: 'target_price', goalPrice: 'goal_price', repairFee: 'repair_fee', gradingFee: 'grading_fee' } const result = {} for (const key in obj) { result[map[key] || key] = obj[key] } return result } export default function BatchMode() { const [images, setImages] = useState([]) const [currentIndex, setCurrentIndex] = useState(0) const [result, setResult] = useState(null) const [loading, setLoading] = useState(false) const [savedCount, setSavedCount] = useState(0) const fileInputRef = useRef(null) const statusOptions = [ { value: 'in_collection', label: '收藏中' }, { value: 'selling', label: '在售' }, { value: 'sold', label: '已售' }, { value: 'grading', label: '送评' }, { value: 'transit', label: '在途' }, { value: 'other', label: '其他' } ] const packagingOptions = [ { value: '单张', label: '单张' }, { value: '标十', label: '标十' }, { value: '标百', label: '标百' }, { value: '裸钞', label: '裸钞' } ] const categoryOptions = [ { value: '自持', label: '自持' }, { 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 handleChooseImages = (e) => { const files = Array.from(e.target.files || []) if (files.length > 0) { setImages(files.slice(0, 10)) setCurrentIndex(0) setResult(null) setSavedCount(0) // Auto OCR first image processOCR(files[0]) } } const processOCR = async (file) => { setLoading(true) try { const formData = new FormData() formData.append('image', file) const res = await fetch('/api/ocr', { method: 'POST', body: formData }) const data = await res.json() const text = data.result || '' const parsed = { name: '生肖纪念钞', ownership_type: '自持', rarity: '通货', status: 'in_collection', version: '', prefixSerial: '', packaging: '单张', isGraded: false, gradingCompany: '', gradingScore: '', threeStar: false, specialMark: '', serialFeature: '', denomination: '贰拾圆', issuer: '中国人民银行', issueYear: '2024', material: '塑料', issueQuantity: '一亿', costPrice: '', goalPrice: '', targetPrice: '', remark: '' } const v = text.match(/发行版别[::]\s*([^\n]+)/) if (v) { parsed.version = v[1].trim() const yearMatch = v[1].match(/20\d{2}/) if (yearMatch) parsed.issueYear = yearMatch[0] } // 自动识别发行机构 const issuerMatch = text.match(/发行机构[::]\s*([^\n]+)/) if (issuerMatch) parsed.issuer = issuerMatch[1].trim() if (text.includes('三星') || text.includes('3星') || text.includes('★★★')) parsed.threeStar = true const d = text.match(/面额[::]\s*([^\n]+)/) if (d) parsed.denomination = d[1].trim() const p = text.match(/冠字序号[::]\s*([^\n]+)/) if (p) parsed.prefixSerial = p[1].trim() // 识别编号 const codeMatch = text.match(/编号[::]\s*([^\n]+)/) if (codeMatch) parsed.code = codeMatch[1].trim() const pk = text.match(/封装类型[::]\s*([^\n]+)/) if (pk) { const pv = pk[1].trim() if (pv.includes('百')) parsed.packaging = '标百' else if (pv.includes('十')) parsed.packaging = '标十' else if (pv.includes('单')) parsed.packaging = '单张' else parsed.packaging = '裸钞' } const g = text.match(/是否评级[::]\s*([^\n]+)/) if (g) parsed.isGraded = g[1].trim() === '是' const gc = text.match(/评级机构[::]\s*([^\n]+)/) if (gc) parsed.gradingCompany = gc[1].trim() const gs = text.match(/评级分数[::]\s*([^\n]+)/) if (gs) parsed.gradingScore = gs[1].trim() const sm = text.match(/特殊标识[::]\s*([^\n]+)/) if (sm) parsed.specialMark = sm[1].trim() const sf = text.match(/号码特征[::]\s*([^\n]+)/) if (sf) parsed.serialFeature = sf[1].trim() setResult(parsed) } catch (e) { console.error(e) alert('识别失败,请重试') } setLoading(false) } const updateResult = (field, value) => { setResult(prev => ({ ...prev, [field]: value })) } const handleSave = async (force = false) => { const token = localStorage.getItem('token') if (!token) { alert('请先登录') return } try { const saveData = convertField({ ...result, _forceSave: force }) const res = await fetch('/api/collections', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }, body: JSON.stringify(saveData) }) if (res.ok) { setSavedCount(prev => prev + 1) alert('保存成功!') } else { const data = await res.json() const errMsg = data.error || data.message || data.detail || '保存失败' // 检查重复 if (errMsg.includes('E002') || errMsg.includes('已存在') || errMsg.includes('禁止重复') || errMsg.includes('重复')) { const confirmed = confirm('发现重复:冠字号 ' + (result.prefixSerial || '') + ' 已存在,是否继续保存?') if (confirmed) { await handleSave(true) // 强制保存 return } } alert(errMsg) } } catch (e) { alert('保存失败: ' + e.message) } } const handleNext = () => { if (currentIndex < images.length - 1) { setCurrentIndex(currentIndex + 1) setResult(null) processOCR(images[currentIndex + 1]) } } const handlePrev = () => { if (currentIndex > 0) { setCurrentIndex(currentIndex - 1) setResult(null) processOCR(images[currentIndex - 1]) } } const handleReOCR = () => { processOCR(images[currentIndex]) } const handleReUpload = () => { setImages([]) setCurrentIndex(0) setResult(null) setSavedCount(0) fileInputRef.current?.click() } // No images selected if (images.length === 0) { return (
fileInputRef.current?.click()} style={{ minHeight: '50vh', background: 'rgba(255,255,255,0.03)', border: '2px dashed rgba(255,255,255,0.15)', borderRadius: '16px', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }} >
📦
点击选择多张图片
最多10张
) } const isSaved = savedCount > currentIndex return (
{/* Sticky Header */}
已保存: {savedCount} / {images.length} | 当前第 {currentIndex + 1} 张
{images.map((_, i) => (
{i + 1}
))}
{currentIndex === images.length - 1 && savedCount > 0 && ( )}
{/* Image */}
preview
{/* Buttons - moved here */}
{currentIndex < images.length - 1 && ( )}
{loading && (
🤖 AI识别中...
)} {result && !loading && ( <> {/* 基本信息 */}
基本信息
名称
updateResult('name', e.target.value)} style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
编号
updateResult('code', e.target.value)} style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
持仓类型
珍惜度
冠字序号
updateResult('prefixSerial', e.target.value)} style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fbbf24', fontSize: '13px', fontFamily: 'monospace' }} />
版别
updateResult('version', e.target.value)} style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
状态
封装
{/* 评级信息 */}
updateResult('isGraded', !result.isGraded)}>
{result.isGraded && }
已评级
{result.isGraded && ( <>
评级公司
updateResult('gradingCompany', e.target.value)} style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
评级分数
updateResult('gradingScore', e.target.value)} style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fbbf24', fontSize: '13px' }} />
)}
updateResult('threeStar', !result.threeStar)}>
{result.threeStar && }
三星
{/* 特殊信息 */}
特殊信息
发行机构
updateResult('issuer', e.target.value)} style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
年份
updateResult('issueYear', e.target.value)} style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
特殊标识
updateResult('specialMark', e.target.value)} style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
号码特征
updateResult('serialFeature', e.target.value)} style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
面额
updateResult('denomination', e.target.value)} style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
{/* 价格信息 */}
成本价
updateResult('costPrice', e.target.value)} style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
出售价
updateResult('targetPrice', e.target.value)} style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fbbf24', fontSize: '13px' }} />
{/* 备注 */}
备注