jiachenlong/frontend/src.bak/pages/OCR.jsx

655 lines
22 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

// AI OCR 识别页面 - 支持拍照识别和手工录入
import React, { useState, useEffect, useRef } from 'react'
// 字段转换函数camelCase → 字段编码
const convertField = (obj) => {
const map = {
// f99 系统字段
id: 'f99_90_id',
userId: 'f99_91_user_id',
// f01 基本信息
name: 'f01_01_name',
code: 'f01_02_code',
category: 'f01_03_category',
status: 'f01_04_status',
remark: 'f01_05_remark',
// f02 详细字段
prefixSerial: 'f02_10_prefix_serial',
version: 'f02_11_version',
packaging: 'f02_12_packaging',
rarity: 'f02_13_rarity',
// f03 评级信息
isGraded: 'f03_20_is_graded',
gradingCompany: 'f03_21_grading_company',
gradingScore: 'f03_22_grading_score',
threeStar: 'f03_23_three_star',
// f04 特殊信息
specialMark: 'f04_30_special_mark',
serialFeature: 'f04_31_serial_feature',
issuer: 'f04_32_issuer',
issueYear: 'f04_33_issue_year',
material: 'f04_34_material',
denomination: 'f04_35_denomination',
issueQuantity: 'f04_36_issue_quantity',
// f05 价格信息
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',
// f06 其他信息
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, disabled = false }) => {
const inputRef = useRef(null)
const handleFocus = () => {
setTimeout(() => {
inputRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, 100)
}
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 (
<div style={{ marginBottom: '12px' }} ref={inputRef}>
<div style={{ fontSize: '13px', color: '#94a3b8', marginBottom: '6px' }}>{label}</div>
{options ? (
<select
value={form[field] || ''}
onChange={onChange}
onFocus={handleFocus}
disabled={disabled}
style={{
background: disabled ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.05)',
border: '1px solid rgba(255,255,255,0.1)',
color: disabled ? '#64748b' : '#fff',
padding: '10px',
borderRadius: '8px',
width: '100%'
}}
>
<option value="">请选择</option>
{options.map(o => <option key={o.value} value={o.value} style={{color:'#000'}}>{o.label}</option>)}
</select>
) : (
<input
type={type}
value={form[field] ?? ''}
onChange={onChange}
onFocus={handleFocus}
disabled={disabled}
style={{
background: disabled ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.05)',
border: '1px solid rgba(255,255,255,0.1)',
color: disabled ? '#64748b' : '#fff',
padding: '10px',
borderRadius: '8px',
width: '100%'
}}
/>
)}
</div>
)
}
// 默认表单数据
const getDefaultForm = () => ({
name: '龙钞',
code: '',
category: '自持',
rarity: '通货',
prefixSerial: '',
version: '2024 龙',
denomination: '',
status: 'in_collection',
packaging: '单张',
material: '',
issueQuantity: '',
purpose: '收藏',
isGraded: false,
gradingCompany: '',
gradingScore: '',
threeStar: false,
specialMark: '',
serialFeature: '',
issuer: '中国人民银行',
issueYear: '',
costPrice: '',
targetPrice: '',
goalPrice: '',
repairFee: '',
gradingFee: '',
remark: ''
})
export default function OCR() {
const [activeTab, setActiveTab] = useState('ai') // ai, manual, batch
const [mode, setMode] = useState('camera') // camera, form
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 fileInputRef = useRef(null)
const videoRef = useRef(null)
const canvasRef = useRef(null)
// 选项定义
const statusOptions = [
{ value: 'in_collection', label: '收藏中' },
{ value: 'selling', label: '出售中' },
{ value: 'sold', label: '已售' },
{ value: 'grading', label: '送评中' },
{ value: 'repairing', label: '修复中' },
{ value: 'transit', label: '在途中' },
{ value: 'other', 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 packagingOptions = [
{ value: '标十', label: '标十' },
{ value: '标百', label: '标百' },
{ value: '单张', label: '单张' },
{ value: '裸钞', label: '裸钞' }
]
const handleChange = (key, value) => {
setForm({ ...form, [key]: value })
if (key === 'targetPrice' && value) {
setForm(prev => ({ ...prev, status: 'sold' }))
}
}
// 选择图片
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)
}
// 调用 OCR 识别
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() }
// 映射识别字段到表单字段
if (data.fields.name) recognizedForm.name = data.fields.name
if (data.fields.code) recognizedForm.code = data.fields.code
if (data.fields.version) recognizedForm.version = data.fields.version
if (data.fields.prefix_serial) recognizedForm.prefixSerial = data.fields.prefix_serial
if (data.fields.denomination) recognizedForm.denomination = data.fields.denomination
if (data.fields.material) recognizedForm.material = data.fields.material
if (data.fields.issue_year) recognizedForm.issueYear = data.fields.issue_year
if (data.fields.issuer) recognizedForm.issuer = data.fields.issuer
if (data.fields.cost_price) recognizedForm.costPrice = data.fields.cost_price
if (data.fields.remark) recognizedForm.remark = data.fields.remark
setForm(recognizedForm)
setMode('form')
alert('识别成功!请检查并完善信息')
} else {
setError('识别结果为空')
}
} catch (e) {
console.error('OCR 识别失败:', 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 formData = convertField(form)
try {
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 || '保存失败')
}
alert('保存成功!')
window.location.hash = '#/list'
window.refreshList?.()
window.refreshHome?.()
} catch (e) {
alert('保存失败:' + e.message)
} finally {
setSaving(false)
}
}
// 拍照模式界面
if (mode === 'camera') {
return (
<div style={{ background: '#0f172a', minHeight: '100vh', paddingBottom: '80px' }}>
{/* 顶部标签切换 */}
<div style={{ padding: '12px 16px', background: 'rgba(255,255,255,0.02)', borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={() => setActiveTab('ai')}
style={{
flex: 1,
padding: '10px',
background: activeTab === 'ai' ? '#fbbf24' : 'rgba(255,255,255,0.05)',
color: activeTab === 'ai' ? '#1e293b' : '#fff',
border: 'none',
borderRadius: '8px',
fontSize: '14px',
fontWeight: '600',
cursor: 'pointer'
}}
>
🤖 AI 识别
</button>
<button
onClick={() => {
setActiveTab('manual');
setMode('form');
}}
style={{
flex: 1,
padding: '10px',
background: activeTab === 'manual' ? '#fbbf24' : 'rgba(255,255,255,0.05)',
color: activeTab === 'manual' ? '#1e293b' : '#fff',
border: 'none',
borderRadius: '8px',
fontSize: '14px',
fontWeight: '600',
cursor: 'pointer'
}}
>
手工录入
</button>
<button
onClick={() => setActiveTab('batch')}
disabled
style={{
flex: 1,
padding: '10px',
background: 'rgba(255,255,255,0.02)',
color: '#64748b',
border: '1px solid rgba(255,255,255,0.05)',
borderRadius: '8px',
fontSize: '14px',
fontWeight: '600',
cursor: 'not-allowed'
}}
>
📦 批量录入
</button>
</div>
</div>
{/* 内容区域 */}
{activeTab === 'ai' && (
<div style={{ padding: '16px' }}>
{/* 图片选择区域 */}
<div style={{
background: 'rgba(255,255,255,0.03)',
borderRadius: '12px',
padding: '24px',
textAlign: 'center',
marginBottom: '12px'
}}>
<input
ref={fileInputRef}
type="file"
accept="image/*"
capture="environment"
onChange={handleSelectImage}
style={{ display: 'none' }}
/>
{imagePreview ? (
<div>
<img
src={imagePreview}
alt="已选择图片"
style={{ maxWidth: '100%', borderRadius: '8px', marginBottom: '16px' }}
/>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'center' }}>
<button
onClick={() => { setSelectedImage(null); setImagePreview(null); }}
style={{
padding: '12px 24px',
background: 'rgba(239, 68, 68, 0.2)',
color: '#ef4444',
border: '1px solid #ef4444',
borderRadius: '8px',
fontSize: '14px',
cursor: 'pointer'
}}
>
🗑 重新选择
</button>
<button
onClick={handleRecognize}
disabled={recognizing}
style={{
padding: '12px 24px',
background: recognizing ? '#64748b' : '#fbbf24',
color: recognizing ? '#94a3b8' : '#1e293b',
border: 'none',
borderRadius: '8px',
fontSize: '14px',
fontWeight: '600',
cursor: recognizing ? 'not-allowed' : 'pointer'
}}
>
{recognizing ? '🔍 识别中...' : '🤖 开始识别'}
</button>
</div>
</div>
) : (
<div>
<div
onClick={() => {
fileInputRef.current.setAttribute('capture', '');
fileInputRef.current.setAttribute('accept', 'image/*');
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'
}}
>
<div style={{ fontSize: '40px', marginBottom: '12px' }}>📷</div>
<div style={{ color: '#60a5fa', fontSize: '15px', fontWeight: 'bold', marginBottom: '4px' }}>
拍照识别
</div>
<div style={{ color: '#94a3b8', fontSize: '13px' }}>
使用相机拍照并识别
</div>
</div>
<div
onClick={() => {
fileInputRef.current.removeAttribute('capture');
fileInputRef.current.setAttribute('accept', 'image/*');
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'
}}
>
<div style={{ fontSize: '40px', marginBottom: '12px' }}>🖼</div>
<div style={{ color: '#22c55e', fontSize: '15px', fontWeight: 'bold', marginBottom: '4px' }}>
从相册选择
</div>
<div style={{ color: '#94a3b8', fontSize: '13px' }}>
从相册选择已有图片
</div>
</div>
</div>
)}
</div>
{/* 错误提示 */}
{error && (
<div style={{
background: 'rgba(239, 68, 68, 0.2)',
border: '1px solid #ef4444',
color: '#ef4444',
padding: '12px',
borderRadius: '8px',
marginBottom: '12px'
}}>
{error}
</div>
)}
{/* 提示信息 */}
<div style={{
background: 'rgba(59, 130, 246, 0.1)',
border: '1px solid rgba(59, 130, 246, 0.3)',
borderRadius: '8px',
padding: '16px',
marginTop: '12px'
}}>
<div style={{ color: '#60a5fa', fontSize: '14px', fontWeight: 'bold', marginBottom: '8px' }}>💡 识别说明</div>
<ul style={{ color: '#94a3b8', fontSize: '13px', paddingLeft: '20px', margin: 0 }}>
<li>支持拍照或从相册选择图片</li>
<li>自动识别名称版别冠字序号等字段</li>
<li>识别结果可手动修改完善</li>
<li>建议拍摄清晰光线充足的正面照片</li>
</ul>
</div>
</div>
</div>
)
}
// 表单模式界面
return (
<div style={{ background: '#0f172a', minHeight: '100vh', paddingBottom: '80px' }}>
{/* 顶部 */}
<div style={{ padding: '16px', background: 'rgba(255,255,255,0.02)', borderBottom: '1px solid rgba(255,255,255,0.05)', display: 'flex', alignItems: 'center', gap: '12px' }}>
<button
onClick={() => setMode('camera')}
style={{
background: 'rgba(255,255,255,0.1)',
color: '#fff',
border: 'none',
borderRadius: '6px',
width: '36px',
height: '36px',
fontSize: '20px',
cursor: 'pointer'
}}
>
</button>
<div>
<div style={{ color: '#fff', fontSize: '18px', fontWeight: 'bold' }}>确认信息</div>
<div style={{ color: '#94a3b8', fontSize: '13px' }}>请检查并完善识别结果</div>
</div>
</div>
{error && (
<div style={{
margin: '16px',
background: 'rgba(239, 68, 68, 0.2)',
border: '1px solid #ef4444',
color: '#ef4444',
padding: '12px',
borderRadius: '8px'
}}>
{error}
</div>
)}
<div style={{ padding: '16px' }}>
{/* 基本信息 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
<div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold', marginBottom: '12px' }}>基本信息</div>
<Input form={form} handleChange={handleChange} label="名称 *" field="name" />
<Input form={form} handleChange={handleChange} label="版别 *" field="version" />
<Input form={form} handleChange={handleChange} label="冠字序号" field="prefixSerial" />
<Input form={form} handleChange={handleChange} label="面值" field="denomination" />
<Input form={form} handleChange={handleChange} label="材质" field="material" />
<Input form={form} handleChange={handleChange} label="发行方" field="issuer" />
<Input form={form} handleChange={handleChange} label="发行年份" field="issueYear" />
</div>
{/* 价格信息 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
<div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold', marginBottom: '12px' }}>价格信息</div>
<Input form={form} handleChange={handleChange} label="成本价" field="costPrice" type="number" />
<Input form={form} handleChange={handleChange} label="目标价" field="targetPrice" type="number" />
<Input form={form} handleChange={handleChange} label="出售价" field="goalPrice" type="number" />
</div>
{/* 备注 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
<div style={{ fontSize: '13px', color: '#94a3b8', marginBottom: '6px' }}>备注</div>
<textarea
value={form.remark || ''}
onChange={(e) => handleChange('remark', e.target.value)}
rows={3}
style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#fff', padding: '10px', borderRadius: '8px', width: '100%', resize: 'none' }}
/>
</div>
{/* 保存按钮 */}
<button
onClick={saving ? null : handleSave}
disabled={saving}
style={{
width: '100%',
padding: '14px',
background: saving ? '#64748b' : '#fbbf24',
color: saving ? '#94a3b8' : '#1e293b',
border: 'none',
borderRadius: '10px',
fontSize: '16px',
fontWeight: '600',
cursor: saving ? 'not-allowed' : 'pointer',
marginBottom: '12px'
}}
>
{saving ? '保存中...' : '✅ 保存藏品'}
</button>
<button
onClick={() => setMode('camera')}
style={{
width: '100%',
padding: '14px',
background: 'rgba(255,255,255,0.05)',
color: '#94a3b8',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '10px',
fontSize: '14px',
cursor: 'pointer'
}}
>
🔄 重新识别
</button>
</div>
</div>
)
}