jiachenlong/frontend/frontend_src/pages/BatchMode.jsx

514 lines
24 KiB
React
Raw Normal View History

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/recognize', { 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 (
<div style={{ padding: '20px' }}>
<div
onClick={() => 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'
}}
>
<div style={{ fontSize: '60px', opacity: 0.5 }}>📦</div>
<div style={{ color: '#fff', fontSize: '16px', marginTop: '16px' }}>点击选择多张图片</div>
<div style={{ color: '#94a3b8', fontSize: '13px', marginTop: '8px' }}>最多10张</div>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
onChange={handleChooseImages}
style={{ display: 'none' }}
/>
</div>
)
}
const isSaved = savedCount > currentIndex
return (
<div style={{ padding: '16px', paddingBottom: '120px' }}>
{/* Sticky Header */}
<div style={{ position: 'sticky', top: 0, background: '#0f172a', padding: '12px 0', marginBottom: '12px', zIndex: 10, borderBottom: '1px solid rgba(255,255,255,0.1)' }}>
<div style={{ color: '#fff', fontSize: '14px', marginBottom: '8px' }}>
已保存: {savedCount} / {images.length} | 当前第 {currentIndex + 1}
</div>
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
{images.map((_, i) => (
<div key={i} style={{
width: '24px', height: '24px', borderRadius: '4px',
background: i < savedCount ? '#22c55e' : (i === currentIndex ? '#fbbf24' : '#333'),
color: '#fff', fontSize: '10px', display: 'flex', alignItems: 'center', justifyContent: 'center'
}}>
{i + 1}
</div>
))}
</div>
{currentIndex === images.length - 1 && savedCount > 0 && (
<button onClick={handleReUpload} style={{ marginTop: '12px', padding: '8px 16px', background: '#6366f1', color: '#fff', border: 'none', borderRadius: '6px', fontSize: '13px', cursor: 'pointer' }}>
重新上传
</button>
)}
</div>
{/* Image */}
<div style={{ marginBottom: '16px' }}>
<img
src={URL.createObjectURL(images[currentIndex])}
alt="preview"
style={{ width: '100%', maxHeight: '200px', objectFit: 'contain', borderRadius: '8px' }}
/>
</div>
{/* Buttons - moved here */}
<div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
<button
onClick={handlePrev}
disabled={currentIndex === 0}
style={{ flex: 1, padding: '12px', background: currentIndex === 0 ? '#333' : 'rgba(255,255,255,0.1)', color: '#fff', border: 'none', borderRadius: '8px', cursor: currentIndex === 0 ? 'not-allowed' : 'pointer' }}
>
上一张
</button>
{currentIndex < images.length - 1 && (
<button
onClick={handleNext}
style={{ flex: 1, padding: '12px', background: '#6366f1', color: '#fff', border: 'none', borderRadius: '8px', cursor: 'pointer' }}
>
下一张
</button>
)}
<button
onClick={handleReOCR}
disabled={loading}
style={{ flex: 1, padding: '12px', background: 'rgba(255,255,255,0.1)', color: '#fff', border: '1px solid rgba(255,255,255,0.2)', borderRadius: '8px', cursor: loading ? 'not-allowed' : 'pointer' }}
>
重新识别
</button>
<button
onClick={handleSave}
disabled={!result || loading}
style={{ flex: 1, padding: '12px', background: result && !loading ? '#22c55e' : '#333', color: '#fff', border: 'none', borderRadius: '8px', fontWeight: 'bold', cursor: result && !loading ? 'pointer' : 'not-allowed' }}
>
保存结果
</button>
</div>
{loading && (
<div style={{ textAlign: 'center', padding: '20px', color: '#fbbf24' }}>🤖 AI识别中...</div>
)}
{result && !loading && (
<>
{/* 基本信息 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '14px', marginBottom: '12px' }}>
<div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold', marginBottom: '12px' }}>基本信息</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>名称</div>
<input type="text" value={result.name || ''} onChange={(e) => 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' }} />
</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>编号</div>
<input type="text" value={result.code || ''} onChange={(e) => 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' }} />
</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>持仓类型</div>
<select value={result.ownership_type || '自持'} onChange={(e) => updateResult('ownership_type', 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' }}>
{categoryOptions.map(o => <option key={o.value} value={o.value} style={{color:'#000'}}>{o.label}</option>)}
</select>
</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>珍惜度</div>
<select value={result.rarity || '通货'} onChange={(e) => updateResult('rarity', 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' }}>
{rarityOptions.map(o => <option key={o.value} value={o.value} style={{color:'#000'}}>{o.label}</option>)}
</select>
</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>冠字序号</div>
<input type="text" value={result.prefixSerial || ''} onChange={(e) => 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' }} />
</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>版别</div>
<input type="text" value={result.version || ''} onChange={(e) => 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' }} />
</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>状态</div>
<select value={result.status || 'in_collection'} onChange={(e) => updateResult('status', 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' }}>
{statusOptions.map(o => <option key={o.value} value={o.value} style={{color:'#000'}}>{o.label}</option>)}
</select>
</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>封装</div>
<select value={result.packaging || '单张'} onChange={(e) => updateResult('packaging', 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' }}>
{packagingOptions.map(o => <option key={o.value} value={o.value} style={{color:'#000'}}>{o.label}</option>)}
</select>
</div>
</div>
{/* 评级信息 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '14px', marginBottom: '12px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '10px', padding: '8px', background: result.isGraded ? 'rgba(34, 197, 94, 0.15)' : 'rgba(255,255,255,0.03)', borderRadius: '6px', cursor: 'pointer' }}
onClick={() => updateResult('isGraded', !result.isGraded)}>
<div style={{ width: '22px', height: '22px', borderRadius: '4px', border: '2px solid', borderColor: result.isGraded ? '#22c55e' : '#64748b', backgroundColor: result.isGraded ? '#22c55e' : 'transparent', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{result.isGraded && <span style={{color:'#fff',fontSize:'14px'}}></span>}
</div>
<span style={{color:'#fff',fontSize:'13px'}}>已评级</span>
</div>
{result.isGraded && (
<>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>评级公司</div>
<input type="text" value={result.gradingCompany || ''} onChange={(e) => 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' }} />
</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>评级分数</div>
<input type="text" value={result.gradingScore || ''} onChange={(e) => 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' }} />
</div>
</>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', padding: '8px', background: result.threeStar ? 'rgba(34, 197, 94, 0.15)' : 'rgba(255,255,255,0.03)', borderRadius: '6px', cursor: 'pointer' }}
onClick={() => updateResult('threeStar', !result.threeStar)}>
<div style={{ width: '22px', height: '22px', borderRadius: '4px', border: '2px solid', borderColor: result.threeStar ? '#22c55e' : '#64748b', backgroundColor: result.threeStar ? '#22c55e' : 'transparent', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{result.threeStar && <span style={{color:'#fff',fontSize:'14px'}}></span>}
</div>
<span style={{color:'#fff',fontSize:'13px'}}>三星</span>
</div>
</div>
{/* 特殊信息 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '14px', marginBottom: '12px' }}>
<div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold', marginBottom: '12px' }}>特殊信息</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>发行机构</div>
<input type="text" value={result.issuer || ''} onChange={(e) => 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' }} />
</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>年份</div>
<input type="text" value={result.issueYear || ''} onChange={(e) => 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' }} />
</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>特殊标识</div>
<input type="text" value={result.specialMark || ''} onChange={(e) => 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' }} />
</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>号码特征</div>
<input type="text" value={result.serialFeature || ''} onChange={(e) => 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' }} />
</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>面额</div>
<input type="text" value={result.denomination || ''} onChange={(e) => 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' }} />
</div>
</div>
{/* 价格信息 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '14px', marginBottom: '16px' }}>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>成本价</div>
<input type="number" value={result.costPrice || ''} onChange={(e) => 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' }} />
</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>出售价</div>
<input type="number" value={result.targetPrice || ''} onChange={(e) => 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' }} />
</div>
</div>
{/* 备注 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '14px', marginBottom: '16px' }}>
<div style={{ marginBottom: '8px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>备注</div>
<textarea value={result.remark || ''} onChange={(e) => updateResult('remark', e.target.value)} rows={2}
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', resize: 'none' }} />
</div>
</div>
</>
)}
{/* Footer */}
{savedCount > 0 && currentIndex === images.length - 1 && (
<button onClick={handleReUpload} style={{ width: '100%', padding: '14px', background: '#6366f1', color: '#fff', border: 'none', borderRadius: '8px', fontSize: '15px', marginTop: '16px', cursor: 'pointer' }}>
重新上传
</button>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
onChange={handleChooseImages}
style={{ display: 'none' }}
/>
</div>
)
}