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>
)
}