diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..1559c50 --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,254 @@ +"use client" +import { useEffect, useMemo, useState } from 'react' +import Link from 'next/link' + +type AdminRecordItem = { + id: string + timestamp: string + sampleCount: number +} + +function toLocalInputValue(iso: string): string { + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return '' + const pad = (n: number) => n.toString().padStart(2, '0') + const year = d.getFullYear() + const month = pad(d.getMonth() + 1) + const day = pad(d.getDate()) + const hour = pad(d.getHours()) + const minute = pad(d.getMinutes()) + const second = pad(d.getSeconds()) + return `${year}-${month}-${day}T${hour}:${minute}:${second}` +} + +export default function AdminPage() { + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [data, setData] = useState([]) + const [selected, setSelected] = useState>(new Set()) + const [page, setPage] = useState(1) + const [pageSize] = useState(50) + const [total, setTotal] = useState(0) + const [editingTimes, setEditingTimes] = useState>({}) + const [dirty, setDirty] = useState>({}) + + const totalPages = useMemo(() => Math.max(1, Math.ceil(total / pageSize)), [total, pageSize]) + + 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)) + 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() + const rows = j.data.map((r: any) => ({ id: r.id, timestamp: r.timestamp, sampleCount: r.sampleCount })) + setData(rows) + setTotal(j.pagination.total) + setSelected(new Set()) + setEditingTimes((prev) => { + const next: Record = {} + for (const r of rows) { + const isoLocal = toLocalInputValue(r.timestamp) + next[r.id] = prev[r.id] ?? isoLocal + } + return next + }) + setDirty({}) + } catch (e: any) { + if (e.name !== 'AbortError') setError(e.message || '加载失败') + } finally { + setLoading(false) + } + } + run() + return () => controller.abort() + }, [page, pageSize]) + + function toggleSelect(id: string) { + setSelected((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } + + function selectAllCurrent() { + setSelected(new Set(data.map((d) => d.id))) + } + + function clearSelection() { + setSelected(new Set()) + } + + async function handleBatchDelete() { + if (selected.size === 0) return + if (!confirm(`确定要删除选中的 ${selected.size} 条记录吗?`)) return + try { + const res = await fetch('/api/records/batch', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ action: 'delete', ids: Array.from(selected) }), + }) + if (!res.ok) throw new Error('批量删除失败') + const j = await res.json() + const deletedIds: string[] = j.deletedIds || [] + setData((prev) => prev.filter((r) => !deletedIds.includes(r.id))) + setTotal((prev) => Math.max(0, prev - deletedIds.length)) + clearSelection() + } catch { + alert('批量删除失败') + } + } + + function setNowFor(id: string) { + const now = new Date() + const isoLocal = toLocalInputValue(now.toISOString()) + setEditingTimes((prev) => ({ ...prev, [id]: isoLocal })) + setDirty((prev) => ({ ...prev, [id]: true })) + } + + function shiftMinutes(id: string, minutes: number) { + setEditingTimes((prev) => { + const current = prev[id] + const base = current ? new Date(current) : new Date() + if (Number.isNaN(base.getTime())) return prev + const shifted = new Date(base.getTime() + minutes * 60 * 1000) + const isoLocal = toLocalInputValue(shifted.toISOString()) + return { ...prev, [id]: isoLocal } + }) + setDirty((prev) => ({ ...prev, [id]: true })) + } + + async function applyTime(id: string) { + const input = editingTimes[id] + if (!input) { + alert('请输入时间') + return + } + const dt = new Date(input) + if (Number.isNaN(dt.getTime())) { + alert('时间格式不正确') + return + } + try { + const res = await fetch('/api/records/batch', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ action: 'updateTimestamp', ids: [id], timestamp: dt.toISOString() }), + }) + if (!res.ok) throw new Error('修改时间失败') + const j = await res.json() + const updated: { id: string; timestamp: string }[] = j.updated || [] + if (!updated.length) return + const { timestamp } = updated[0] + setData((prev) => prev.map((r) => (r.id === id ? { ...r, timestamp } : r))) + setEditingTimes((prev) => ({ ...prev, [id]: toLocalInputValue(timestamp) })) + setDirty((prev) => ({ ...prev, [id]: false })) + } catch { + alert('修改时间失败') + } + } + + return ( +
+
+
+
+

Admin 面板

+

批量删除记录,批量修改时间等维护操作

+
+ ← 返回首页 +
+ + {error &&
{error}
} + +
+
+ + + 已选 {selected.size} 条 +
+
+ +
+
+ +
+
+
+ 选中 + ID + 时间 / 编辑 + 采样点数 +
+
+ {loading && ( +
加载中...
+ )} + {!loading && data.length === 0 && ( +
暂无数据
+ )} +
+ {data.map((r) => ( +
+
+
+ toggleSelect(r.id)} + className="h-4 w-4" + /> + {r.id} +
+
+ {new Date(r.timestamp).toLocaleString()} + {r.sampleCount} +
+ 详情 +
+
+
+ { + const v = e.target.value + setEditingTimes((prev) => ({ ...prev, [r.id]: v })) + const original = toLocalInputValue(r.timestamp) + setDirty((prev) => ({ ...prev, [r.id]: v !== original })) + }} + className="w-full bg-transparent text-[11px] outline-none" + /> +
+
+ + + + +
+
+
+ 当前:{new Date(r.timestamp).toLocaleString()} · 点数 {r.sampleCount} +
+
+ ))} +
+
+ +
{page} / {totalPages}
+ +
+
+
+
+ ) +} diff --git a/app/api/records/batch/route.ts b/app/api/records/batch/route.ts new file mode 100644 index 0000000..2b35225 --- /dev/null +++ b/app/api/records/batch/route.ts @@ -0,0 +1,50 @@ +import { NextRequest } from 'next/server' +import { prisma } from '@/src/lib/prisma' +import { isAuthorizedDelete } from '@/src/lib/utils' + +type BatchBody = + | { action: 'delete'; ids: string[] } + | { action: 'updateTimestamp'; ids: string[]; timestamp: string } + +export async function POST(req: NextRequest) { + try { + if (!isAuthorizedDelete(req)) { + return Response.json({ error: 'unauthorized' }, { status: 401 }) + } + + const body = (await req.json()) as BatchBody + + if (!body || !Array.isArray((body as any).ids) || (body as any).ids.length === 0) { + return Response.json({ error: 'ids required' }, { status: 400 }) + } + + const ids = (body as any).ids as string[] + + if (body.action === 'delete') { + const result = await prisma.recording.deleteMany({ where: { id: { in: ids } } }) + return Response.json({ success: true, deletedCount: result.count, deletedIds: ids }) + } + + if (body.action === 'updateTimestamp') { + const ts = new Date(body.timestamp) + if (Number.isNaN(ts.getTime())) { + return Response.json({ error: 'invalid timestamp' }, { status: 400 }) + } + + await prisma.recording.updateMany({ where: { id: { in: ids } }, data: { timestamp: ts } }) + const updated = await prisma.recording.findMany({ + where: { id: { in: ids } }, + select: { id: true, timestamp: true }, + }) + return Response.json({ + success: true, + updated: updated.map((r) => ({ id: r.id, timestamp: r.timestamp.toISOString() })), + }) + } + + return Response.json({ error: 'unsupported action' }, { status: 400 }) + } catch (err) { + console.error(err) + return Response.json({ error: 'internal error' }, { status: 500 }) + } +}