enterprise-saa-s-dashboard-.../src/app/materials/register/page.tsx

331 lines
18 KiB
TypeScript
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.

'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useRef } from 'react'
import { ArrowLeft, Plus, Trash2, Upload, Info, CheckCircle, FileText, X, ScanLine } from 'lucide-react'
/** 板卡类型 -> 可选版本 */
const versionsByType: Record<string, { version: string; firmware: string }[]> = {
'主协板': [
{ version: 'MB-V1.2', firmware: 'v2.1' },
{ version: 'MB-V2.1', firmware: 'v1.8' },
],
'采集板': [
{ version: 'RX-V1.3', firmware: 'v3.0' },
{ version: 'RX-V2.1', firmware: 'v2.5' },
],
'发射板': [
{ version: 'TX-V1.5', firmware: 'v1.2' },
{ version: 'TX-V2.1', firmware: 'v1.0' },
],
'升压板': [
{ version: 'BO-V2.1', firmware: 'v1.1' },
{ version: 'BO-V2.2', firmware: 'v0.9' },
],
'电缆头': [
{ version: 'CBH-V1.0', firmware: '-' },
],
'电缆': [
{ version: 'CBL-30M', firmware: '-' },
{ version: 'CBL-60M', firmware: '-' },
{ version: 'CBL-100M', firmware: '-' },
],
'机箱': [
{ version: 'GD30-CASE-A', firmware: '-' },
{ version: 'GD30-CASE-B', firmware: '-' },
{ version: 'GM10-CASE-A', firmware: '-' },
],
'电源': [
{ version: 'BP150-V1.0', firmware: '-' },
{ version: 'BP300-V1.0', firmware: '-' },
{ version: 'BP600-V1.0', firmware: '-' },
{ version: 'BP600-V2.0', firmware: '-' },
],
}
const typeOptions = Object.keys(versionsByType)
interface BoardEntry {
id: number
type: string
version: string
firmware: string
sn: string
productionDate: string
remark: string
calibFile: File | null
}
let nextId = 1
export default function BoardRegisterPage() {
const router = useRouter()
const [entries, setEntries] = useState<BoardEntry[]>([createEntry()])
const [batchMode, setBatchMode] = useState(false)
function createEntry(): BoardEntry {
return { id: nextId++, type: '采集板', version: 'RX-V2.1', firmware: 'v2.1', sn: '', productionDate: '', remark: '', calibFile: null }
}
const addEntry = () => {
setEntries(prev => [...prev, createEntry()])
}
const removeEntry = (id: number) => {
if (entries.length <= 1) return
setEntries(prev => prev.filter(e => e.id !== id))
}
const updateEntry = (id: number, field: keyof BoardEntry, value: string) => {
setEntries(prev => prev.map(e => {
if (e.id !== id) return e
const updated = { ...e, [field]: value }
// 切换类型时自动选第一个版本和固件
if (field === 'type') {
const versions = versionsByType[value]
if (versions && versions.length > 0) {
updated.version = versions[0].version
updated.firmware = versions[0].firmware
}
// 切换到非采集板时清除校准文件
if (value !== '采集板') updated.calibFile = null
}
// 切换版本时自动填充固件
if (field === 'version') {
const versions = versionsByType[updated.type]
const match = versions?.find(m => m.version === value)
if (match) updated.firmware = match.firmware
}
return updated
}))
}
const fileInputRefs = useRef<Record<number, HTMLInputElement | null>>({})
const handleCalibFileChange = (entryId: number, file: File | null) => {
setEntries(prev => prev.map(e => e.id === entryId ? { ...e, calibFile: file } : e))
}
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
const isValid = entries.every(e => e.sn.trim() && e.productionDate)
return (
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
<div style={{ flex: 1, padding: 24, paddingBottom: 80 }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
<button onClick={() => router.push('/materials')} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 32, height: 32, border: '1px solid #D9D9D9', borderRadius: 6, backgroundColor: '#fff', cursor: 'pointer' }}>
<ArrowLeft size={16} />
</button>
<div>
<h2 style={{ fontSize: 20, fontWeight: 600, margin: 0 }}></h2>
<p style={{ fontSize: 14, color: 'rgba(0,0,0,0.45)', margin: '4px 0 0' }}></p>
</div>
</div>
{/* Info Banner */}
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12, padding: 16, backgroundColor: '#eef5f0', borderRadius: 8, marginBottom: 24, border: '1px solid #a3c4ad' }}>
<Info size={18} style={{ color: '#4a7c59', flexShrink: 0, marginTop: 2 }} />
<div style={{ fontSize: 14, color: '#4a7c59', lineHeight: 1.6 }}>
SN号为唯一标识
</div>
</div>
{/* Mode Toggle */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
<span style={{ fontSize: 14, color: 'rgba(0,0,0,0.65)' }}></span>
<div style={{ display: 'flex', gap: 0, borderRadius: 6, overflow: 'hidden', border: '1px solid #D9D9D9' }}>
<button onClick={() => setBatchMode(false)} style={{ padding: '6px 16px', fontSize: 13, border: 'none', cursor: 'pointer', backgroundColor: !batchMode ? '#4a7c59' : '#fff', color: !batchMode ? '#fff' : 'rgba(0,0,0,0.65)' }}></button>
<button onClick={() => setBatchMode(true)} style={{ padding: '6px 16px', fontSize: 13, border: 'none', cursor: 'pointer', backgroundColor: batchMode ? '#4a7c59' : '#fff', color: batchMode ? '#fff' : 'rgba(0,0,0,0.65)', borderLeft: '1px solid #D9D9D9' }}></button>
</div>
{batchMode && (
<span style={{ fontSize: 12, color: 'rgba(0,0,0,0.45)' }}> {entries.length} </span>
)}
</div>
{/* Entry Cards */}
{entries.map((entry, idx) => (
<div key={entry.id} style={{ backgroundColor: '#fff', borderRadius: 8, padding: 24, marginBottom: 16, boxShadow: '0 1px 2px rgba(0,0,0,0.05)', border: '1px solid #F0F0F0' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<h3 style={{ fontSize: 15, fontWeight: 600, margin: 0, color: 'rgba(0,0,0,0.85)' }}>
{batchMode ? `物料 #${idx + 1}` : '物料信息'}
</h3>
{batchMode && entries.length > 1 && (
<button onClick={() => removeEntry(entry.id)} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '4px 12px', border: '1px solid #FFCCC7', borderRadius: 6, backgroundColor: '#FFF1F0', color: '#FF4D4F', cursor: 'pointer', fontSize: 13 }}>
<Trash2 size={13} />
</button>
)}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 20 }}>
{/* 板卡类型 */}
<div>
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 6 }}><span style={{ color: '#FF4D4F' }}>*</span> </label>
<select value={entry.type} onChange={e => updateEntry(entry.id, 'type', e.target.value)} style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14 }}>
{typeOptions.map(t => <option key={t} value={t}>{t}</option>)}
</select>
</div>
{/* 板卡版本 */}
<div>
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 6 }}><span style={{ color: '#FF4D4F' }}>*</span> </label>
<select value={entry.version} onChange={e => updateEntry(entry.id, 'version', e.target.value)} style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14 }}>
{(versionsByType[entry.type] || []).map(m => <option key={m.version} value={m.version}>{m.version}</option>)}
</select>
</div>
{/* 固件版本(自动填充,只读) */}
<div>
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 6 }}></label>
<input value={entry.firmware} readOnly style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14, backgroundColor: '#FAFAFA', color: 'rgba(0,0,0,0.65)', boxSizing: 'border-box' }} />
</div>
{/* 物料SN */}
<div>
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 6 }}><span style={{ color: '#FF4D4F' }}>*</span> SN号</label>
<div style={{ display: 'flex', gap: 6 }}>
<input value={entry.sn} onChange={e => updateEntry(entry.id, 'sn', e.target.value)} placeholder={`扫码或手动输入 如 ${entry.version}-20250401001`} style={{ flex: 1, padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14, boxSizing: 'border-box' }} />
<button title="扫码录入SN" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 36, height: 36, border: '1px solid #D9D9D9', borderRadius: 6, backgroundColor: '#fff', cursor: 'pointer', flexShrink: 0 }}>
<ScanLine size={16} style={{ color: '#4a7c59' }} />
</button>
</div>
</div>
{/* 生产日期 */}
<div>
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 6 }}><span style={{ color: '#FF4D4F' }}>*</span> </label>
<input type="date" value={entry.productionDate} onChange={e => updateEntry(entry.id, 'productionDate', e.target.value)} style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14, boxSizing: 'border-box' }} />
</div>
{/* 备注 */}
<div>
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 6 }}></label>
<input value={entry.remark} onChange={e => updateEntry(entry.id, 'remark', e.target.value)} placeholder="可选" style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14, boxSizing: 'border-box' }} />
</div>
</div>
{/* 采集板校准提示 */}
{entry.type === '采集板' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 16, padding: '8px 12px', backgroundColor: '#FFFBE6', borderRadius: 6, border: '1px solid #FFE58F' }}>
<Info size={14} style={{ color: '#FAAD14', flexShrink: 0 }} />
<span style={{ fontSize: 12, color: 'rgba(0,0,0,0.65)' }}>"待校准"</span>
</div>
)}
{/* 采集板校准文件导入 */}
{entry.type === '采集板' && (
<div style={{ marginTop: 16 }}>
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 8 }}></label>
<input
type="file"
accept=".csv,.xlsx,.xls,.dat"
ref={el => { fileInputRefs.current[entry.id] = el }}
onChange={e => handleCalibFileChange(entry.id, e.target.files?.[0] || null)}
style={{ display: 'none' }}
/>
{entry.calibFile ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', backgroundColor: '#eef5f0', borderRadius: 6, border: '1px solid #a3c4ad' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<FileText size={16} style={{ color: '#4a7c59', flexShrink: 0 }} />
<div>
<div style={{ fontSize: 13, fontWeight: 500, color: 'rgba(0,0,0,0.85)' }}>{entry.calibFile.name}</div>
<div style={{ fontSize: 12, color: 'rgba(0,0,0,0.45)' }}>{formatFileSize(entry.calibFile.size)}</div>
</div>
</div>
<button onClick={() => { handleCalibFileChange(entry.id, null); if (fileInputRefs.current[entry.id]) fileInputRefs.current[entry.id]!.value = '' }} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 24, height: 24, border: 'none', background: 'none', cursor: 'pointer', color: 'rgba(0,0,0,0.45)', borderRadius: 4 }}>
<X size={14} />
</button>
</div>
) : (
<button onClick={() => fileInputRefs.current[entry.id]?.click()} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, width: '100%', padding: '14px 0', border: '2px dashed #D9D9D9', borderRadius: 6, backgroundColor: '#FAFAFA', cursor: 'pointer', fontSize: 13, color: 'rgba(0,0,0,0.45)' }}>
<Upload size={16} /> .csv / .xlsx / .dat
</button>
)}
</div>
)}
</div>
))}
{/* Add More Button (batch mode) */}
{batchMode && (
<button onClick={addEntry} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, width: '100%', padding: '12px 0', border: '2px dashed #D9D9D9', borderRadius: 8, backgroundColor: '#FAFAFA', cursor: 'pointer', fontSize: 14, color: 'rgba(0,0,0,0.45)', marginBottom: 16 }}>
<Plus size={16} />
</button>
)}
{/* Preview Summary */}
{entries.length > 0 && (
<div style={{ backgroundColor: '#fff', borderRadius: 8, padding: 20, boxShadow: '0 1px 2px rgba(0,0,0,0.05)', border: '1px solid #F0F0F0' }}>
<h3 style={{ fontSize: 15, fontWeight: 600, margin: '0 0 16px', color: 'rgba(0,0,0,0.85)' }}></h3>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ backgroundColor: '#FAFAFA' }}>
{['序号', '类型', '版本', '固件', 'SN号', '生产日期', '校准文件', '状态'].map(h => (
<th key={h} style={{ padding: '10px 14px', textAlign: 'left', fontSize: 13, fontWeight: 600, color: 'rgba(0,0,0,0.85)', borderBottom: '1px solid #F0F0F0' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{entries.map((entry, i) => (
<tr key={entry.id} style={{ borderBottom: '1px solid #F0F0F0' }}>
<td style={{ padding: '10px 14px', fontSize: 13, color: 'rgba(0,0,0,0.45)' }}>{i + 1}</td>
<td style={{ padding: '10px 14px', fontSize: 13 }}>{entry.type}</td>
<td style={{ padding: '10px 14px', fontSize: 13 }}>{entry.version}</td>
<td style={{ padding: '10px 14px', fontSize: 13, color: 'rgba(0,0,0,0.65)' }}>{entry.firmware}</td>
<td style={{ padding: '10px 14px', fontSize: 13, fontWeight: 500, color: entry.sn ? 'rgba(0,0,0,0.85)' : 'rgba(0,0,0,0.25)' }}>{entry.sn || '未填写'}</td>
<td style={{ padding: '10px 14px', fontSize: 13, color: entry.productionDate ? 'rgba(0,0,0,0.65)' : 'rgba(0,0,0,0.25)' }}>{entry.productionDate || '未填写'}</td>
<td style={{ padding: '10px 14px', fontSize: 13 }}>
{entry.type === '采集板' ? (
entry.calibFile ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, color: '#4a7c59', fontSize: 12 }}>
<FileText size={12} />{entry.calibFile.name}
</span>
) : (
<span style={{ fontSize: 12, color: 'rgba(0,0,0,0.25)' }}></span>
)
) : (
<span style={{ fontSize: 12, color: 'rgba(0,0,0,0.25)' }}>-</span>
)}
</td>
<td style={{ padding: '10px 14px' }}>
<span style={{ padding: '2px 8px', borderRadius: 4, fontSize: 12, ...(entry.type === '采集板' ? { backgroundColor: '#FFFBE6', color: '#FAAD14', border: '1px solid #FFE58F' } : { backgroundColor: '#E6F7FF', color: '#1890FF', border: '1px solid #91D5FF' }) }}>
{entry.type === '采集板' ? '待校准' : '在库'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Sticky Bottom Bar */}
<div style={{ position: 'sticky', bottom: 0, backgroundColor: '#fff', borderTop: '1px solid #F0F0F0', padding: '12px 24px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', zIndex: 10 }}>
<span style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)' }}>
{entries.length}
{entries.some(e => e.type === '采集板') && <span style={{ color: '#FAAD14' }}> · </span>}
{entries.some(e => e.type === '采集板' && e.calibFile) && <span style={{ color: '#4a7c59' }}> · {entries.filter(e => e.calibFile).length} </span>}
</span>
<div style={{ display: 'flex', gap: 12 }}>
<button onClick={() => router.push('/materials')} style={{ padding: '8px 24px', border: '1px solid #D9D9D9', borderRadius: 6, backgroundColor: '#fff', cursor: 'pointer', fontSize: 14 }}></button>
<button
onClick={() => router.push('/materials')}
disabled={!isValid}
style={{ padding: '8px 24px', border: 'none', borderRadius: 6, backgroundColor: isValid ? '#4a7c59' : '#D9D9D9', color: '#fff', cursor: isValid ? 'pointer' : 'not-allowed', fontSize: 14 }}
>
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<CheckCircle size={14} />
</span>
</button>
</div>
</div>
</div>
)
}