v1.2.51 一尘看板完整版:添加YichenBoardFull组件,包含统计卡片、分类统计、搜索、筛选、分页、帖子展开等功能

This commit is contained in:
甲辰生产 2026-04-07 18:43:19 +08:00
parent e8c3069bfd
commit 25dab7dca8
3 changed files with 879 additions and 9 deletions

View File

@ -1 +1 @@
VERSION=1.2.50
VERSION=1.2.51

View File

@ -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 (
<div style={{ flex: 1, minWidth: '45%' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>{label}</div>
{options ? (
<select value={form[field] || ''} onChange={onChange}
style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#fff', padding: '8px', borderRadius: '6px', width: '100%', fontSize: '13px' }}>
<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}
style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#fff', padding: '8px', borderRadius: '6px', width: '100%', fontSize: '13px' }} />
)}
</div>
)
}
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 (
<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')}
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>
{error && (
<div style={{ margin: '16px', background: 'rgba(239, 68, 68, 0.2)', border: '1px solid #ef4444', color: '#ef4444', padding: '12px', borderRadius: '8px' }}> {error}</div>
)}
{/* AI 识别模式 */}
{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', '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' }}>
<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.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>
<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>
)}
{/* 手工录入模式 - 完整表单 */}
{activeTab === 'manual' && (
<div style={{ padding: '16px' }}>
{/* 图片上传区域 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
<div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold' }}>藏品图片 ({uploadImages.length}/1)</div>
</div>
<input ref={imageFileInputRef} type="file" accept="image/*" onChange={handleUploadImage} style={{ display: 'none' }} />
{uploadImages.length > 0 ? (
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${Math.min(uploadImages.length, 1)}, 1fr)`, gap: '12px' }}>
{uploadImages.map((img, index) => (
<div key={index} style={{ position: 'relative' }}>
<img src={img.preview} alt={img.name} style={{ width: '100%', aspectRatio: '1.5', objectFit: 'contain', borderRadius: '12px', border: '2px solid #10b981', background: 'rgba(0,0,0,0.3)' }} />
<div style={{
position: 'absolute',
bottom: '4px',
left: '4px',
right: '4px',
background: 'rgba(0,0,0,0.7)',
color: '#fff',
padding: '4px',
borderRadius: '4px',
fontSize: '10px',
textAlign: 'center',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>{img.name}</div>
<button
onClick={() => {
const newImages = uploadImages.filter((_, i) => i !== index)
setUploadImages(newImages)
}}
style={{
position: 'absolute',
top: '8px',
right: '8px',
width: '28px',
height: '28px',
borderRadius: '50%',
background: 'rgba(239, 68, 68, 0.9)',
color: '#fff',
border: 'none',
fontSize: '16px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>×</button>
<div style={{
position: 'absolute',
top: '8px',
left: '8px',
background: 'rgba(0,0,0,0.6)',
color: '#fff',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px'
}}>{index + 1}/{uploadImages.length}</div>
</div>
))}
{uploadImages.length < 1 && (
<div onClick={() => 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'
}}>
<div style={{ fontSize: '32px', marginBottom: '8px' }}>📷</div>
<div>添加图片</div>
<div style={{ fontSize: '11px', marginTop: '4px' }}>最多可上传 1 </div>
</div>
)}
</div>
) : (
<div onClick={() => 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'
}}>
<div style={{ fontSize: '48px', marginBottom: '8px' }}>📷</div>
<div>点击上传图片</div>
<div style={{ fontSize: '11px', marginTop: '4px', color: '#94a3b8' }}>最多可上传 1 </div>
</div>
)}
</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>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px' }}>
<Input form={form} handleChange={handleChange} label="名称 *" field="name" />
<Input form={form} handleChange={handleChange} label="编号(可选)" field="code" />
<Input form={form} handleChange={handleChange} label="持仓类型" field="category" options={categoryOptions} />
<Input form={form} handleChange={handleChange} label="珍惜度" field="rarity" options={rarityOptions} />
<Input form={form} handleChange={handleChange} label="冠字序号" field="prefixSerial" />
<Input form={form} handleChange={handleChange} label="版别 *" field="version" />
<Input form={form} handleChange={handleChange} label="面值" field="denomination" />
<Input form={form} handleChange={handleChange} label="状态" field="status" options={statusOptions} />
<Input form={form} handleChange={handleChange} label="包装" field="packaging" options={packagingOptions} />
</div>
</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>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px', alignItems: 'center', marginBottom: '12px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input type="checkbox" id="isGraded" checked={form.isGraded || false} onChange={(e) => handleChange('isGraded', e.target.checked)}
style={{ width: '18px', height: '18px', cursor: 'pointer', accentColor: '#22c55e' }} />
<label htmlFor="isGraded" style={{ color: '#fff', cursor: 'pointer', fontSize: '13px' }}>是否评级</label>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input type="checkbox" id="threeStar" checked={form.threeStar || false} onChange={(e) => handleChange('threeStar', e.target.checked)}
style={{ width: '18px', height: '18px', cursor: 'pointer', accentColor: '#22c55e' }} />
<label htmlFor="threeStar" style={{ color: '#fff', cursor: 'pointer', fontSize: '13px' }}>三星</label>
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px' }}>
<Input form={form} handleChange={handleChange} label="评级公司" field="gradingCompany" />
<Input form={form} handleChange={handleChange} label="评级分数" field="gradingScore" />
</div>
</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>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px' }}>
<Input form={form} handleChange={handleChange} label="特殊标识" field="specialMark" />
<Input form={form} handleChange={handleChange} label="号码特征" field="serialFeature" />
<Input form={form} handleChange={handleChange} label="号码分类" field="numberCategory" options={numberCategoryOptions} />
<Input form={form} handleChange={handleChange} label="发行方" field="issuer" />
<Input form={form} handleChange={handleChange} label="发行年份" field="issueYear" />
<Input form={form} handleChange={handleChange} label="材质" field="material" />
<Input form={form} handleChange={handleChange} label="发行量" field="issueQuantity" />
</div>
</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>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px' }}>
<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" />
<Input form={form} handleChange={handleChange} label="修复费" field="repairFee" type="number" />
<Input form={form} handleChange={handleChange} label="评级费" field="gradingFee" type="number" />
<Input form={form} handleChange={handleChange} label="用途" field="purpose" />
</div>
</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={() => setActiveTab('ai')}
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' }}>
🔄 切换到 AI 识别
</button>
</div>
)}
{/* 批量录入模式 */}
{activeTab === 'batch' && (
<div style={{ padding: '48px 16px', textAlign: 'center', color: '#94a3b8' }}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>🚧</div>
<div style={{ fontSize: '16px', fontWeight: 'bold', marginBottom: '8px' }}>批量录入开发中</div>
<div style={{ fontSize: '13px' }}>敬请期待后续版本</div>
</div>
)}
{/* 版本号 */}
<div style={{ position: 'fixed', bottom: '16px', right: '16px', color: 'rgba(255,255,255,0.2)', fontSize: '11px', zIndex: 100 }}>v{APP_VERSION}</div>
</div>
)
}

View File

@ -1,5 +1,4 @@
import React, { useState, useEffect } from 'react'
import YichensBoard from './YichensBoard'
// -
const getUserPhone = () => {
@ -73,11 +72,18 @@ export default function News() {
try {
//
const token = localStorage.getItem('token')
// tab
const type = activeTab === 'seek' ? 'seek' : activeTab === 'deal' ? 'deal' : 'yichen'
// token便matched_count
const headers = token ? { Authorization: `Bearer ${token}` } : {}
const res = await fetch(`${API_BASE}/api/information/list?info_type=${type}&page=${currentPage}&page_size=50`, { headers })
let url = ''
if (activeTab === 'yichen') {
// - API
url = '/api/yichens/posts?limit=390&offset=0'
} else {
const type = activeTab === 'seek' ? 'seek' : 'deal'
url = `${API_BASE}/api/information/list?info_type=${type}&page=${currentPage}&page_size=50`
}
const res = await fetch(url, { headers })
//
const total = res.headers.get('X-Total-Pages') || res.headers.get('x-total-pages')
if (total) setTotalPages(parseInt(total))
@ -478,7 +484,7 @@ export default function News() {
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{activeTab === 'yichen' ? <YichensBoard /> : infoList.map(item => {
{activeTab === 'yichen' ? <YichenBoardFull /> : infoList.map(item => {
//
const content = item.content || ''
const featuresMatch = content.match(/号码特征[:\s]*(.+?)(?:\n|$)/)
@ -489,14 +495,15 @@ export default function News() {
const cleanContent = content.replace(/价格[:\s]*.+?(\n|$)/g, '').replace(/号码特征[:\s]*.+?(\n|$)/g, '').replace(/联系方式[:\s]*.+?(\n|$)/g, '').trim()
return (
<div key={item.id} style={{ background: '#1e293b', borderRadius: '12px', padding: '16px' }}>
<div key={item.post_id || item.id} style={{ background: '#1e293b', borderRadius: '12px', padding: '16px' }}>
{/* 标题 */}
<div style={{ color: '#fbbf24', fontSize: '15px', fontWeight: '600', marginBottom: '8px' }}>
{item.title}
</div>
{/* 创建日期 + 用户名 */}
<div style={{ color: '#64748b', fontSize: '12px', marginBottom: '8px' }}>
📅 {formatDate(item.created_at)} &nbsp;|&nbsp; 👤 {(item.content && item.content.match(/发布者: (.+)/)?.[1]) || item.user_name || '匿名用户'}
📅 {item.post_time ? item.post_time.substring(0,16) : formatDate(item.created_at)} &nbsp;|&nbsp; 👤 {item.author_username || item.user_name || '匿名用户'}
{item.category && <span style={{ marginLeft: '8px', padding: '2px 6px', borderRadius: '4px', fontSize: '10px', background: 'rgba(139,92,246,0.3)', color: '#fff' }}>{item.category}</span>}
</div>
{/* 号码特征 */}
{features && (
@ -992,3 +999,193 @@ export default function News() {
</div>
)
}
// ==================== ====================
function YichenBoardFull() {
const [todayStats, setTodayStats] = useState({ total: 0, deals: 0, wants: 0, others: 0, dragons: 0, horses: 0, snakes: 0, tianma: 0 })
const [todayCategory, setTodayCategory] = useState([])
const [posts, setPosts] = useState([])
const [loading, setLoading] = useState(false)
const [expandedPosts, setExpandedPosts] = useState({})
const [postTypeFilter, setPostTypeFilter] = useState('all')
const [categoryFilter, setCategoryFilter] = useState('')
const [page, setPage] = useState(1)
const [totalPosts, setTotalPosts] = useState(0)
const [searchKeyword, setSearchKeyword] = useState('')
const today = new Date()
const dateStr = today.getFullYear() + '/' + (today.getMonth() + 1) + '/' + today.getDate()
useEffect(() => { fetchTodayStats(); fetchPosts(1, '') }, [])
useEffect(() => { if (todayStats.total > 0) fetchTodayCategory() }, [todayStats])
useEffect(() => { setPage(1); fetchPosts(1, categoryFilter) }, [postTypeFilter, categoryFilter, searchKeyword])
const fetchTodayStats = async () => {
try {
const res = await fetch('/api/yichens/stats/today')
setTodayStats(await res.json())
} catch(e) { console.error(e) }
}
const fetchTodayCategory = async () => {
try {
const res = await fetch('/api/yichens/stats/today-category')
setTodayCategory(await res.json())
} catch(e) { console.error(e) }
}
const fetchPosts = async (p, cat) => {
setLoading(true)
const currentPage = p !== undefined ? p : page
const currentCat = cat !== undefined ? cat : categoryFilter
let url = '/api/yichens/posts?limit=390&offset=' + ((currentPage - 1) * 390)
if (postTypeFilter === 'deal') url += '&post_type=deal'
else if (postTypeFilter === 'want') url += '&post_type=want'
else if (postTypeFilter === 'other') url += '&post_type=normal'
try {
const res = await fetch(url)
let data = await res.json() || []
if (currentCat) {
if (currentCat === '龙') data = data.filter(p => p.category && (p.category.includes('龙') || p.category.includes('纪念钞')))
else if (currentCat === '蛇') data = data.filter(p => p.category && p.category.includes('蛇'))
else if (currentCat === '马') data = data.filter(p => p.category && p.category.includes('马'))
else if (currentCat === '其他') data = data.filter(p => p.category && !p.category.includes('龙') && !p.category.includes('纪念钞') && !p.category.includes('蛇') && !p.category.includes('马'))
}
if (searchKeyword) {
const kw = searchKeyword.trim()
if (kw) {
data = data.filter(p => (p.title && p.title.includes(kw)) || (p.content && p.content.includes(kw)) || (p.category && p.category.includes(kw)))
}
}
setPosts(data)
setTotalPosts(todayStats.total || 0)
} catch { setPosts([]) }
setLoading(false)
}
const toggleExpand = (postId) => setExpandedPosts(prev => ({...prev, [postId]: !prev[postId]}))
const StatCard = ({ label, value, color }) => (
<div style={{ background: 'linear-gradient(145deg, #1f2937 0%, #111827 100%)', borderRadius: 12, padding: '12px 8px', border: '1px solid #374151', textAlign: 'center' }}>
<div style={{ color: '#9ca3af', fontSize: 11, marginBottom: 4 }}>{label}</div>
<div style={{ color: color || '#fff', fontSize: 20, fontWeight: 'bold' }}>{value}</div>
</div>
)
return (
<div style={{ padding: '0' }}>
{/* 统计卡片 */}
<div style={{ marginBottom: 16 }}>
<div style={{ color: '#f9fafb', fontSize: 14, fontWeight: 600, marginBottom: 10 }}>
📈 连体钞/纪念钞 <span style={{ color: '#9ca3af', fontWeight: 400 }}>{dateStr}</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 8 }}>
<StatCard label="全部" value={todayStats.total} color="#3b82f6" />
<StatCard label="出售" value={todayStats.deals} color="#10b981" />
<StatCard label="求购" value={todayStats.wants} color="#f59e0b" />
<StatCard label="其他" value={todayStats.others || 0} color="#8b5cf6" />
</div>
</div>
{/* 分类统计 */}
{todayCategory.length > 0 && (
<div style={{ background: 'linear-gradient(145deg, #1f2937 0%, #111827 100%)', borderRadius: 12, padding: 12, marginBottom: 20, border: '1px solid #374151' }}>
<div style={{ color: '#f9fafb', fontSize: 14, fontWeight: 600, marginBottom: 8 }}>📊 今日分类统计</div>
<div style={{ display: 'flex', gap: 6, overflowX: 'auto', paddingBottom: 4 }}>
{todayCategory.map(cat => (
<span key={cat.category} style={{ padding: '4px 10px', background: 'rgba(59,130,246,0.2)', borderRadius: 16, color: '#93c5fd', fontSize: 11, whiteSpace: 'nowrap' }}>
{cat.category} ({cat.count})
</span>
))}
</div>
</div>
)}
{/* 搜索框 */}
<div style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', gap: 8 }}>
<input type="text" placeholder="搜索标题、内容、分类..." value={searchKeyword} onChange={e => setSearchKeyword(e.target.value)}
style={{ flex: 1, padding: '10px 14px', borderRadius: 8, border: '1px solid #374151', background: '#1f2937', color: '#fff', fontSize: 13, outline: 'none' }} />
<button onClick={() => { setSearchKeyword(''); fetchPosts(1, categoryFilter) }}
style={{ padding: '10px 16px', borderRadius: 8, border: 'none', background: '#4b5563', color: '#fff', cursor: 'pointer', fontSize: 13 }}>清空</button>
</div>
{searchKeyword && <div style={{ color: '#9ca3af', fontSize: 12, marginTop: 6 }}>搜索: "{searchKeyword}"找到 {posts.length} 条结果</div>}
</div>
{/* 类型筛选 */}
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
{[{ key: 'all', label: '全部' }, { key: 'deal', label: '出售' }, { key: 'want', label: '求购' }, { key: 'other', label: '其他' }].map(item => (
<button key={item.key} onClick={() => setPostTypeFilter(item.key === 'all' ? 'all' : item.key)}
style={{ padding: '8px 16px', borderRadius: 8, border: 'none', background: postTypeFilter === (item.key === 'all' ? 'all' : item.key) ? 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)' : '#374151', color: '#fff', cursor: 'pointer', fontSize: 13 }}>
{item.label}
</button>
))}
</div>
{/* 分类筛选 */}
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
{[{ key: '', label: '全部' }, { key: '龙', label: '龙钞' }, { key: '蛇', label: '蛇钞' }, { key: '马', label: '马钞' }, { key: '其他', label: '其他' }].map(item => (
<button key={item.key} onClick={() => setCategoryFilter(item.key)}
style={{ padding: '8px 16px', borderRadius: 8, border: 'none', background: categoryFilter === item.key ? 'linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)' : '#374151', color: '#fff', cursor: 'pointer', fontSize: 13 }}>
{item.label}
</button>
))}
</div>
{/* 帖子列表 */}
{loading ? (
<div style={{ textAlign: 'center', color: '#9ca3af', padding: 40 }}>加载中...</div>
) : posts.length === 0 ? (
<div style={{ textAlign: 'center', color: '#9ca3af', padding: 40 }}>暂无数据</div>
) : (
<div>
{posts.map(item => (
<div key={item.post_id} style={{ background: 'linear-gradient(145deg, #1f2937 0%, #111827 100%)', borderRadius: 12, padding: 16, marginBottom: 12, border: '1px solid #374151' }}>
<div onClick={() => toggleExpand(item.post_id)} style={{ cursor: 'pointer' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<span style={{ color: '#fff', fontSize: 15, fontWeight: 500, flex: 1 }}>{item.title || '无标题'}</span>
<div style={{ display: 'flex', gap: 4 }}>
<span style={{ color: item.post_type === 'deal' ? '#10b981' : item.post_type === 'want' ? '#f59e0b' : '#8b5cf6', fontSize: 12 }}>
{item.post_type === 'deal' ? '出售' : item.post_type === 'want' ? '求购' : '其他'}
</span>
<span style={{ padding: '2px 8px', borderRadius: 4, fontSize: 11, background: item.category?.includes('龙') ? 'rgba(251,191,36,0.4)' : item.category?.includes('马') ? 'rgba(180,83,9,0.4)' : item.category?.includes('蛇') ? 'rgba(249,168,212,0.4)' : 'rgba(139,92,246,0.4)', color: item.category?.includes('龙') ? '#fde047' : item.category?.includes('马') ? '#d97706' : item.category?.includes('蛇') ? '#fbcfe8' : '#c4b5fd' }}>
{item.category || '-'}
</span>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', color: '#9ca3af', fontSize: 12 }}>
<span>{item.author_username || '未知'}</span>
<span>{item.post_time?.substring(0, 16) || ''}</span>
</div>
</div>
{expandedPosts[item.post_id] && item.content && (
<div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid #374151' }}>
<div style={{ color: '#e2e8f0', fontSize: 13, lineHeight: 1.6, whiteSpace: 'pre-wrap', wordBreak: 'break-word', maxHeight: 300, overflowY: 'auto' }}>{item.content}</div>
{item.url && (
<a href={item.url} target="_blank" rel="noopener noreferrer" style={{ display: 'inline-block', marginTop: 12, padding: '8px 16px', background: 'rgba(59,130,246,0.2)', borderRadius: 8, color: '#60a5fa', textDecoration: 'none', fontSize: 13 }}>查看原帖 </a>
)}
</div>
)}
</div>
))}
{/* 分页 */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', background: '#1e293b', borderRadius: 12, marginTop: 16 }}>
<div style={{ color: '#9ca3af', fontSize: 12 }}> {totalPosts} 条帖子{Math.ceil(totalPosts/390)} 当前第 {page} </div>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={() => { if (page > 1) { setPage(page - 1); fetchPosts(page - 1, categoryFilter) } }} disabled={page === 1}
style={{ padding: '6px 12px', borderRadius: 6, border: 'none', background: page === 1 ? '#374151' : '#3b82f6', color: page === 1 ? '#6b7280' : '#fff', cursor: page === 1 ? 'not-allowed' : 'pointer', fontSize: 12 }}>上一页</button>
<button onClick={() => { if (posts.length >= 390) { setPage(page + 1); fetchPosts(page + 1, categoryFilter) } }} disabled={posts.length < 390}
style={{ padding: '6px 12px', borderRadius: 6, border: 'none', background: posts.length < 390 ? '#374151' : '#3b82f6', color: posts.length < 390 ? '#6b7280' : '#fff', cursor: posts.length < 390 ? 'not-allowed' : 'pointer', fontSize: 12 }}>下一页</button>
</div>
</div>
</div>
)}
</div>
)
}