jiachenlong/frontend/frontend_src/pages/BatchMode.jsx

514 lines
24 KiB
JavaScript
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.

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>
)
}