From ed6b3393d7d6a0e9bb5a3b988713591a92fbc48e Mon Sep 17 00:00:00 2001 From: feie9456 Date: Sat, 15 Nov 2025 15:47:11 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=87=E6=B3=A8=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/data/route.ts | 2 +- app/api/records/[id]/route.ts | 36 +++++++- app/api/records/route.ts | 2 + app/page.tsx | 77 +++++++++++++++-- app/records/[id]/page.tsx | 84 +++++++++++++++---- .../migration.sql | 2 + prisma/schema.prisma | 1 + 7 files changed, 177 insertions(+), 27 deletions(-) create mode 100644 prisma/migrations/20251115073152_add_note_field/migration.sql diff --git a/app/api/data/route.ts b/app/api/data/route.ts index 13d98e4..8f25c92 100644 --- a/app/api/data/route.ts +++ b/app/api/data/route.ts @@ -19,7 +19,7 @@ export async function POST(req: NextRequest) { return Response.json({ error: first?.message ?? 'invalid body' }, { status: 400 }) } const { code, fit, recStartMs, recEndMs } = parsed.data - if (code.length > 4096) { + if (code.length > 16384) { return Response.json({ error: 'payload too large' }, { status: 413 }) } diff --git a/app/api/records/[id]/route.ts b/app/api/records/[id]/route.ts index 17e1ca3..dd19b39 100644 --- a/app/api/records/[id]/route.ts +++ b/app/api/records/[id]/route.ts @@ -2,10 +2,11 @@ import { NextRequest } from 'next/server' import { prisma } from '@/src/lib/prisma' import { isAuthorizedDelete } from '@/src/lib/utils' -export async function GET(_req: NextRequest, { params }: { params: { id: string } }) { +export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params try { const rec = await prisma.recording.findUnique({ - where: { id: params.id }, + where: { id }, select: { id: true, timestamp: true, @@ -46,12 +47,13 @@ export async function GET(_req: NextRequest, { params }: { params: { id: string } } -export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) { +export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params try { if (!isAuthorizedDelete(req)) { return Response.json({ error: 'unauthorized' }, { status: 401 }) } - const rec = await prisma.recording.delete({ where: { id: params.id } }) + const rec = await prisma.recording.delete({ where: { id } }) return Response.json({ success: true, id: rec.id }) } catch (err: any) { if (err?.code === 'P2025') { @@ -61,3 +63,29 @@ export async function DELETE(req: NextRequest, { params }: { params: { id: strin return Response.json({ error: 'internal error' }, { status: 500 }) } } + +export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params + try { + const body = await req.json() + const { note } = body + + if (note !== undefined && typeof note !== 'string') { + return Response.json({ error: 'invalid note' }, { status: 400 }) + } + + const rec = await prisma.recording.update({ + where: { id }, + data: { note: note || null }, + select: { id: true, note: true }, + }) + + return Response.json({ success: true, id: rec.id, note: rec.note }) + } catch (err: any) { + if (err?.code === 'P2025') { + return Response.json({ error: 'not found' }, { status: 404 }) + } + console.error(err) + return Response.json({ error: 'internal error' }, { status: 500 }) + } +} diff --git a/app/api/records/route.ts b/app/api/records/route.ts index 7705133..ad05adf 100644 --- a/app/api/records/route.ts +++ b/app/api/records/route.ts @@ -54,6 +54,7 @@ export async function GET(req: NextRequest) { force_min: true, force_max: true, force_avg: true, + note: true, }, }), ]) @@ -65,6 +66,7 @@ export async function GET(req: NextRequest) { duration: r.duration, hasFit: r.fit_a !== null && r.fit_a !== undefined, fit: r.fit_a !== null && r.fit_a !== undefined ? { a: r.fit_a!, b: r.fit_b! } : undefined, + note: r.note ?? undefined, stats: { codeMin: r.code_min, codeMax: r.code_max, diff --git a/app/page.tsx b/app/page.tsx index 063f8e2..70bc900 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -9,6 +9,7 @@ type RecordItem = { duration: number hasFit: boolean fit?: { a: number; b: number } + note?: string stats: { codeMin: number codeMax: number @@ -31,9 +32,49 @@ export default function Home() { const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc') const [startDate, setStartDate] = useState('') const [endDate, setEndDate] = useState('') + const [editingNote, setEditingNote] = useState(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() { @@ -78,17 +119,17 @@ export default function Home() { setEndDate(e.target.value)} className="rounded border border-zinc-300 bg-white px-2 py-1 text-sm dark:bg-zinc-900" /> - 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"> - 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"> - 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"> @@ -107,7 +148,7 @@ export default function Home() {
))} {!loading && data.map((r) => ( -
+
{new Date(r.timestamp).toLocaleString()}
@@ -125,13 +166,39 @@ export default function Home() { )}
+ {editingNote === r.id ? ( +
+ 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 + /> + + +
+ ) : ( +
+ {r.note ? ( +
+
{r.note}
+ +
+ ) : ( + + )} +
+ )}
-
+
{r.hasFit && r.fit && (
y = {r.fit.a.toFixed(4)}x + {r.fit.b.toFixed(3)}
)}
查看 +
diff --git a/app/records/[id]/page.tsx b/app/records/[id]/page.tsx index 73b42ee..cbe5154 100644 --- a/app/records/[id]/page.tsx +++ b/app/records/[id]/page.tsx @@ -1,5 +1,5 @@ "use client" -import { useEffect, useMemo, useState } from 'react' +import { use, useEffect, useMemo, useState } from 'react' import Link from 'next/link' import { Chart as ChartJS, @@ -37,10 +37,13 @@ function toFixed(n: number | undefined | null, d = 3) { return n.toFixed(d) } -export default function Page({ params }: { params: { id: string } }) { +export default function Page({ params }: { params: Promise<{ id: string }> }) { + const { id } = use(params) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [rec, setRec] = useState(null) + const [emaAlpha, setEmaAlpha] = useState(0.2) + const [enableEma, setEnableEma] = useState(false) useEffect(() => { const controller = new AbortController() @@ -48,11 +51,12 @@ export default function Page({ params }: { params: { id: string } }) { setLoading(true) setError(null) try { - const res = await fetch(`/api/records/${params.id}`, { signal: controller.signal }) + const res = await fetch(`/api/records/${id}`, { signal: controller.signal }) if (!res.ok) throw new Error(`HTTP ${res.status}`) const j = await res.json() setRec(j) - } catch (e: any) { + } catch (e) { + // @ts-expect-error any if (e.name !== 'AbortError') setError(e.message || '加载失败') } finally { setLoading(false) @@ -60,21 +64,30 @@ export default function Page({ params }: { params: { id: string } }) { } run() return () => controller.abort() - }, [params.id]) + }, [id]) const timeAxis = useMemo(() => { if (!rec) return [] as number[] - const step = rec.sampleCount > 0 ? rec.duration / rec.sampleCount : 0 - return Array.from({ length: rec.sampleCount }, (_, i) => +(i * step).toFixed(6)) + const step = rec.sampleCount > 0 ? (rec.duration * 1000) / rec.sampleCount : 0 + return Array.from({ length: rec.sampleCount }, (_, i) => +(i * step).toFixed(3)) }, [rec]) + const smoothedCode = useMemo(() => { + if (!rec || !enableEma) return rec?.code || [] + return applyEMA(rec.code, emaAlpha) + }, [rec, enableEma, emaAlpha]) + const forceSeries = useMemo(() => { if (!rec?.fit) return undefined const { a, b } = rec.fit - return rec.code.map((c) => a * c + b) - }, [rec]) + const dataToUse = enableEma ? smoothedCode : rec.code + return dataToUse.map((c) => a * c + b) + }, [rec, enableEma, smoothedCode]) - const codeHistogram = useMemo(() => buildHistogram(rec?.code || [], 30), [rec]) + const codeHistogram = useMemo(() => { + const dataToUse = enableEma ? smoothedCode : (rec?.code || []) + return buildHistogram(dataToUse, 30) + }, [rec, enableEma, smoothedCode]) const forceHistogram = useMemo(() => buildHistogram(forceSeries || [], 30), [forceSeries]) return ( @@ -83,20 +96,20 @@ export default function Page({ params }: { params: { id: string } }) {
← 返回 -

记录 {rec?.id || params.id}

+

记录 {rec?.id || id}

{ e.preventDefault() - const body = JSON.stringify({ ids: [params.id], format: 'csv' as const }) + const body = JSON.stringify({ ids: [id], format: 'csv' as const }) const res = await fetch('/api/export', { method: 'POST', headers: { 'content-type': 'application/json' }, body }) const blob = await res.blob() const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url - a.download = `record-${params.id}.csv` + a.download = `record-${id}.csv` a.click() URL.revokeObjectURL(url) }} @@ -140,7 +153,35 @@ export default function Page({ params }: { params: { id: string } }) {
-
时序曲线
+
+
时序曲线
+
+ + {enableEma && ( + + )} +
+
({ x: c, y: forceSeries[i] })), + data: (enableEma ? smoothedCode : rec.code).map((c, i) => ({ x: c, y: forceSeries[i] })), backgroundColor: '#22c55e', }, ], @@ -235,6 +276,15 @@ export default function Page({ params }: { params: { id: string } }) { ) } +function applyEMA(data: number[], alpha: number): number[] { + if (data.length === 0) return [] + const result: number[] = [data[0]] + for (let i = 1; i < data.length; i++) { + result.push(alpha * data[i] + (1 - alpha) * result[i - 1]) + } + return result +} + function buildHistogram(values: number[], bins: number) { if (!values.length || bins <= 0) return { labels: [] as string[], counts: [] as number[] } let min = values[0] diff --git a/prisma/migrations/20251115073152_add_note_field/migration.sql b/prisma/migrations/20251115073152_add_note_field/migration.sql new file mode 100644 index 0000000..6735b77 --- /dev/null +++ b/prisma/migrations/20251115073152_add_note_field/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Recording" ADD COLUMN "note" VARCHAR(500); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f57c4ed..a14d5a3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -23,6 +23,7 @@ model Recording { force_min Float? force_max Float? force_avg Float? + note String? @db.VarChar(500) created_at DateTime @default(now()) @@index([timestamp(sort: Desc)])