enterprise-saa-s-dashboard-.../src/app/devices/page.tsx

274 lines
16 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, 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>
)
}