nano-force-web-neo/app/admin/AdminClient.tsx

249 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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<Set<string>>(new Set())
const [editingTimes, setEditingTimes] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {}
for (const r of initialData) {
initial[r.id] = toLocalInputValue(r.timestamp)
}
return initial
})
const [dirty, setDirty] = useState<Record<string, boolean>>({})
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 (
<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-2 sm:p-6">
<header className="mb-4 flex items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold">Admin </h1>
<p className="mt-1 text-xs text-zinc-500"></p>
</div>
<Link href="/" className="rounded border px-3 py-1.5 text-sm"> </Link>
</header>
<section className="mb-4 flex flex-wrap items-center gap-3 rounded border border-zinc-200 bg-white p-3 text-sm dark:border-zinc-800 dark:bg-zinc-950">
<div className="flex items-center gap-2">
<button onClick={selectAllCurrent} className="rounded border px-2 py-1"></button>
<button onClick={clearSelection} className="rounded border px-2 py-1"></button>
<span className="text-xs text-zinc-500"> {selected.size} </span>
</div>
<div className="flex items-center gap-2">
<button onClick={handleBatchDelete} className="rounded bg-red-600 px-3 py-1 text-white disabled:opacity-50" disabled={selected.size === 0}></button>
</div>
</section>
<section className="rounded border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-950">
<div className="hidden items-center justify-between border-b px-3 py-2 text-xs text-zinc-500 dark:border-zinc-800 sm:flex">
<div className="flex items-center gap-2">
<span className="w-8 text-center"></span>
<span className="w-40">ID</span>
<span className="w-72"> / </span>
<span className="w-24 text-right"></span>
</div>
</div>
{data.length === 0 && (
<div className="px-3 py-3 text-sm text-zinc-500"></div>
)}
<div className="divide-y text-sm dark:divide-zinc-800">
{data.map((r) => (
<div key={r.id} className="border-b px-3 py-2 text-xs last:border-b-0 dark:border-zinc-800">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={selected.has(r.id)}
onChange={() => toggleSelect(r.id)}
className="h-4 w-4"
/>
<span className="max-w-[200px] truncate font-mono sm:w-40">{r.id}</span>
</div>
<div className="hidden items-center gap-2 sm:flex">
<span className="text-[11px] text-zinc-500">{new Date(r.timestamp).toLocaleString()}</span>
<span className="w-16 text-right text-[11px] text-zinc-500">{r.sampleCount}</span>
</div>
<Link href={`/records/${r.id}`} className="text-[11px] text-blue-600 hover:underline dark:text-blue-400"></Link>
</div>
<div className="mt-1 flex flex-col gap-1 sm:mt-1 sm:flex-row sm:items-center sm:gap-2">
<div
className={`flex flex-1 items-center gap-1 rounded border px-1 py-0.5 ${dirty[r.id] ? 'border-amber-400 bg-amber-50 dark:border-amber-500 dark:bg-amber-900/30' : 'border-zinc-300 bg-white dark:border-zinc-700 dark:bg-zinc-900'}`}
>
<input
type="datetime-local"
value={editingTimes[r.id] || ''}
onChange={(e) => {
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"
/>
</div>
<div className="mt-1 flex flex-wrap gap-1 sm:mt-0 sm:flex-none sm:justify-start justify-end">
<button
onClick={() => {
const original = toLocalInputValue(r.timestamp)
setEditingTimes((prev) => ({ ...prev, [r.id]: original }))
setDirty((prev) => ({ ...prev, [r.id]: false }))
}}
className="rounded border px-2 py-1 text-[11px]"
></button>
<button onClick={() => setNowFor(r.id)} className="rounded border px-2 py-1 text-[11px]"></button>
<button onClick={() => shiftMinutes(r.id, 1)} className="rounded border px-2 py-1 text-[11px]">+1min</button>
<button onClick={() => shiftMinutes(r.id, 10)} className="rounded border px-2 py-1 text-[11px]">+10min</button>
<button onClick={() => applyTime(r.id)} className="rounded bg-zinc-900 px-3 py-1 text-[11px] text-white dark:bg-zinc-100 dark:text-black"></button>
</div>
</div>
<div className="mt-1 text-[11px] text-zinc-500 sm:hidden">
{r.duration} · {r.sampleCount}
</div>
</div>
))}
</div>
<div className="flex items-center justify-center gap-4 border-t px-3 py-2 text-sm dark:border-zinc-800">
<button
className="rounded border px-3 py-1.5 disabled:opacity-50"
onClick={() => goToPage(Math.max(1, currentPage - 1))}
disabled={currentPage <= 1}
></button>
<div className="text-xs">{currentPage} / {totalPages}</div>
<button
className="rounded border px-3 py-1.5 disabled:opacity-50"
onClick={() => goToPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage >= totalPages}
></button>
</div>
</section>
</div>
</div>
)
}