This commit is contained in:
2025-11-15 14:36:16 +08:00
parent 5cf0704d33
commit 03b2ce017a
15 changed files with 916 additions and 87 deletions
+81
View File
@@ -0,0 +1,81 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { prisma } from '@/src/lib/prisma'
import { applyFit, basicStats, genId, getSampleRateHz } from '@/src/lib/utils'
const DataSchema = z.object({
code: z.array(z.number().int()).min(1, 'code array empty').max(4096, 'too many points'),
fit: z.object({ a: z.number(), b: z.number() }).optional(),
recStartMs: z.number().int().nonnegative().optional(),
recEndMs: z.number().int().nonnegative().optional(),
})
export async function POST(req: NextRequest) {
try {
const json = await req.json()
const parsed = DataSchema.safeParse(json)
if (!parsed.success) {
const first = parsed.error.errors[0]
return Response.json({ error: first?.message ?? 'invalid body' }, { status: 400 })
}
const { code, fit, recStartMs, recEndMs } = parsed.data
if (code.length > 4096) {
return Response.json({ error: 'payload too large' }, { status: 413 })
}
const sampleCount = code.length
const { min: codeMin, max: codeMax, avg: codeAvg } = basicStats(code)
let forceMin: number | null = null
let forceMax: number | null = null
let forceAvg: number | null = null
if (fit) {
const forces = applyFit(code, fit.a, fit.b)
const s = basicStats(forces)
forceMin = s.min
forceMax = s.max
forceAvg = s.avg
}
let duration: number
if (
typeof recStartMs === 'number' &&
typeof recEndMs === 'number' &&
Number.isFinite(recStartMs) &&
Number.isFinite(recEndMs) &&
recEndMs > recStartMs
) {
duration = (recEndMs - recStartMs) / 1000
} else {
const sampleRate = getSampleRateHz()
duration = sampleCount / sampleRate
}
const ts = new Date()
const id = genId()
await prisma.recording.create({
data: {
id,
timestamp: ts,
sample_count: sampleCount,
duration,
code_data: code.map((n) => Math.trunc(n)),
rec_start_ms: typeof recStartMs === 'number' ? BigInt(Math.trunc(recStartMs)) : null,
rec_end_ms: typeof recEndMs === 'number' ? BigInt(Math.trunc(recEndMs)) : null,
fit_a: fit?.a ?? null,
fit_b: fit?.b ?? null,
code_min: codeMin,
code_max: codeMax,
code_avg: codeAvg,
force_min: forceMin,
force_max: forceMax,
force_avg: forceAvg,
} ,
})
return Response.json({ success: true, id, timestamp: ts.toISOString() })
} catch (err) {
console.error(err)
return Response.json({ error: 'internal error' }, { status: 500 })
}
}
+77
View File
@@ -0,0 +1,77 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { prisma } from '@/src/lib/prisma'
const ExportSchema = z.object({
ids: z.array(z.string()).min(1),
format: z.enum(['csv', 'json']),
})
export async function POST(req: NextRequest) {
try {
const body = await req.json()
const parsed = ExportSchema.safeParse(body)
if (!parsed.success) return Response.json({ error: 'invalid body' }, { status: 400 })
const { ids, format } = parsed.data
const rows = await prisma.recording.findMany({
where: { id: { in: ids } },
orderBy: { timestamp: 'asc' },
// Cast select to any to accommodate freshly added fields before prisma generate
select: {
id: true,
timestamp: true,
code_data: true,
fit_a: true,
fit_b: true,
sample_count: true,
duration: true,
rec_start_ms: true,
rec_end_ms: true,
} ,
})
if (format === 'json') {
const payload = rows.map((r) => ({
id: r.id,
timestamp: new Date(r.timestamp).toISOString(),
code: r.code_data,
fit: r.fit_a != null ? { a: r.fit_a, b: r.fit_b } : undefined,
sampleCount: r.sample_count,
duration: r.duration,
}))
return new Response(JSON.stringify(payload), {
headers: {
'content-type': 'application/json; charset=utf-8',
'content-disposition': `attachment; filename="export-${Date.now()}.json"`,
},
})
}
// CSV
let csv = 'recordId,timestamp,index,timeSec,code,force\n'
for (const r of rows) {
let step = r.sample_count > 0 ? r.duration / r.sample_count : 0
if (r.rec_start_ms != null && r.rec_end_ms != null && r.sample_count > 0) {
const dtMs = Number((r.rec_end_ms as bigint) - (r.rec_start_ms as bigint))
if (Number.isFinite(dtMs) && dtMs > 0) step = dtMs / 1000 / r.sample_count
}
const hasFit = r.fit_a != null
for (let i = 0; i < r.code_data.length; i++) {
const t = (i * step).toFixed(6)
const code = r.code_data[i]
const force = hasFit ? r.fit_a! * code + (r.fit_b ?? 0) : ''
csv += `${r.id},${new Date(r.timestamp).toISOString()},${i},${t},${code},${hasFit ? force : ''}\n`
}
}
return new Response(csv, {
headers: {
'content-type': 'text/csv; charset=utf-8',
'content-disposition': `attachment; filename="export-${Date.now()}.csv"`,
},
})
} catch (err) {
console.error(err)
return Response.json({ error: 'internal error' }, { status: 500 })
}
}
+63
View File
@@ -0,0 +1,63 @@
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 } }) {
try {
const rec = await prisma.recording.findUnique({
where: { id: params.id },
select: {
id: true,
timestamp: true,
code_data: true,
fit_a: true,
fit_b: true,
sample_count: true,
duration: true,
code_min: true,
code_max: true,
code_avg: true,
force_min: true,
force_max: true,
force_avg: true,
},
})
if (!rec) return Response.json({ error: 'not found' }, { status: 404 })
return Response.json({
id: rec.id,
timestamp: rec.timestamp.toISOString(),
code: rec.code_data,
fit: rec.fit_a != null ? { a: rec.fit_a, b: rec.fit_b } : undefined,
sampleCount: rec.sample_count,
duration: rec.duration,
stats: {
codeMin: rec.code_min,
codeMax: rec.code_max,
codeAvg: rec.code_avg,
forceMin: rec.force_min ?? undefined,
forceMax: rec.force_max ?? undefined,
forceAvg: rec.force_avg ?? undefined,
},
})
} catch (err) {
console.error(err)
return Response.json({ error: 'internal error' }, { status: 500 })
}
}
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
try {
if (!isAuthorizedDelete(req)) {
return Response.json({ error: 'unauthorized' }, { status: 401 })
}
const rec = await prisma.recording.delete({ where: { id: params.id } })
return Response.json({ success: true, id: rec.id })
} 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 })
}
}
+91
View File
@@ -0,0 +1,91 @@
import { NextRequest } from 'next/server'
import { prisma } from '@/src/lib/prisma'
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url)
const page = Math.max(1, parseInt(searchParams.get('page') || '1', 10) || 1)
const pageSizeRaw = parseInt(searchParams.get('pageSize') || '20', 10) || 20
const pageSize = Math.min(100, Math.max(1, pageSizeRaw))
const startDate = searchParams.get('startDate')
const endDate = searchParams.get('endDate')
const hasFit = ((): boolean | undefined => {
const v = searchParams.get('hasFit')
return v === null ? undefined : v === 'true'
})()
const sortBy = searchParams.get('sortBy') || 'timestamp' // timestamp | duration | maxValue
const sortOrder = (searchParams.get('sortOrder') === 'asc' ? 'asc' : 'desc') as 'asc' | 'desc'
const where: any = {}
if (startDate || endDate) {
where.timestamp = {}
if (startDate) where.timestamp.gte = new Date(startDate)
if (endDate) where.timestamp.lte = new Date(endDate)
}
if (hasFit === true) where.NOT = { fit_a: null }
if (hasFit === false) where.fit_a = null
let orderBy: any
if (sortBy === 'duration') orderBy = { duration: sortOrder }
else if (sortBy === 'maxValue') {
if (hasFit === true) orderBy = { force_max: sortOrder }
else orderBy = { code_max: sortOrder }
} else {
orderBy = { timestamp: sortOrder }
}
const [total, rows] = await Promise.all([
prisma.recording.count({ where }),
prisma.recording.findMany({
where,
orderBy,
skip: (page - 1) * pageSize,
take: pageSize,
select: {
id: true,
timestamp: true,
sample_count: true,
duration: true,
fit_a: true,
fit_b: true,
code_min: true,
code_max: true,
code_avg: true,
force_min: true,
force_max: true,
force_avg: true,
},
}),
])
const data = rows.map((r) => ({
id: r.id,
timestamp: r.timestamp.toISOString(),
sampleCount: r.sample_count,
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,
stats: {
codeMin: r.code_min,
codeMax: r.code_max,
codeAvg: r.code_avg,
forceMin: r.force_min ?? undefined,
forceMax: r.force_max ?? undefined,
forceAvg: r.force_avg ?? undefined,
},
}))
return Response.json({
data,
pagination: {
total,
page,
pageSize,
totalPages: Math.max(1, Math.ceil(total / pageSize)),
},
})
} catch (err) {
console.error(err)
return Response.json({ error: 'internal error' }, { status: 500 })
}
}