217 lines
10 KiB
TypeScript
217 lines
10 KiB
TypeScript
"use client"
|
|
import { useEffect, useMemo, useState } from 'react'
|
|
import Link from 'next/link'
|
|
|
|
type RecordItem = {
|
|
id: string
|
|
timestamp: string
|
|
sampleCount: number
|
|
duration: number
|
|
hasFit: boolean
|
|
fit?: { a: number; b: number }
|
|
note?: string
|
|
stats: {
|
|
codeMin: number
|
|
codeMax: number
|
|
codeAvg: number
|
|
forceMin?: number
|
|
forceMax?: number
|
|
forceAvg?: number
|
|
}
|
|
}
|
|
|
|
export default function Home() {
|
|
const [loading, setLoading] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [data, setData] = useState<RecordItem[]>([])
|
|
const [total, setTotal] = useState(0)
|
|
const [page, setPage] = useState(1)
|
|
const [pageSize, setPageSize] = useState(12)
|
|
const [hasFit, setHasFit] = useState<'all' | 'fit' | 'raw'>('all')
|
|
const [sortBy, setSortBy] = useState<'timestamp' | 'duration' | 'maxValue'>('timestamp')
|
|
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc')
|
|
const [startDate, setStartDate] = useState('')
|
|
const [endDate, setEndDate] = useState('')
|
|
const [editingNote, setEditingNote] = useState<string | null>(null)
|
|
const [noteText, setNoteText] = useState('')
|
|
|
|
const totalPages = useMemo(() => Math.max(1, Math.ceil(total / pageSize)), [total, pageSize])
|
|
|
|
async function handleDeleteRecord(id: string) {
|
|
if (!confirm('确定要删除这条记录吗?')) return
|
|
try {
|
|
const res = await fetch(`/api/records/${id}`, { method: 'DELETE' })
|
|
if (!res.ok) throw new Error('删除失败')
|
|
setData((prev) => prev.filter((r) => r.id !== id))
|
|
setTotal((prev) => prev - 1)
|
|
} catch (err) {
|
|
alert('删除失败')
|
|
}
|
|
}
|
|
|
|
async function handleSaveNote(id: string) {
|
|
try {
|
|
const res = await fetch(`/api/records/${id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ note: noteText }),
|
|
})
|
|
if (!res.ok) throw new Error('保存失败')
|
|
setData((prev) => prev.map((r) => (r.id === id ? { ...r, note: noteText } : r)))
|
|
setEditingNote(null)
|
|
setNoteText('')
|
|
} catch (err) {
|
|
alert('保存备注失败')
|
|
}
|
|
}
|
|
|
|
function startEditNote(id: string, currentNote?: string) {
|
|
setEditingNote(id)
|
|
setNoteText(currentNote || '')
|
|
}
|
|
|
|
function cancelEditNote() {
|
|
setEditingNote(null)
|
|
setNoteText('')
|
|
}
|
|
|
|
useEffect(() => {
|
|
const controller = new AbortController()
|
|
async function run() {
|
|
setLoading(true)
|
|
setError(null)
|
|
try {
|
|
const params = new URLSearchParams()
|
|
params.set('page', String(page))
|
|
params.set('pageSize', String(pageSize))
|
|
params.set('sortBy', sortBy)
|
|
params.set('sortOrder', sortOrder)
|
|
if (startDate) params.set('startDate', new Date(startDate).toISOString())
|
|
if (endDate) params.set('endDate', new Date(endDate).toISOString())
|
|
if (hasFit === 'fit') params.set('hasFit', 'true')
|
|
if (hasFit === 'raw') params.set('hasFit', 'false')
|
|
const res = await fetch(`/api/records?${params.toString()}`, { signal: controller.signal })
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
const j = await res.json()
|
|
setData(j.data)
|
|
setTotal(j.pagination.total)
|
|
} catch (e: any) {
|
|
if (e.name !== 'AbortError') setError(e.message || '加载失败')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
run()
|
|
return () => controller.abort()
|
|
}, [page, pageSize, hasFit, sortBy, sortOrder, startDate, endDate])
|
|
|
|
return (
|
|
<div className="min-h-screen bg-zinc-50 text-zinc-900 dark:bg-black dark:text-zinc-100">
|
|
<div className="mx-auto max-w-6xl p-4 sm:p-6">
|
|
<header className="mb-4 flex flex-col gap-3 sm:mb-6 sm:flex-row sm:items-end sm:justify-between">
|
|
<h1 className="text-2xl font-semibold">ESP32 数据采集系统</h1>
|
|
<div className="flex flex-wrap items-end gap-2">
|
|
<div className="flex items-center gap-2">
|
|
<label className="text-sm">开始</label>
|
|
<input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} className="rounded border border-zinc-300 bg-white px-2 py-1 text-sm dark:bg-zinc-900" />
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<label className="text-sm">结束</label>
|
|
<input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} className="rounded border border-zinc-300 bg-white px-2 py-1 text-sm dark:bg-zinc-900" />
|
|
</div>
|
|
<select value={hasFit} onChange={(e) => setHasFit(e.target.value as 'all' | 'fit' | 'raw')} className="rounded border border-zinc-300 bg-white px-2 py-1 text-sm dark:bg-zinc-900">
|
|
<option value="all">全部</option>
|
|
<option value="fit">仅已拟合</option>
|
|
<option value="raw">仅未拟合</option>
|
|
</select>
|
|
<select value={sortBy} onChange={(e) => setSortBy(e.target.value as 'timestamp' | 'duration' | 'maxValue')} className="rounded border border-zinc-300 bg-white px-2 py-1 text-sm dark:bg-zinc-900">
|
|
<option value="timestamp">按时间</option>
|
|
<option value="duration">按时长</option>
|
|
<option value="maxValue">按最大值</option>
|
|
</select>
|
|
<select value={sortOrder} onChange={(e) => setSortOrder(e.target.value as 'asc' | 'desc')} className="rounded border border-zinc-300 bg-white px-2 py-1 text-sm dark:bg-zinc-900">
|
|
<option value="desc">降序</option>
|
|
<option value="asc">升序</option>
|
|
</select>
|
|
<select value={pageSize} onChange={(e) => { setPageSize(parseInt(e.target.value)); setPage(1) }} className="rounded border border-zinc-300 bg-white px-2 py-1 text-sm dark:bg-zinc-900">
|
|
{[12, 20, 50, 100].map((n) => (
|
|
<option key={n} value={n}>{n}/页</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</header>
|
|
|
|
{error && <div className="mb-4 rounded bg-red-100 px-3 py-2 text-red-800">{error}</div>}
|
|
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
{loading && Array.from({ length: 6 }).map((_, i) => (
|
|
<div key={i} className="h-40 animate-pulse rounded-lg bg-zinc-200 dark:bg-zinc-800" />
|
|
))}
|
|
{!loading && data.map((r) => (
|
|
<div key={r.id} className="flex flex-col justify-between rounded-lg border border-zinc-200 bg-white p-4 shadow-sm transition hover:shadow-md dark:border-zinc-800 dark:bg-zinc-950">
|
|
<div>
|
|
<div className="flex items-center justify-between">
|
|
<div className="text-sm text-zinc-500">{new Date(r.timestamp).toLocaleString()}</div>
|
|
<span className={`rounded-full px-2 py-0.5 text-xs ${r.hasFit ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' : 'bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300'}`}>{r.hasFit ? '已拟合' : '未拟合'}</span>
|
|
</div>
|
|
<div className="mt-2 text-xl font-semibold">{r.sampleCount} 点 · {r.duration.toFixed(2)} s</div>
|
|
<div className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
|
{r.hasFit ? (
|
|
<>
|
|
力值: {r.stats.forceMin?.toFixed(2)} ~ {r.stats.forceMax?.toFixed(2)} mN · 均值 {r.stats.forceAvg?.toFixed(2)}
|
|
</>
|
|
) : (
|
|
<>
|
|
码值: {r.stats.codeMin} ~ {r.stats.codeMax} · 均值 {r.stats.codeAvg.toFixed(2)}
|
|
</>
|
|
)}
|
|
</div>
|
|
{editingNote === r.id ? (
|
|
<div className="mt-2 flex gap-2">
|
|
<input
|
|
type="text"
|
|
value={noteText}
|
|
onChange={(e) => setNoteText(e.target.value)}
|
|
placeholder="添加备注..."
|
|
className="flex-1 rounded border border-zinc-300 bg-white px-2 py-1 text-sm dark:border-zinc-700 dark:bg-zinc-900"
|
|
autoFocus
|
|
/>
|
|
<button onClick={() => handleSaveNote(r.id)} className="rounded bg-green-600 px-2 py-1 text-xs text-white">保存</button>
|
|
<button onClick={cancelEditNote} className="rounded bg-zinc-500 px-2 py-1 text-xs text-white">取消</button>
|
|
</div>
|
|
) : (
|
|
<div className="mt-2 min-h-[20px]">
|
|
{r.note ? (
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex-1 text-xs text-zinc-600 dark:text-zinc-400">{r.note}</div>
|
|
<button onClick={() => startEditNote(r.id, r.note)} className="text-xs text-blue-600 hover:underline dark:text-blue-400">编辑</button>
|
|
</div>
|
|
) : (
|
|
<button onClick={() => startEditNote(r.id)} className="text-xs text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300">+ 添加备注</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="mt-3 flex items-center justify-between">
|
|
{r.hasFit && r.fit && (
|
|
<div className="text-[11px] text-zinc-500">y = {r.fit.a.toFixed(4)}x + {r.fit.b.toFixed(3)}</div>
|
|
)}
|
|
<div className="ml-auto flex gap-2">
|
|
<Link href={`/records/${r.id}`} className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm text-white dark:bg-zinc-100 dark:text-black">查看</Link>
|
|
<button onClick={() => handleDeleteRecord(r.id)} className="rounded-md bg-red-600 px-3 py-1.5 text-sm text-white hover:bg-red-700">删除</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="mt-6 flex items-center justify-center gap-4">
|
|
<button className="rounded border px-3 py-1.5 disabled:opacity-50" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>上一页</button>
|
|
<div className="text-sm">{page} / {totalPages}</div>
|
|
<button className="rounded border px-3 py-1.5 disabled:opacity-50" onClick={() => setPage((p) => Math.min(totalPages, p + 1))} disabled={page >= totalPages}>下一页</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|