🐛 添加被忽略的必要文件
This commit is contained in:
@@ -1 +0,0 @@
|
||||
|
||||
222
web-admin/src/features/logs/LoginLogPage.tsx
Normal file
222
web-admin/src/features/logs/LoginLogPage.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Button, Card, Form, Input, Modal, Select, Space, Table, Tag, Typography, message } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { loginLogApi } from '@/lib/api'
|
||||
import { formatDate } from '@/lib/date'
|
||||
import type { LoginLog } from '@/types/system'
|
||||
|
||||
type LoginLogSearch = {
|
||||
username?: string
|
||||
status?: boolean
|
||||
}
|
||||
|
||||
export function LoginLogPage() {
|
||||
const [searchForm] = Form.useForm<LoginLogSearch>()
|
||||
const [rows, setRows] = useState<LoginLog[]>([])
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [total, setTotal] = useState(0)
|
||||
|
||||
const reloadRows = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const values = searchForm.getFieldsValue()
|
||||
const response = await loginLogApi.getLoginLogList({
|
||||
page,
|
||||
pageSize,
|
||||
username: values.username,
|
||||
...(typeof values.status === 'boolean' ? { status: values.status } : {}),
|
||||
})
|
||||
setRows(response.data.list)
|
||||
setTotal(response.data.total)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, pageSize, searchForm])
|
||||
|
||||
useEffect(() => {
|
||||
reloadRows()
|
||||
}, [reloadRows])
|
||||
|
||||
const refreshAfterDelete = (removedCount: number) => {
|
||||
setSelectedRowKeys([])
|
||||
if (rows.length <= removedCount && page > 1) {
|
||||
setPage((current) => current - 1)
|
||||
return
|
||||
}
|
||||
reloadRows()
|
||||
}
|
||||
|
||||
const runSearch = () => {
|
||||
if (page !== 1) {
|
||||
setPage(1)
|
||||
return
|
||||
}
|
||||
reloadRows()
|
||||
}
|
||||
|
||||
const deleteRow = (record: LoginLog) => {
|
||||
Modal.confirm({
|
||||
title: `删除登录日志 #${record.ID}`,
|
||||
content: '删除后无法恢复。',
|
||||
okText: '删除',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
await loginLogApi.deleteLoginLog(record.ID)
|
||||
message.success('登录日志已删除')
|
||||
refreshAfterDelete(1)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const deleteSelectedRows = () => {
|
||||
if (!selectedRowKeys.length) {
|
||||
return
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: `批量删除 ${selectedRowKeys.length} 条登录日志`,
|
||||
content: '删除后无法恢复。',
|
||||
okText: '删除',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
await loginLogApi.deleteLoginLogByIds(selectedRowKeys.map((item) => Number(item)))
|
||||
message.success('登录日志已删除')
|
||||
refreshAfterDelete(selectedRowKeys.length)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const columns: ColumnsType<LoginLog> = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'ID',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '用户名',
|
||||
dataIndex: 'username',
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
title: '登录 IP',
|
||||
dataIndex: 'ip',
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
width: 110,
|
||||
render: (_, record) => <Tag color={record.status ? 'green' : 'red'}>{record.status ? '成功' : '失败'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '详情',
|
||||
render: (_, record) => record.errorMessage || (record.status ? '登录成功' : '-'),
|
||||
},
|
||||
{
|
||||
title: '浏览器/设备',
|
||||
dataIndex: 'agent',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '登录时间',
|
||||
width: 180,
|
||||
render: (_, record) => formatDate(record.CreatedAt),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 100,
|
||||
fixed: 'right',
|
||||
render: (_, record) => (
|
||||
<Button danger type="link" onClick={() => deleteRow(record)}>
|
||||
删除
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<Card className="glass-panel page-panel">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<Typography.Title level={2} style={{ marginBottom: 8 }}>
|
||||
登录日志
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
|
||||
当前页用于审计登录结果、失败原因和终端信息。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="glass-panel page-panel">
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Form
|
||||
form={searchForm}
|
||||
layout="inline"
|
||||
onFinish={runSearch}
|
||||
>
|
||||
<Form.Item name="username" label="用户名">
|
||||
<Input allowClear placeholder="输入用户名" style={{ width: 220 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="全部状态"
|
||||
style={{ width: 160 }}
|
||||
options={[
|
||||
{ label: '成功', value: true },
|
||||
{ label: '失败', value: false },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
searchForm.resetFields()
|
||||
runSearch()
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Space>
|
||||
<Button danger disabled={!selectedRowKeys.length} onClick={deleteSelectedRows}>
|
||||
批量删除
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
rowKey="ID"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={rows}
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: setSelectedRowKeys,
|
||||
}}
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setPage(nextPage)
|
||||
setPageSize(nextPageSize)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
292
web-admin/src/features/logs/OperationLogPage.tsx
Normal file
292
web-admin/src/features/logs/OperationLogPage.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Button, Card, Form, Input, Modal, Space, Table, Tag, Typography, message } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { operationApi } from '@/lib/api'
|
||||
import { formatDate } from '@/lib/date'
|
||||
import type { OperationRecord } from '@/types/system'
|
||||
|
||||
type OperationSearch = {
|
||||
method?: string
|
||||
path?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
function formatPayload(payload?: string) {
|
||||
if (!payload) {
|
||||
return '无内容'
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(payload), null, 2)
|
||||
} catch {
|
||||
return payload
|
||||
}
|
||||
}
|
||||
|
||||
export function OperationLogPage() {
|
||||
const [searchForm] = Form.useForm<OperationSearch>()
|
||||
const [rows, setRows] = useState<OperationRecord[]>([])
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [previewTitle, setPreviewTitle] = useState('')
|
||||
const [previewContent, setPreviewContent] = useState('')
|
||||
|
||||
const previewOpen = useMemo(() => Boolean(previewTitle), [previewTitle])
|
||||
|
||||
const reloadRows = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const values = searchForm.getFieldsValue()
|
||||
const response = await operationApi.getOperationList({
|
||||
page,
|
||||
pageSize,
|
||||
method: values.method,
|
||||
path: values.path,
|
||||
status: values.status || undefined,
|
||||
})
|
||||
setRows(response.data.list)
|
||||
setTotal(response.data.total)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, pageSize, searchForm])
|
||||
|
||||
useEffect(() => {
|
||||
reloadRows()
|
||||
}, [reloadRows])
|
||||
|
||||
const refreshAfterDelete = (removedCount: number) => {
|
||||
setSelectedRowKeys([])
|
||||
if (rows.length <= removedCount && page > 1) {
|
||||
setPage((current) => current - 1)
|
||||
return
|
||||
}
|
||||
reloadRows()
|
||||
}
|
||||
|
||||
const openPreview = (title: string, payload?: string) => {
|
||||
setPreviewTitle(title)
|
||||
setPreviewContent(formatPayload(payload))
|
||||
}
|
||||
|
||||
const deleteRow = (record: OperationRecord) => {
|
||||
Modal.confirm({
|
||||
title: `删除操作记录 #${record.ID}`,
|
||||
content: '删除后无法恢复。',
|
||||
okText: '删除',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
await operationApi.deleteOperation(record.ID)
|
||||
message.success('操作记录已删除')
|
||||
refreshAfterDelete(1)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const runSearch = () => {
|
||||
if (page !== 1) {
|
||||
setPage(1)
|
||||
return
|
||||
}
|
||||
reloadRows()
|
||||
}
|
||||
|
||||
const deleteSelectedRows = () => {
|
||||
if (!selectedRowKeys.length) {
|
||||
return
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: `批量删除 ${selectedRowKeys.length} 条操作记录`,
|
||||
content: '删除后无法恢复。',
|
||||
okText: '删除',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
await operationApi.deleteOperationByIds(selectedRowKeys.map((item) => Number(item)))
|
||||
message.success('操作记录已删除')
|
||||
refreshAfterDelete(selectedRowKeys.length)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const columns: ColumnsType<OperationRecord> = [
|
||||
{
|
||||
title: '操作人',
|
||||
width: 180,
|
||||
render: (_, record) => {
|
||||
const userName = record.user?.userName || '系统'
|
||||
const nickName = record.user?.nickName
|
||||
return nickName ? `${userName} (${nickName})` : userName
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
width: 180,
|
||||
render: (_, record) => formatDate(record.CreatedAt),
|
||||
},
|
||||
{
|
||||
title: '状态码',
|
||||
dataIndex: 'status',
|
||||
width: 110,
|
||||
render: (status: number) => <Tag color={status >= 200 && status < 400 ? 'green' : 'red'}>{status}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '请求 IP',
|
||||
dataIndex: 'ip',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '请求方法',
|
||||
dataIndex: 'method',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '请求路径',
|
||||
dataIndex: 'path',
|
||||
width: 260,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '耗时',
|
||||
dataIndex: 'latency',
|
||||
width: 120,
|
||||
render: (value?: string) => value || '-',
|
||||
},
|
||||
{
|
||||
title: '请求体',
|
||||
width: 100,
|
||||
render: (_, record) => (
|
||||
<Button type="link" disabled={!record.body} onClick={() => openPreview('请求体', record.body)}>
|
||||
查看
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '响应体',
|
||||
width: 100,
|
||||
render: (_, record) => (
|
||||
<Button type="link" disabled={!record.resp} onClick={() => openPreview('响应体', record.resp)}>
|
||||
查看
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 100,
|
||||
fixed: 'right',
|
||||
render: (_, record) => (
|
||||
<Button danger type="link" onClick={() => deleteRow(record)}>
|
||||
删除
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<Card className="glass-panel page-panel">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<Typography.Title level={2} style={{ marginBottom: 8 }}>
|
||||
操作历史
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
|
||||
当前页用于审计接口调用链路、请求内容和响应结果。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="glass-panel page-panel">
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Form
|
||||
form={searchForm}
|
||||
layout="inline"
|
||||
onFinish={runSearch}
|
||||
>
|
||||
<Form.Item name="method" label="请求方法">
|
||||
<Input allowClear placeholder="如 GET" style={{ width: 180 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="path" label="请求路径">
|
||||
<Input allowClear placeholder="如 /user/getUserList" style={{ width: 280 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态码">
|
||||
<Input allowClear placeholder="如 200" style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
searchForm.resetFields()
|
||||
runSearch()
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Space>
|
||||
<Button danger disabled={!selectedRowKeys.length} onClick={deleteSelectedRows}>
|
||||
批量删除
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
rowKey="ID"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={rows}
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: setSelectedRowKeys,
|
||||
}}
|
||||
scroll={{ x: 1500 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setPage(nextPage)
|
||||
setPageSize(nextPageSize)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
open={previewOpen}
|
||||
title={previewTitle}
|
||||
footer={null}
|
||||
onCancel={() => {
|
||||
setPreviewTitle('')
|
||||
setPreviewContent('')
|
||||
}}
|
||||
width={880}
|
||||
>
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
maxHeight: 520,
|
||||
overflow: 'auto',
|
||||
padding: 16,
|
||||
borderRadius: 16,
|
||||
background: 'rgba(16, 37, 66, 0.05)',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{previewContent}
|
||||
</pre>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user