274 lines
16 KiB
TypeScript
274 lines
16 KiB
TypeScript
'use client'
|
||
import { useState, useMemo } from 'react'
|
||
import Link from 'next/link'
|
||
import { Download, Plus, ChevronLeft, ChevronRight, Monitor, Cpu, Wifi, Power, Tag, Trash2 } from 'lucide-react'
|
||
import { useApi } from '@/lib/hooks'
|
||
|
||
interface Device {
|
||
id: number; sn: string; model: string; type: string; status: string; firmware: string; production_date: string; customer: string; batch: string
|
||
}
|
||
|
||
/**
|
||
* 根据日期计算 ISO 周数,返回 "YYYY-WXX" 格式
|
||
* ISO 8601:每周从周一开始,包含该年第一个周四的那周为第1周
|
||
*/
|
||
function getYearWeek(dateStr: string): string {
|
||
const date = new Date(dateStr)
|
||
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()))
|
||
// 调整到最近的周四(ISO 周定义)
|
||
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7))
|
||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1))
|
||
const weekNo = Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7)
|
||
return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`
|
||
}
|
||
|
||
/**
|
||
* 根据年周字符串计算该周的起止日期范围(周一~周日),用于侧边栏展示
|
||
*/
|
||
function getWeekRange(yearWeek: string): string {
|
||
const [yearStr, weekStr] = yearWeek.split('-W')
|
||
const year = parseInt(yearStr, 10)
|
||
const week = parseInt(weekStr, 10)
|
||
// ISO 周:找到该年1月4日所在周的周一,再偏移到目标周
|
||
const jan4 = new Date(Date.UTC(year, 0, 4))
|
||
const dayOfWeek = jan4.getUTCDay() || 7
|
||
const monday = new Date(jan4.getTime())
|
||
monday.setUTCDate(jan4.getUTCDate() - dayOfWeek + 1 + (week - 1) * 7)
|
||
const sunday = new Date(monday.getTime())
|
||
sunday.setUTCDate(monday.getUTCDate() + 6)
|
||
const fmt = (d: Date) => `${String(d.getUTCMonth() + 1).padStart(2, '0')}.${String(d.getUTCDate()).padStart(2, '0')}`
|
||
return `${fmt(monday)}-${fmt(sunday)}`
|
||
}
|
||
|
||
const modelOptions = ['全部', 'GD-30 Supreme', 'GD-20', 'GD-10 Supreme', 'GM-10', 'GT-10', 'GP-10']
|
||
const statusOptions = ['全部', '已激活', '已出厂', '装配中']
|
||
|
||
function getStatusStyle(status: string) {
|
||
switch (status) {
|
||
case '已激活': return { backgroundColor: '#F6FFED', color: '#52C41A', border: '1px solid #B7EB8F' }
|
||
case '已出厂': return { backgroundColor: '#FFF7E6', color: '#FA8C16', border: '1px solid #FFD591' }
|
||
case '装配中': return { backgroundColor: '#eef5f0', color: '#4a7c59', border: '1px solid #a3c4ad' }
|
||
default: return { backgroundColor: '#FAFAFA', color: 'rgba(0,0,0,0.45)', border: '1px solid #D9D9D9' }
|
||
}
|
||
}
|
||
|
||
function getStatusIcon(status: string) {
|
||
switch (status) {
|
||
case '已激活': return <Wifi size={14} style={{ color: '#52C41A' }} />
|
||
case '已出厂': return <Monitor size={14} style={{ color: '#FA8C16' }} />
|
||
case '装配中': return <Cpu size={14} style={{ color: '#4a7c59' }} />
|
||
default: return null
|
||
}
|
||
}
|
||
|
||
export default function DevicesPage() {
|
||
const { data: devicesData, loading, refetch } = useApi<Device[]>('/api/devices', [])
|
||
const [filterModel, setFilterModel] = useState('全部')
|
||
const [filterStatus, setFilterStatus] = useState('全部')
|
||
const [filterDate, setFilterDate] = useState('')
|
||
const [searchText, setSearchText] = useState('')
|
||
const [selectedBatch, setSelectedBatch] = useState('全部')
|
||
const [currentPage, setCurrentPage] = useState(1)
|
||
const pageSize = 8
|
||
|
||
// 从数据中提取所有生产批次,按名称倒序排列,统计每个批次的设备数量,按年分组
|
||
const batchGroups = useMemo(() => {
|
||
const batchMap = new Map<string, number>()
|
||
devicesData.forEach(d => {
|
||
batchMap.set(d.batch, (batchMap.get(d.batch) || 0) + 1)
|
||
})
|
||
const sorted = Array.from(batchMap.entries())
|
||
.sort((a, b) => b[0].localeCompare(a[0]))
|
||
.map(([batch, count]) => {
|
||
const match = batch.match(/BATCH-(\d{4})/)
|
||
return { batch, count, year: match ? match[1] : '未知' }
|
||
})
|
||
|
||
// 按年分组
|
||
const groups = new Map<string, { batch: string; count: number }[]>()
|
||
sorted.forEach(item => {
|
||
const list = groups.get(item.year) || []
|
||
list.push({ batch: item.batch, count: item.count })
|
||
groups.set(item.year, list)
|
||
})
|
||
return groups
|
||
}, [devicesData])
|
||
|
||
const filtered = devicesData.filter(d => {
|
||
if (selectedBatch !== '全部' && d.batch !== selectedBatch) return false
|
||
if (filterModel !== '全部' && d.model !== filterModel) return false
|
||
if (filterStatus !== '全部' && d.status !== filterStatus) return false
|
||
if (filterDate && !d.production_date.startsWith(filterDate)) return false
|
||
if (searchText && !d.sn.toLowerCase().includes(searchText.toLowerCase())) return false
|
||
return true
|
||
})
|
||
const totalPages = Math.ceil(filtered.length / pageSize)
|
||
const paged = filtered.slice((currentPage - 1) * pageSize, currentPage * pageSize)
|
||
|
||
const handleBatchSelect = (batch: string) => {
|
||
setSelectedBatch(batch)
|
||
setCurrentPage(1)
|
||
}
|
||
|
||
if (loading) return <div style={{ padding: 24 }}>加载中...</div>
|
||
|
||
return (
|
||
<div style={{ padding: 24 }}>
|
||
{/* Header */}
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||
<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 style={{ display: 'flex', gap: 12 }}>
|
||
<button style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px', border: '1px solid #D9D9D9', borderRadius: 6, backgroundColor: '#fff', cursor: 'pointer', fontSize: 14 }}>
|
||
<Download size={16} />导出
|
||
</button>
|
||
<Link href="/registration" style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px', border: 'none', borderRadius: 6, backgroundColor: '#4a7c59', color: '#fff', fontSize: 14, textDecoration: 'none' }}>
|
||
<Plus size={16} />登记设备
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Filter */}
|
||
<div style={{ backgroundColor: '#fff', borderRadius: 8, padding: 20, marginBottom: 24, boxShadow: '0 1px 2px rgba(0,0,0,0.05)' }}>
|
||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 16 }}>
|
||
<div style={{ flex: 1 }}>
|
||
<label style={{ display: 'block', fontSize: 13, color: 'rgba(0,0,0,0.65)', marginBottom: 6 }}>设备型号</label>
|
||
<select value={filterModel} onChange={e => { setFilterModel(e.target.value); setCurrentPage(1) }} style={{ width: '100%', padding: '6px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14 }}>
|
||
{modelOptions.map(m => <option key={m} value={m}>{m}</option>)}
|
||
</select>
|
||
</div>
|
||
<div style={{ flex: 1 }}>
|
||
<label style={{ display: 'block', fontSize: 13, color: 'rgba(0,0,0,0.65)', marginBottom: 6 }}>设备状态</label>
|
||
<select value={filterStatus} onChange={e => { setFilterStatus(e.target.value); setCurrentPage(1) }} style={{ width: '100%', padding: '6px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14 }}>
|
||
{statusOptions.map(s => <option key={s} value={s}>{s}</option>)}
|
||
</select>
|
||
</div>
|
||
<div style={{ flex: 1 }}>
|
||
<label style={{ display: 'block', fontSize: 13, color: 'rgba(0,0,0,0.65)', marginBottom: 6 }}>生产日期</label>
|
||
<input type="date" value={filterDate} onChange={e => { setFilterDate(e.target.value); setCurrentPage(1) }} style={{ width: '100%', padding: '6px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14, boxSizing: 'border-box' }} />
|
||
</div>
|
||
<div style={{ flex: 1 }}>
|
||
<label style={{ display: 'block', fontSize: 13, color: 'rgba(0,0,0,0.65)', marginBottom: 6 }}>SN搜索</label>
|
||
<input type="text" value={searchText} onChange={e => { setSearchText(e.target.value); setCurrentPage(1) }} placeholder="输入设备SN号" style={{ width: '100%', padding: '6px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14, boxSizing: 'border-box' }} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Main content: Batch sidebar + Device cards */}
|
||
<div style={{ display: 'flex', gap: 20 }}>
|
||
{/* Batch sidebar */}
|
||
<div style={{ width: 200, flexShrink: 0, backgroundColor: '#fff', borderRadius: 8, boxShadow: '0 1px 2px rgba(0,0,0,0.05)', padding: 16, alignSelf: 'flex-start' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 16 }}>
|
||
<Tag size={15} style={{ color: '#4a7c59' }} />
|
||
<span style={{ fontSize: 14, fontWeight: 600, color: 'rgba(0,0,0,0.85)' }}>生产批次</span>
|
||
</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||
<button
|
||
onClick={() => handleBatchSelect('全部')}
|
||
style={{
|
||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||
padding: '8px 12px', borderRadius: 6, border: 'none', cursor: 'pointer', fontSize: 13, textAlign: 'left',
|
||
backgroundColor: selectedBatch === '全部' ? '#eef5f0' : 'transparent',
|
||
color: selectedBatch === '全部' ? '#4a7c59' : 'rgba(0,0,0,0.65)',
|
||
fontWeight: selectedBatch === '全部' ? 600 : 400,
|
||
}}
|
||
>
|
||
<span>全部批次</span>
|
||
<span style={{
|
||
fontSize: 12, padding: '1px 8px', borderRadius: 10,
|
||
backgroundColor: selectedBatch === '全部' ? '#4a7c59' : '#f0f0f0',
|
||
color: selectedBatch === '全部' ? '#fff' : 'rgba(0,0,0,0.45)',
|
||
}}>{devicesData.length}</span>
|
||
</button>
|
||
{Array.from(batchGroups.entries()).map(([year, batches]) => (
|
||
<div key={year}>
|
||
<div style={{ padding: '8px 12px 4px', fontSize: 12, fontWeight: 600, color: 'rgba(0,0,0,0.35)', letterSpacing: '0.5px' }}>{year} 年</div>
|
||
{batches.map(({ batch, count }) => (
|
||
<button
|
||
key={batch}
|
||
onClick={() => handleBatchSelect(batch)}
|
||
style={{
|
||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||
padding: '7px 12px', borderRadius: 6, border: 'none', cursor: 'pointer', fontSize: 13, textAlign: 'left', width: '100%',
|
||
backgroundColor: selectedBatch === batch ? '#eef5f0' : 'transparent',
|
||
color: selectedBatch === batch ? '#4a7c59' : 'rgba(0,0,0,0.65)',
|
||
fontWeight: selectedBatch === batch ? 600 : 400,
|
||
}}
|
||
>
|
||
<div>
|
||
<div>{batch}</div>
|
||
</div>
|
||
<span style={{
|
||
fontSize: 12, padding: '1px 8px', borderRadius: 10, flexShrink: 0,
|
||
backgroundColor: selectedBatch === batch ? '#4a7c59' : '#f0f0f0',
|
||
color: selectedBatch === batch ? '#fff' : 'rgba(0,0,0,0.45)',
|
||
}}>{count}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Right content area */}
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
{/* Device Cards */}
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 16, marginBottom: 24 }}>
|
||
{paged.map(device => (
|
||
<div key={device.id} style={{ backgroundColor: '#fff', borderRadius: 8, boxShadow: '0 1px 2px rgba(0,0,0,0.05)', overflow: 'hidden' }}>
|
||
<div style={{ padding: 20 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||
<span style={{ fontSize: 15, fontWeight: 600, color: 'rgba(0,0,0,0.85)' }}>{device.sn}</span>
|
||
</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||
{getStatusIcon(device.status)}
|
||
<span style={{ ...getStatusStyle(device.status), padding: '2px 8px', borderRadius: 4, fontSize: 12 }}>{device.status}</span>
|
||
<span style={{ fontSize: 11, padding: '2px 8px', borderRadius: 4, backgroundColor: '#f5f5f5', color: 'rgba(0,0,0,0.45)', border: '1px solid #e8e8e8' }}>{device.batch}</span>
|
||
</div>
|
||
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.65)', lineHeight: 2 }}>
|
||
<div>型号:{device.model} {device.type}</div>
|
||
<div>主机版本:{device.firmware}</div>
|
||
<div>生产批次:{device.batch}</div>
|
||
<div>生产日期:{device.production_date}</div>
|
||
</div>
|
||
</div>
|
||
<div style={{ display: 'flex', borderTop: '1px solid #F0F0F0' }}>
|
||
<Link href={`/devices/${device.sn}`} style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4, padding: '10px 0', fontSize: 13, color: '#4a7c59', textDecoration: 'none', cursor: 'pointer' }}>
|
||
详情
|
||
</Link>
|
||
<div style={{ width: 1, backgroundColor: '#F0F0F0' }} />
|
||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '6px 8px' }}>
|
||
<select value={device.status} onChange={async (e) => { await fetch('/api/devices', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: device.id, status: e.target.value }) }); refetch() }} style={{ border: 'none', background: 'none', fontSize: 13, color: '#4a7c59', cursor: 'pointer', outline: 'none' }}>
|
||
{['装配中', '已激活', '已出厂'].map(s => <option key={s} value={s}>{s}</option>)}
|
||
</select>
|
||
</div>
|
||
<div style={{ width: 1, backgroundColor: '#F0F0F0' }} />
|
||
<button onClick={async () => { await fetch('/api/devices', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: device.id }) }); refetch() }} style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4, padding: '10px 0', fontSize: 13, color: '#FF4D4F', border: 'none', background: 'none', cursor: 'pointer' }}>
|
||
<Trash2 size={13} />删除
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Pagination */}
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||
<span style={{ fontSize: 14, color: 'rgba(0,0,0,0.45)' }}>
|
||
显示 {filtered.length > 0 ? (currentPage - 1) * pageSize + 1 : 0}-{Math.min(currentPage * pageSize, filtered.length)} / 共 {filtered.length} 台
|
||
</span>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||
<button onClick={() => setCurrentPage(p => Math.max(1, p - 1))} disabled={currentPage === 1} style={{ padding: '4px 8px', border: '1px solid #D9D9D9', borderRadius: 4, backgroundColor: '#fff', cursor: currentPage === 1 ? 'not-allowed' : 'pointer', opacity: currentPage === 1 ? 0.4 : 1 }}><ChevronLeft size={16} /></button>
|
||
{Array.from({ length: totalPages }, (_, i) => (
|
||
<button key={i} onClick={() => setCurrentPage(i + 1)} style={{ width: 32, height: 32, borderRadius: 4, fontSize: 14, border: currentPage === i + 1 ? '1px solid #4a7c59' : '1px solid #D9D9D9', color: currentPage === i + 1 ? '#4a7c59' : 'rgba(0,0,0,0.65)', backgroundColor: currentPage === i + 1 ? '#eef5f0' : '#fff', cursor: 'pointer' }}>{i + 1}</button>
|
||
))}
|
||
<button onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))} disabled={currentPage === totalPages || totalPages === 0} style={{ padding: '4px 8px', border: '1px solid #D9D9D9', borderRadius: 4, backgroundColor: '#fff', cursor: currentPage === totalPages || totalPages === 0 ? 'not-allowed' : 'pointer', opacity: currentPage === totalPages || totalPages === 0 ? 0.4 : 1 }}><ChevronRight size={16} /></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|