From d4390f468ed62fa97e57b5a6996bc4609729dea3 Mon Sep 17 00:00:00 2001 From: feie9456 Date: Thu, 20 Nov 2025 17:40:47 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E7=AE=A1=E7=90=86=E5=91=98?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E4=BD=BF=E7=94=A8SSR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/AdminClient.tsx | 248 +++++++++++++++++++++++++++++++++ app/admin/page.tsx | 284 +++++--------------------------------- 2 files changed, 286 insertions(+), 246 deletions(-) create mode 100644 app/admin/AdminClient.tsx diff --git a/app/admin/AdminClient.tsx b/app/admin/AdminClient.tsx new file mode 100644 index 0000000..15867b1 --- /dev/null +++ b/app/admin/AdminClient.tsx @@ -0,0 +1,248 @@ +"use client" +import { useState } from 'react' +import Link from 'next/link' +import { useRouter } from 'next/navigation' + +type AdminRecordItem = { + id: string + timestamp: string + sampleCount: number + duration: 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 AdminClient({ + initialData, + currentPage, + pageSize, + totalPages, +}: { + initialData: AdminRecordItem[] + currentPage: number + pageSize: number + totalPages: number +}) { + const router = useRouter() + const [data, setData] = useState(initialData) + const [selected, setSelected] = useState>(new Set()) + const [editingTimes, setEditingTimes] = useState>(() => { + const initial: Record = {} + for (const r of initialData) { + initial[r.id] = toLocalInputValue(r.timestamp) + } + return initial + }) + const [dirty, setDirty] = useState>({}) + + 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))) + 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('修改时间失败') + } + } + + function goToPage(page: number) { + const params = new URLSearchParams() + params.set('page', String(page)) + params.set('pageSize', String(pageSize)) + router.push(`/admin?${params.toString()}`) + } + + return ( +
+
+
+
+

Admin 面板

+

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

+
+ ← 返回首页 +
+ +
+
+ + + 已选 {selected.size} 条 +
+
+ +
+
+ +
+
+
+ 选中 + ID + 时间 / 编辑 + 采样点数 +
+
+ {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" + /> +
+
+ + + + + +
+
+
+ 时长 {r.duration} · 点数 {r.sampleCount} +
+
+ ))} +
+
+ +
{currentPage} / {totalPages}
+ +
+
+
+
+ ) +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 1559c50..751ddba 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -1,254 +1,46 @@ -"use client" -import { useEffect, useMemo, useState } from 'react' -import Link from 'next/link' +import { prisma } from '@/src/lib/prisma' +import AdminClient from './AdminClient' type AdminRecordItem = { id: string timestamp: string sampleCount: number + duration: 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}
- -
-
-
-
- ) +export default async function AdminPage({ + searchParams, +}: { + searchParams: Promise<{ page?: string; pageSize?: string }> +}) { + const params = await searchParams + const page = Math.max(1, parseInt(params.page || '1', 10) || 1) + const pageSizeRaw = parseInt(params.pageSize || '50', 10) || 50 + const pageSize = Math.min(100, Math.max(1, pageSizeRaw)) + + const [total, rows] = await Promise.all([ + prisma.recording.count(), + prisma.recording.findMany({ + orderBy: { timestamp: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + select: { + id: true, + timestamp: true, + sample_count: true, + duration: true, + }, + }), + ]) + + const data: AdminRecordItem[] = rows.map((r) => ({ + id: r.id, + timestamp: r.timestamp.toISOString(), + sampleCount: r.sample_count, + duration: r.duration, + })) + + const totalPages = Math.max(1, Math.ceil(total / pageSize)) + + return }