feat: add operation log scrolling and pagination

This commit is contained in:
WuYongJie 2026-09-03 13:44:06 +08:00
parent 259f4b8e7e
commit 583e3240ce
7 changed files with 215 additions and 90 deletions

View File

@ -34,7 +34,7 @@ export async function GET(request: NextRequest) {
const params = request.nextUrl.searchParams
const page = Math.max(1, Number.parseInt(params.get('page') ?? '1', 10) || 1)
const pageSize = Math.min(
100,
200,
Math.max(1, Number.parseInt(params.get('pageSize') ?? '50', 10) || 50),
)
const result = await operationLogService.list({

View File

@ -92,7 +92,7 @@ export default function ProductDevelopmentOperationLogClient() {
aria-labelledby="operations-tab"
hidden={activeTab !== 'operations'}
>
<OperationLogClient showHeader={false} />
<OperationLogClient showHeader={false} embedded />
</div>
<div
id="notifications-panel"

View File

@ -69,5 +69,34 @@ describe('OperationLogClient', () => {
expect(screen.getAllByText('JUSHUITAN_VIRTUAL_INVENTORY_FAILED').length).toBeGreaterThan(0)
expect(screen.getByText(/rate limited/)).toBeInTheDocument()
expect(screen.getByRole('navigation', { name: '操作日志分页' })).toBeInTheDocument()
expect(screen.getByRole('textbox', { name: '跳转页码' })).toHaveValue('1')
})
it('supports changing pages and page size from the floating pagination', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({
success: true,
data: [],
meta: { pagination: { page: 1, total: 101, totalPages: 3, hasMore: true } },
}),
})
const actor = userEvent.setup()
render(<OperationLogClient embedded showHeader={false} />)
await waitFor(() => expect(screen.getByText('共 101 条')).toBeInTheDocument())
await actor.click(screen.getByRole('button', { name: '下一页' }))
await waitFor(() => expect(fetchMock).toHaveBeenLastCalledWith(
expect.stringContaining('page=2'),
expect.any(Object),
))
await actor.selectOptions(screen.getByRole('combobox', { name: '每页条数' }), '200')
await waitFor(() => expect(fetchMock).toHaveBeenLastCalledWith(
expect.stringContaining('pageSize=200'),
expect.any(Object),
))
})
})

View File

@ -3,14 +3,11 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import {
ChevronLeft,
ChevronRight,
ExternalLink,
FileWarning,
RefreshCw,
Search,
ShieldAlert,
X,
} from 'lucide-react'
import {
actionLabels,
@ -22,13 +19,23 @@ import {
type OperationLog,
type OperationStatus,
} from './operationLogPresentation'
import { OperationLogPagination } from './OperationLogPagination'
import { OperationLogDetailDrawer } from './OperationLogDetailDrawer'
export default function OperationLogClient({ showHeader = true }: { showHeader?: boolean }) {
export default function OperationLogClient({
showHeader = true,
embedded = false,
}: {
showHeader?: boolean
embedded?: boolean
}) {
const [logs, setLogs] = useState<OperationLog[]>([])
const [selectedLog, setSelectedLog] = useState<OperationLog | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [page, setPage] = useState(1)
const [pageInput, setPageInput] = useState('1')
const [pageSize, setPageSize] = useState(50)
const [total, setTotal] = useState(0)
const [totalPages, setTotalPages] = useState(1)
const [problemOnly, setProblemOnly] = useState(true)
@ -39,7 +46,7 @@ export default function OperationLogClient({ showHeader = true }: { showHeader?:
const [endDate, setEndDate] = useState('')
const query = useMemo(() => {
const params = new URLSearchParams({ page: String(page), pageSize: '50' })
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
if (problemOnly) params.set('problemOnly', 'true')
if (action) params.set('action', action)
if (status && !problemOnly) params.set('status', status)
@ -47,7 +54,7 @@ export default function OperationLogClient({ showHeader = true }: { showHeader?:
if (startDate) params.set('startDate', startDate)
if (endDate) params.set('endDate', endDate)
return params.toString()
}, [action, endDate, keyword, page, problemOnly, startDate, status])
}, [action, endDate, keyword, page, pageSize, problemOnly, startDate, status])
const loadLogs = useCallback(async () => {
setLoading(true)
@ -82,8 +89,16 @@ export default function OperationLogClient({ showHeader = true }: { showHeader?:
callback()
}
const commitPageInput = () => {
const nextPage = Math.min(Math.max(1, totalPages), Math.max(1, Number(pageInput) || 1))
setPage(nextPage)
setPageInput(String(nextPage))
}
useEffect(() => setPageInput(String(page)), [page])
return (
<main className="min-h-screen bg-[#f5f7f7] text-zinc-900">
<main className={`relative flex min-h-[620px] flex-col bg-[#f5f7f7] text-zinc-900 ${embedded ? 'h-[calc(100vh-190px)]' : 'min-h-screen'}`}>
{showHeader ? (
<header className="border-b border-zinc-200 bg-white">
<div className="mx-auto flex max-w-[1680px] items-center justify-between gap-4 px-5 py-4">
@ -105,8 +120,8 @@ export default function OperationLogClient({ showHeader = true }: { showHeader?:
</header>
) : null}
<div className="mx-auto max-w-[1680px] p-5">
<section className="border border-zinc-200 bg-white">
<div className="mx-auto flex min-h-0 w-full max-w-[1680px] flex-1 flex-col p-5">
<section className="flex min-h-0 flex-1 flex-col overflow-hidden border border-zinc-200 bg-white">
<div className="flex flex-wrap items-center gap-3 border-b border-zinc-200 px-4 py-3">
<button
type="button"
@ -178,9 +193,9 @@ export default function OperationLogClient({ showHeader = true }: { showHeader?:
{error ? (
<div className="px-4 py-12 text-center text-sm font-medium text-red-600">{error}</div>
) : (
<div className="overflow-x-auto">
<div className="min-h-0 flex-1 overflow-auto bg-white pb-24">
<table className="w-full min-w-[1060px] border-collapse text-sm">
<thead className="bg-zinc-50 text-left text-xs font-semibold text-zinc-500">
<thead className="sticky top-0 z-10 bg-zinc-50 text-left text-xs font-semibold text-zinc-500">
<tr>
<th className="px-4 py-3">时间</th>
<th className="px-4 py-3">结果</th>
@ -227,85 +242,23 @@ export default function OperationLogClient({ showHeader = true }: { showHeader?:
</div>
)}
<footer className="flex items-center justify-between gap-4 border-t border-zinc-200 px-4 py-3 text-sm text-zinc-500">
<span>共 {total} 条</span>
<div className="flex items-center gap-2">
<button
type="button"
disabled={page <= 1}
onClick={() => setPage((current) => Math.max(1, current - 1))}
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-zinc-300 bg-white disabled:cursor-not-allowed disabled:opacity-40"
aria-label="上一页"
>
<ChevronLeft className="h-4 w-4" />
</button>
<span>{page} / {Math.max(1, totalPages)}</span>
<button
type="button"
disabled={page >= totalPages}
onClick={() => setPage((current) => Math.min(totalPages, current + 1))}
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-zinc-300 bg-white disabled:cursor-not-allowed disabled:opacity-40"
aria-label="下一页"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</footer>
</section>
</div>
{selectedLog ? (
<div className="fixed inset-0 z-[100] flex justify-end bg-black/20" onMouseDown={() => setSelectedLog(null)}>
<aside
className="h-full w-full max-w-[540px] overflow-y-auto border-l border-zinc-200 bg-white p-5 shadow-2xl"
onMouseDown={(event) => event.stopPropagation()}
>
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold text-zinc-500">日志详情</p>
<h2 className="mt-1 text-lg font-bold">{actionLabels[selectedLog.action]}</h2>
</div>
<button
type="button"
onClick={() => setSelectedLog(null)}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-zinc-500 transition hover:bg-zinc-100"
aria-label="关闭详情"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="mt-5"><OperationStatusBadge status={selectedLog.status} /></div>
<dl className="mt-6 divide-y divide-zinc-100 border-y border-zinc-100 text-sm">
{[
['时间', formatTime(selectedLog.createdAt)],
['人员 / 来源', `${selectedLog.actorName} / ${selectedLog.source === 'SYSTEM' ? '系统任务' : '人员操作'}`],
['SPU', selectedLog.productSpu || '-'],
['SKU', selectedLog.skuCode || '-'],
['节点', selectedLog.workspaceCategory || '-'],
['错误码', selectedLog.errorCode || '-'],
['请求编号', selectedLog.requestId || '-'],
].map(([label, value]) => (
<div key={label} className="grid grid-cols-[96px_minmax(0,1fr)] gap-3 py-3">
<dt className="text-zinc-500">{label}</dt>
<dd className="break-words font-medium text-zinc-800">{value}</dd>
</div>
))}
</dl>
<section className="mt-6">
<h3 className="text-sm font-semibold text-zinc-800">结果说明</h3>
<p className="mt-2 whitespace-pre-wrap rounded-md border border-zinc-200 bg-zinc-50 p-3 text-sm leading-6 text-zinc-700">{selectedLog.message}</p>
</section>
{selectedLog.details ? (
<section className="mt-6">
<h3 className="text-sm font-semibold text-zinc-800">上下文</h3>
<pre className="mt-2 overflow-x-auto rounded-md border border-zinc-200 bg-zinc-950 p-3 text-xs leading-5 text-zinc-100">
{JSON.stringify(selectedLog.details, null, 2)}
</pre>
</section>
) : null}
</aside>
</div>
) : null}
<OperationLogPagination
page={page}
pageInput={pageInput}
pageSize={pageSize}
total={total}
totalPages={totalPages}
loading={loading}
onPageChange={setPage}
onPageInputChange={setPageInput}
onCommitPageInput={commitPageInput}
onPageSizeChange={(value) => { setPageSize(value); setPage(1) }}
/>
{selectedLog ? <OperationLogDetailDrawer log={selectedLog} onClose={() => setSelectedLog(null)} /> : null}
</main>
)
}

View File

@ -0,0 +1,67 @@
import { X } from 'lucide-react'
import {
actionLabels,
formatTime,
OperationStatusBadge,
type OperationLog,
} from './operationLogPresentation'
type Props = {
log: OperationLog
onClose: () => void
}
export function OperationLogDetailDrawer({ log, onClose }: Props) {
return (
<div className="fixed inset-0 z-[100] flex justify-end bg-black/20" onMouseDown={onClose}>
<aside
className="h-full w-full max-w-[540px] overflow-y-auto border-l border-zinc-200 bg-white p-5 shadow-2xl"
onMouseDown={(event) => event.stopPropagation()}
>
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold text-zinc-500">日志详情</p>
<h2 className="mt-1 text-lg font-bold">{actionLabels[log.action]}</h2>
</div>
<button
type="button"
onClick={onClose}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-zinc-500 transition hover:bg-zinc-100"
aria-label="关闭详情"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="mt-5"><OperationStatusBadge status={log.status} /></div>
<dl className="mt-6 divide-y divide-zinc-100 border-y border-zinc-100 text-sm">
{[
['时间', formatTime(log.createdAt)],
['人员 / 来源', `${log.actorName} / ${log.source === 'SYSTEM' ? '系统任务' : '人员操作'}`],
['SPU', log.productSpu || '-'],
['SKU', log.skuCode || '-'],
['节点', log.workspaceCategory || '-'],
['错误码', log.errorCode || '-'],
['请求编号', log.requestId || '-'],
].map(([label, value]) => (
<div key={label} className="grid grid-cols-[96px_minmax(0,1fr)] gap-3 py-3">
<dt className="text-zinc-500">{label}</dt>
<dd className="break-words font-medium text-zinc-800">{value}</dd>
</div>
))}
</dl>
<section className="mt-6">
<h3 className="text-sm font-semibold text-zinc-800">结果说明</h3>
<p className="mt-2 whitespace-pre-wrap rounded-md border border-zinc-200 bg-zinc-50 p-3 text-sm leading-6 text-zinc-700">{log.message}</p>
</section>
{log.details ? (
<section className="mt-6">
<h3 className="text-sm font-semibold text-zinc-800">上下文</h3>
<pre className="mt-2 overflow-x-auto rounded-md border border-zinc-200 bg-zinc-950 p-3 text-xs leading-5 text-zinc-100">
{JSON.stringify(log.details, null, 2)}
</pre>
</section>
) : null}
</aside>
</div>
)
}

View File

@ -0,0 +1,76 @@
import { ArrowLeft, ArrowRight } from 'lucide-react'
type Props = {
page: number
pageInput: string
pageSize: number
total: number
totalPages: number
loading: boolean
onPageChange: (page: number) => void
onPageInputChange: (value: string) => void
onCommitPageInput: () => void
onPageSizeChange: (pageSize: number) => void
}
export function OperationLogPagination(props: Props) {
const totalPageCount = Math.max(1, props.totalPages)
return (
<nav
aria-label="操作日志分页"
className="absolute bottom-6 left-1/2 z-30 flex max-w-[calc(100%-32px)] -translate-x-1/2 items-center gap-1 whitespace-nowrap rounded-full border border-zinc-200 bg-white/95 px-2 py-2 text-xs shadow-[0_4px_12px_rgba(0,0,0,0.06),0_2px_4px_rgba(0,0,0,0.04)] backdrop-blur-xl sm:gap-2.5 sm:px-4 sm:text-[13px]"
>
<span className="text-zinc-600 sm:mr-2">{props.loading ? '统计中...' : `共 ${props.total} 条`}</span>
<button
type="button"
disabled={props.loading || props.page <= 1}
onClick={() => props.onPageChange(Math.max(1, props.page - 1))}
className="inline-flex h-[30px] w-[30px] items-center justify-center gap-1 rounded-[4px] border border-zinc-200 bg-white text-zinc-800 disabled:cursor-not-allowed disabled:opacity-40 sm:w-auto sm:px-3"
aria-label="上一页"
>
<ArrowLeft className="h-3 w-3" />
<span className="hidden sm:inline">上一页</span>
</button>
<span className="inline-flex items-center gap-1 text-zinc-600">
<input
value={props.pageInput}
onChange={(event) => props.onPageInputChange(event.target.value.replace(/\D/g, ''))}
onBlur={props.onCommitPageInput}
onKeyDown={(event) => {
if (event.key === 'Enter') props.onCommitPageInput()
}}
inputMode="numeric"
aria-label="跳转页码"
className="h-7 w-10 rounded-[4px] border border-zinc-200 bg-white px-1 text-center font-semibold text-zinc-800 outline-none focus:border-zinc-500 sm:w-[52px]"
/>
<span className="text-zinc-400">/</span>
<span>{totalPageCount} 页</span>
</span>
<button
type="button"
disabled={props.loading || props.page >= totalPageCount}
onClick={() => props.onPageChange(Math.min(totalPageCount, props.page + 1))}
className="inline-flex h-[30px] w-[30px] items-center justify-center gap-1 rounded-[4px] border border-zinc-200 bg-white text-zinc-800 disabled:cursor-not-allowed disabled:opacity-40 sm:w-auto sm:px-3"
aria-label="下一页"
>
<span className="hidden sm:inline">下一页</span>
<ArrowRight className="h-3 w-3" />
</button>
<span className="inline-flex items-center gap-1.5 text-zinc-600 sm:ml-2">
<span className="hidden sm:inline">每页</span>
<select
value={props.pageSize}
onChange={(event) => props.onPageSizeChange(Number(event.target.value))}
aria-label="每页条数"
className="h-7 rounded-[4px] border border-zinc-200 bg-white px-2 text-zinc-800 outline-none"
>
<option value={50}>50</option>
<option value={100}>100</option>
<option value={200}>200</option>
</select>
<span className="hidden sm:inline">条</span>
</span>
</nav>
)
}

View File

@ -48,7 +48,7 @@ class OperationLogService {
async list(options: OperationLogListOptions = {}) {
const page = Math.max(1, options.page ?? 1)
const pageSize = Math.min(100, Math.max(1, options.pageSize ?? 50))
const pageSize = Math.min(200, Math.max(1, options.pageSize ?? 50))
const { items, total } = await operationLogRepository.findMany({
page,
pageSize,