feat: add operation log scrolling and pagination
This commit is contained in:
parent
259f4b8e7e
commit
583e3240ce
@ -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({
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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),
|
||||
))
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user