概览页增加后台登录记录
登录成功/失败写入 admin_login_logs,系统概览下方展示全部账号的时间、IP、设备等信息。
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
-- 002_admin_login_logs.sql —— 后台登录记录。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admin_login_logs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT,
|
||||
username TEXT NOT NULL DEFAULT '',
|
||||
role TEXT NOT NULL DEFAULT '',
|
||||
ip TEXT NOT NULL DEFAULT '',
|
||||
location TEXT NOT NULL DEFAULT '',
|
||||
user_agent TEXT NOT NULL DEFAULT '',
|
||||
success BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
failure_reason TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_admin_login_logs_created_at
|
||||
ON admin_login_logs(created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_admin_login_logs_username
|
||||
ON admin_login_logs(username);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_admin_login_logs_user_id
|
||||
ON admin_login_logs(user_id);
|
||||
|
||||
COMMENT ON TABLE admin_login_logs IS '后台账号登录记录';
|
||||
COMMENT ON COLUMN admin_login_logs.location IS '登录地点(可选,依赖 IP 推断或代理头)';
|
||||
COMMENT ON COLUMN admin_login_logs.user_agent IS '客户端设备 / User-Agent';
|
||||
@@ -0,0 +1,119 @@
|
||||
import { query } from '../db/client.js'
|
||||
|
||||
export type AdminLoginLogRow = {
|
||||
[column: string]: unknown
|
||||
id: number
|
||||
user_id: number | null
|
||||
username: string
|
||||
role: string
|
||||
ip: string
|
||||
location: string
|
||||
user_agent: string
|
||||
success: boolean
|
||||
failure_reason: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
type AdminLoginLogCreateInput = {
|
||||
userId?: number | string | null
|
||||
username?: string
|
||||
role?: string
|
||||
ip?: string
|
||||
location?: string
|
||||
userAgent?: string
|
||||
success?: boolean
|
||||
failureReason?: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
type AdminLoginLogListInput = {
|
||||
username?: string
|
||||
dateFrom?: string
|
||||
dateTo?: string
|
||||
page?: number | string
|
||||
pageSize?: number | string
|
||||
}
|
||||
|
||||
export async function createAdminLoginLog(
|
||||
input: AdminLoginLogCreateInput,
|
||||
): Promise<AdminLoginLogRow | null> {
|
||||
const result = await query<AdminLoginLogRow>(
|
||||
`
|
||||
INSERT INTO admin_login_logs (
|
||||
user_id,
|
||||
username,
|
||||
role,
|
||||
ip,
|
||||
location,
|
||||
user_agent,
|
||||
success,
|
||||
failure_reason,
|
||||
created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING *
|
||||
`,
|
||||
[
|
||||
input.userId || null,
|
||||
String(input.username || '').trim(),
|
||||
String(input.role || '').trim(),
|
||||
String(input.ip || '').trim(),
|
||||
String(input.location || '').trim(),
|
||||
String(input.userAgent || '').trim(),
|
||||
input.success !== false,
|
||||
String(input.failureReason || '').trim(),
|
||||
input.createdAt,
|
||||
],
|
||||
)
|
||||
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function listAdminLoginLogs(
|
||||
queryInput: AdminLoginLogListInput = {},
|
||||
): Promise<{ items: AdminLoginLogRow[]; total: number }> {
|
||||
const conditions: string[] = []
|
||||
const params: unknown[] = []
|
||||
|
||||
if (queryInput.username) {
|
||||
params.push(String(queryInput.username).trim())
|
||||
conditions.push(`username = $${params.length}`)
|
||||
}
|
||||
|
||||
if (queryInput.dateFrom) {
|
||||
params.push(queryInput.dateFrom)
|
||||
conditions.push(`created_at >= $${params.length}`)
|
||||
}
|
||||
|
||||
if (queryInput.dateTo) {
|
||||
params.push(queryInput.dateTo)
|
||||
conditions.push(`created_at <= $${params.length}`)
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
||||
const page = Math.max(1, Number(queryInput.page) || 1)
|
||||
const pageSize = Math.min(100, Math.max(1, Number(queryInput.pageSize) || 20))
|
||||
const offset = (page - 1) * pageSize
|
||||
|
||||
const totalResult = await query<{ total: number }>(
|
||||
`SELECT COUNT(*)::int AS total FROM admin_login_logs ${whereClause}`,
|
||||
params,
|
||||
)
|
||||
|
||||
params.push(pageSize)
|
||||
params.push(offset)
|
||||
const itemsResult = await query<AdminLoginLogRow>(
|
||||
`
|
||||
SELECT *
|
||||
FROM admin_login_logs
|
||||
${whereClause}
|
||||
ORDER BY id DESC
|
||||
LIMIT $${params.length - 1} OFFSET $${params.length}
|
||||
`,
|
||||
params,
|
||||
)
|
||||
|
||||
return {
|
||||
items: itemsResult.rows,
|
||||
total: Number(totalResult.rows[0]?.total || 0),
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,11 @@ import { Router } from 'express'
|
||||
|
||||
import { createRateLimitMiddleware, getBodyFieldRateLimitKey } from '../../middleware/rate-limit.js'
|
||||
import { getAdminSessionSummary, loginAdmin } from '../../services/admin/admin-auth-service.js'
|
||||
import {
|
||||
resolveClientIp,
|
||||
resolveClientLocation,
|
||||
resolveUserAgent,
|
||||
} from '../../services/admin/admin-login-log-service.js'
|
||||
import { createJsonHandler, extractBearerToken } from './session.js'
|
||||
|
||||
const router = Router()
|
||||
@@ -12,7 +17,11 @@ router.post('/auth/login', createRateLimitMiddleware({
|
||||
max: 10,
|
||||
key: getBodyFieldRateLimitKey('username'),
|
||||
}), createJsonHandler(
|
||||
(req) => loginAdmin(req.body?.username, req.body?.password),
|
||||
(req) => loginAdmin(req.body?.username, req.body?.password, {
|
||||
ip: resolveClientIp(req),
|
||||
userAgent: resolveUserAgent(req),
|
||||
location: resolveClientLocation(req),
|
||||
}),
|
||||
{
|
||||
successMessage: '登录成功',
|
||||
errorMessage: '后台登录失败',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Router } from 'express'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
|
||||
import { getAdminDashboardSummary } from '../../services/admin/admin-dashboard-service.js'
|
||||
import { getAdminLoginLogs } from '../../services/admin/admin-login-log-service.js'
|
||||
import { createJsonHandler } from './session.js'
|
||||
|
||||
const router = Router()
|
||||
@@ -14,4 +16,13 @@ router.get('/dashboard/summary', createJsonHandler(
|
||||
},
|
||||
))
|
||||
|
||||
router.get('/dashboard/login-logs', createJsonHandler(
|
||||
(req) => getAdminLoginLogs(req.query as JsonObject),
|
||||
{
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取登录记录失败',
|
||||
scope: '[admin/dashboard/login-logs]',
|
||||
},
|
||||
))
|
||||
|
||||
export default router
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { addHours, nowIso } from '../../utils/time.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { normalizePage, normalizePageSize } from './admin-query-utils.js'
|
||||
import { recordAdminLoginLog } from './admin-login-log-service.js'
|
||||
|
||||
type AdminUserRow = NonNullable<Awaited<ReturnType<typeof getAdminUserById>>>
|
||||
|
||||
@@ -58,13 +59,27 @@ export async function ensureAdminUsersBootstrapped(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function loginAdmin(username: unknown, password: unknown): Promise<JsonObject> {
|
||||
export async function loginAdmin(
|
||||
username: unknown,
|
||||
password: unknown,
|
||||
meta: {
|
||||
ip?: string
|
||||
userAgent?: string
|
||||
location?: string
|
||||
} = {},
|
||||
): Promise<JsonObject> {
|
||||
ensureAdminAuthConfigured()
|
||||
|
||||
const normalizedUsername = String(username || '').trim().toLowerCase()
|
||||
const normalizedPassword = String(password || '').trim()
|
||||
|
||||
if (!normalizedUsername || !normalizedPassword) {
|
||||
await recordAdminLoginLog({
|
||||
username: normalizedUsername,
|
||||
success: false,
|
||||
failureReason: 'missing_credentials',
|
||||
...pickLoginMeta(meta),
|
||||
})
|
||||
throw createHttpError('缺少后台账号或密码', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_credentials_required',
|
||||
@@ -73,13 +88,29 @@ export async function loginAdmin(username: unknown, password: unknown): Promise<
|
||||
|
||||
const user = await getAdminUserByUsername(normalizedUsername)
|
||||
if (!user || user.status !== 'active' || !verifyAdminPassword(normalizedPassword, user.password_hash)) {
|
||||
await recordAdminLoginLog({
|
||||
userId: user ? Number(user.id) : null,
|
||||
username: normalizedUsername,
|
||||
role: user ? normalizeAdminRole(user.role) : '',
|
||||
success: false,
|
||||
failureReason: 'invalid_credentials',
|
||||
...pickLoginMeta(meta),
|
||||
})
|
||||
throw createHttpError('账号或密码错误', {
|
||||
statusCode: 401,
|
||||
errorCode: 'admin_login_failed',
|
||||
})
|
||||
}
|
||||
|
||||
return createAdminSession(user)
|
||||
const session = createAdminSession(user)
|
||||
await recordAdminLoginLog({
|
||||
userId: Number(user.id),
|
||||
username: String(user.username || normalizedUsername),
|
||||
role: normalizeAdminRole(user.role),
|
||||
success: true,
|
||||
...pickLoginMeta(meta),
|
||||
})
|
||||
return session
|
||||
}
|
||||
|
||||
export async function verifyAdminSessionToken(token: unknown): Promise<AdminSession> {
|
||||
@@ -523,3 +554,27 @@ function mapAdminUser(user: AdminUserRow): JsonObject {
|
||||
updatedAt: user.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
function pickLoginMeta(meta: {
|
||||
ip?: string
|
||||
userAgent?: string
|
||||
location?: string
|
||||
} = {}) {
|
||||
const result: {
|
||||
ip?: string
|
||||
userAgent?: string
|
||||
location?: string
|
||||
} = {}
|
||||
|
||||
if (typeof meta.ip === 'string' && meta.ip.trim()) {
|
||||
result.ip = meta.ip.trim()
|
||||
}
|
||||
if (typeof meta.userAgent === 'string' && meta.userAgent.trim()) {
|
||||
result.userAgent = meta.userAgent.trim()
|
||||
}
|
||||
if (typeof meta.location === 'string' && meta.location.trim()) {
|
||||
result.location = meta.location.trim()
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { Request } from 'express'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
|
||||
import {
|
||||
createAdminLoginLog,
|
||||
listAdminLoginLogs,
|
||||
} from '../../repositories/admin-login-log-repo.js'
|
||||
import { logWarn } from '../../utils/logger.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import {
|
||||
normalizeDateQuery,
|
||||
normalizePage,
|
||||
normalizePageSize,
|
||||
} from './admin-query-utils.js'
|
||||
|
||||
type RecordAdminLoginInput = {
|
||||
userId?: number | null
|
||||
username?: string
|
||||
role?: string
|
||||
success?: boolean
|
||||
failureReason?: string
|
||||
ip?: string
|
||||
location?: string
|
||||
userAgent?: string
|
||||
}
|
||||
|
||||
export async function recordAdminLoginLog(input: RecordAdminLoginInput = {}) {
|
||||
try {
|
||||
return await createAdminLoginLog({
|
||||
userId: input.userId ?? null,
|
||||
username: String(input.username || '').trim(),
|
||||
role: String(input.role || '').trim(),
|
||||
ip: String(input.ip || '').trim(),
|
||||
location: String(input.location || '').trim(),
|
||||
userAgent: String(input.userAgent || '').trim(),
|
||||
success: input.success !== false,
|
||||
failureReason: String(input.failureReason || '').trim(),
|
||||
createdAt: nowIso(),
|
||||
})
|
||||
} catch (error) {
|
||||
logWarn('[admin/login-log]', '写入登录记录失败,已跳过', {
|
||||
username: input.username,
|
||||
error,
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function recordAdminLoginFromRequest(
|
||||
req: Request,
|
||||
input: Omit<RecordAdminLoginInput, 'ip' | 'userAgent' | 'location'> = {},
|
||||
) {
|
||||
return recordAdminLoginLog({
|
||||
...input,
|
||||
ip: resolveClientIp(req),
|
||||
userAgent: resolveUserAgent(req),
|
||||
location: resolveClientLocation(req),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getAdminLoginLogs(query: JsonObject = {}) {
|
||||
const page = normalizePage(query.page)
|
||||
const pageSize = normalizePageSize(query.pageSize)
|
||||
const { items, total } = await listAdminLoginLogs({
|
||||
page,
|
||||
pageSize,
|
||||
username: String(query.username || '').trim(),
|
||||
dateFrom: normalizeDateQuery(query.dateFrom),
|
||||
dateTo: normalizeDateQuery(query.dateTo, true),
|
||||
})
|
||||
|
||||
return {
|
||||
items: items.map((item) => ({
|
||||
logId: Number(item.id),
|
||||
userId: Number(item.user_id || 0) || null,
|
||||
username: String(item.username || '').trim(),
|
||||
role: String(item.role || '').trim(),
|
||||
ip: String(item.ip || '').trim(),
|
||||
location: String(item.location || '').trim(),
|
||||
userAgent: String(item.user_agent || '').trim(),
|
||||
success: item.success !== false,
|
||||
failureReason: String(item.failure_reason || '').trim(),
|
||||
createdAt: item.created_at,
|
||||
})),
|
||||
pagination: { page, pageSize, total },
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveClientIp(req: Request) {
|
||||
const forwarded = String(req.headers['x-forwarded-for'] || '')
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.find(Boolean)
|
||||
const realIp = String(req.headers['x-real-ip'] || '').trim()
|
||||
const ip = String(forwarded || realIp || req.ip || req.socket?.remoteAddress || '').trim()
|
||||
return ip.replace(/^::ffff:/, '')
|
||||
}
|
||||
|
||||
export function resolveUserAgent(req: Request) {
|
||||
return String(req.headers['user-agent'] || '').trim()
|
||||
}
|
||||
|
||||
/** 优先读常见代理/CDN 地理位置头,没有则留空。 */
|
||||
export function resolveClientLocation(req: Request) {
|
||||
const country = firstHeader(req, [
|
||||
'cf-ipcountry',
|
||||
'x-vercel-ip-country',
|
||||
'cloudfront-viewer-country',
|
||||
'x-country-code',
|
||||
])
|
||||
const city = firstHeader(req, [
|
||||
'cf-ipcity',
|
||||
'x-vercel-ip-city',
|
||||
'x-city',
|
||||
])
|
||||
const region = firstHeader(req, [
|
||||
'cf-region',
|
||||
'x-vercel-ip-country-region',
|
||||
'x-region',
|
||||
])
|
||||
|
||||
return [country, region, city].filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
function firstHeader(req: Request, names: string[]) {
|
||||
for (const name of names) {
|
||||
const value = String(req.headers[name] || '').trim()
|
||||
if (value && value.toUpperCase() !== 'XX') {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
@@ -1,8 +1,34 @@
|
||||
import { Alert, Card, Col, Row, Skeleton, Statistic } from 'antd'
|
||||
import { ReloadOutlined, SearchOutlined } from '@ant-design/icons'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Form,
|
||||
Input,
|
||||
Row,
|
||||
Skeleton,
|
||||
Space,
|
||||
Statistic,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
|
||||
import PageHeader from '@/components/admin/PageHeader'
|
||||
import { fetchAdminDashboardSummary } from '@/services/admin'
|
||||
import StatusTag from '@/components/admin/StatusTag'
|
||||
import dayjs from '@/lib/dayjs'
|
||||
import { fetchAdminDashboardSummary, fetchAdminLoginLogs } from '@/services/admin'
|
||||
import type { AdminLoginLogItem } from '@/types/admin'
|
||||
import {
|
||||
ADMIN_DEFAULT_PAGE_SIZE,
|
||||
buildAdminTablePagination,
|
||||
} from '@/utils/admin-pagination'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
const cards = [
|
||||
{ key: 'todayOrders', label: '今日订单' },
|
||||
@@ -12,26 +38,124 @@ const cards = [
|
||||
{ key: 'abnormalTasks', label: '异常任务' },
|
||||
] as const
|
||||
|
||||
type LoginFilterForm = {
|
||||
username?: string
|
||||
dateRange?: unknown
|
||||
}
|
||||
|
||||
export default function AdminDashboardPage() {
|
||||
const query = useQuery({
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
|
||||
const [filters, setFilters] = useState({
|
||||
username: '',
|
||||
dateFrom: '',
|
||||
dateTo: '',
|
||||
})
|
||||
|
||||
const summaryQuery = useQuery({
|
||||
queryKey: ['admin-dashboard-summary'],
|
||||
queryFn: () => fetchAdminDashboardSummary(),
|
||||
})
|
||||
const summary = query.data?.data
|
||||
const summary = summaryQuery.data?.data
|
||||
|
||||
const loginQueryParams = useMemo(
|
||||
() => ({
|
||||
page,
|
||||
pageSize,
|
||||
username: filters.username || undefined,
|
||||
dateFrom: filters.dateFrom || undefined,
|
||||
dateTo: filters.dateTo || undefined,
|
||||
}),
|
||||
[filters.dateFrom, filters.dateTo, filters.username, page, pageSize],
|
||||
)
|
||||
|
||||
const loginQuery = useQuery({
|
||||
queryKey: ['admin-login-logs', loginQueryParams],
|
||||
queryFn: () => fetchAdminLoginLogs(loginQueryParams),
|
||||
})
|
||||
const loginData = loginQuery.data?.data
|
||||
|
||||
const columns: TableColumnsType<AdminLoginLogItem> = [
|
||||
{
|
||||
title: '登录时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 180,
|
||||
render: (value) => formatAdminDateTime(value),
|
||||
},
|
||||
{
|
||||
title: '账号',
|
||||
dataIndex: 'username',
|
||||
minWidth: 140,
|
||||
render: (value, row) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text strong>{value || '-'}</Typography.Text>
|
||||
{row.role ? <StatusTag value={row.role} /> : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '结果',
|
||||
width: 100,
|
||||
render: (_, row) =>
|
||||
row.success ? (
|
||||
<Tag color="green">成功</Tag>
|
||||
) : (
|
||||
<Tag color="red">失败</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '登录地点',
|
||||
dataIndex: 'location',
|
||||
minWidth: 140,
|
||||
render: (value) => value || '-',
|
||||
},
|
||||
{
|
||||
title: '登录 IP',
|
||||
dataIndex: 'ip',
|
||||
width: 150,
|
||||
render: (value) => value || '-',
|
||||
},
|
||||
{
|
||||
title: '设备信息',
|
||||
dataIndex: 'userAgent',
|
||||
minWidth: 280,
|
||||
render: (value) => (
|
||||
<Typography.Text ellipsis={{ tooltip: value || '-' }} style={{ maxWidth: 420 }}>
|
||||
{value || '-'}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
function applyLoginFilters(values: LoginFilterForm) {
|
||||
const range = Array.isArray(values.dateRange) ? values.dateRange : []
|
||||
const dateFrom =
|
||||
range[0] && dayjs(range[0]).isValid() ? dayjs(range[0]).format('YYYY-MM-DD') : ''
|
||||
const dateTo =
|
||||
range[1] && dayjs(range[1]).isValid() ? dayjs(range[1]).format('YYYY-MM-DD') : ''
|
||||
setPage(1)
|
||||
setFilters({
|
||||
username: String(values.username || '').trim(),
|
||||
dateFrom,
|
||||
dateTo,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader title="系统概览" description="快速查看订单和交付链路的运行状态。" />
|
||||
|
||||
{query.error ? (
|
||||
{summaryQuery.error ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={query.error instanceof Error ? query.error.message : '读取概览失败'}
|
||||
message={
|
||||
summaryQuery.error instanceof Error ? summaryQuery.error.message : '读取概览失败'
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{query.isLoading ? (
|
||||
{summaryQuery.isLoading ? (
|
||||
<Card>
|
||||
<Skeleton active paragraph={{ rows: 8 }} />
|
||||
</Card>
|
||||
@@ -46,6 +170,83 @@ export default function AdminDashboardPage() {
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<Card
|
||||
title="登录记录"
|
||||
extra={
|
||||
<Space wrap>
|
||||
<Typography.Text type="secondary">全部后台账号的登录信息</Typography.Text>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={loginQuery.isFetching}
|
||||
onClick={() => void loginQuery.refetch()}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Form<LoginFilterForm>
|
||||
layout="inline"
|
||||
className="filter-form"
|
||||
onFinish={applyLoginFilters}
|
||||
initialValues={{ username: filters.username }}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<Form.Item name="username" label="账号">
|
||||
<Input allowClear placeholder="用户名" style={{ width: 180 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="dateRange" label="登录时段">
|
||||
<AdminRangePicker />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setPage(1)
|
||||
setFilters({ username: '', dateFrom: '', dateTo: '' })
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>
|
||||
查询
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
{loginQuery.error ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message={
|
||||
loginQuery.error instanceof Error
|
||||
? loginQuery.error.message
|
||||
: '读取登录记录失败'
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Table<AdminLoginLogItem>
|
||||
rowKey="logId"
|
||||
loading={loginQuery.isLoading || loginQuery.isFetching}
|
||||
columns={columns}
|
||||
dataSource={loginData?.items || []}
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={buildAdminTablePagination({
|
||||
current: loginData?.pagination.page || page,
|
||||
pageSize: loginData?.pagination.pageSize || pageSize,
|
||||
total: loginData?.pagination.total || 0,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setPage(nextPage)
|
||||
setPageSize(nextPageSize)
|
||||
},
|
||||
})}
|
||||
locale={{ emptyText: '暂无登录记录(新登录成功后会自动写入)' }}
|
||||
/>
|
||||
</Card>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
import { apiGet } from '@/lib/http'
|
||||
import type { AdminDashboardSummary } from '@/types/admin'
|
||||
import type { AdminDashboardSummary, AdminLoginLogListResult } from '@/types/admin'
|
||||
|
||||
export function fetchAdminDashboardSummary() {
|
||||
return apiGet<AdminDashboardSummary>('/api/v1/admin/dashboard/summary')
|
||||
}
|
||||
|
||||
export function fetchAdminLoginLogs(params: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
username?: string
|
||||
dateFrom?: string
|
||||
dateTo?: string
|
||||
} = {}) {
|
||||
const search = new URLSearchParams()
|
||||
if (params.page) search.set('page', String(params.page))
|
||||
if (params.pageSize) search.set('pageSize', String(params.pageSize))
|
||||
if (params.username) search.set('username', params.username)
|
||||
if (params.dateFrom) search.set('dateFrom', params.dateFrom)
|
||||
if (params.dateTo) search.set('dateTo', params.dateTo)
|
||||
const queryString = search.toString()
|
||||
return apiGet<AdminLoginLogListResult>(
|
||||
`/api/v1/admin/dashboard/login-logs${queryString ? `?${queryString}` : ''}`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ export type { AdminAuditLogItem } from './audit-logs'
|
||||
// Dashboard types
|
||||
export type { AdminDashboardSummary } from './dashboard'
|
||||
|
||||
// Login logs
|
||||
export type { AdminLoginLogItem, AdminLoginLogListResult } from './login-logs'
|
||||
|
||||
// Orders types
|
||||
export type {
|
||||
AdminOrderFulfillmentProgress,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
export interface AdminLoginLogItem {
|
||||
logId: number
|
||||
userId: number | null
|
||||
username: string
|
||||
role: string
|
||||
ip: string
|
||||
location: string
|
||||
userAgent: string
|
||||
success: boolean
|
||||
failureReason: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface AdminLoginLogListResult {
|
||||
items: AdminLoginLogItem[]
|
||||
pagination: {
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user