import { useCallback, useEffect, useMemo, useState } from "react"; import { Button, Card, DatePicker, Descriptions, Drawer, Form, Input, Select, Space, Statistic, Table, Tag, Typography, type TableProps, } from "antd"; import dayjs, { type Dayjs } from "dayjs"; import { RefreshCw, Search, ShieldCheck } from "lucide-react"; import { usePageSdk } from "openxiangda/runtime/react"; import type { LoginLogListParams, LoginLogRecord, LoginLogStats, PageOffsetListResult, } from "openxiangda/runtime"; import { useAdminPageMetaController } from "@/layouts/AdminShell"; type FilterValues = { keyword?: string; method?: string; range?: [Dayjs, Dayjs]; source?: string; sourceAppType?: string; status?: "success" | "failure"; }; type QueryState = { filters: Omit; limit: number; page: number; }; const emptyStats: LoginLogStats = { total: 0, success: 0, failure: 0, byMethod: [], }; const statusOptions = [ { label: "全部状态", value: "" }, { label: "成功", value: "success" }, { label: "失败", value: "failure" }, ]; const methodOptions = [ { label: "全部方式", value: "" }, { label: "账号密码", value: "password" }, { label: "钉钉免登", value: "dingtalk" }, { label: "SSO", value: "sso" }, { label: "手机号验证码", value: "phone_code" }, { label: "游客访问", value: "guest" }, { label: "票据免登", value: "ticket" }, { label: "CLI 授权", value: "cli_authorization" }, { label: "账号切换", value: "impersonate" }, ]; const sourceOptions = [ { label: "全部来源", value: "" }, { label: "平台", value: "platform" }, { label: "应用登录", value: "openxiangda_app" }, { label: "钉钉", value: "dingtalk" }, { label: "SSO", value: "sso" }, { label: "公开游客", value: "public_guest" }, { label: "开放票据", value: "openapi_frontend" }, { label: "验证票据", value: "openxiangda_verification" }, { label: "CLI", value: "openxiangda_cli" }, ]; const trimText = (value?: string) => { const text = String(value || "").trim(); return text || undefined; }; const formatDateTime = (value?: string | Date | null) => value ? dayjs(value).format("YYYY-MM-DD HH:mm:ss") : "-"; const normalizeFilters = ( values: FilterValues, ): Omit => { const [startAt, endAt] = values.range || []; return { keyword: trimText(values.keyword), method: trimText(values.method), source: trimText(values.source), sourceAppType: trimText(values.sourceAppType), status: values.status || undefined, startAt: startAt ? startAt.startOf("day").toISOString() : undefined, endAt: endAt ? endAt.endOf("day").toISOString() : undefined, }; }; const statusTag = (status?: string) => status === "success" ? ( 成功 ) : ( 失败 ); const dash = (value?: string | number | null) => value === undefined || value === null || value === "" ? "-" : String(value); export function LoginLogPage() { const sdk = usePageSdk(); const setPageMeta = useAdminPageMetaController(); const [form] = Form.useForm(); const [query, setQuery] = useState({ filters: {}, limit: 20, page: 1, }); const [items, setItems] = useState([]); const [total, setTotal] = useState(0); const [stats, setStats] = useState(emptyStats); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [detailOpen, setDetailOpen] = useState(false); const [detailLoading, setDetailLoading] = useState(false); const [detail, setDetail] = useState(null); useEffect(() => { setPageMeta({ breadcrumbs: ["首页", "应用工作台", "登录日志"], title: "登录日志", }); return () => setPageMeta(null); }, [setPageMeta]); const loadData = useCallback(async () => { setLoading(true); setError(""); try { const [listResponse, statsResponse] = await Promise.all([ sdk.loginLog.list>({ ...query.filters, page: query.page, limit: query.limit, }), sdk.loginLog.stats({ startAt: query.filters.startAt, endAt: query.filters.endAt, }), ]); const result = listResponse.result; setItems(result?.items || []); setTotal(Number(result?.total || 0)); setStats(statsResponse.result || emptyStats); } catch (err) { setError(err instanceof Error ? err.message : "登录日志加载失败"); } finally { setLoading(false); } }, [query, sdk]); useEffect(() => { void loadData(); }, [loadData]); const successRate = useMemo(() => { if (!stats.total) return "0.0"; return ((stats.success / stats.total) * 100).toFixed(1); }, [stats.success, stats.total]); const columns = useMemo["columns"]>( () => [ { dataIndex: "createdAt", key: "createdAt", render: value => formatDateTime(value), title: "时间", width: 180, }, { key: "user", render: (_, record) => ( {dash(record.name || record.username)} {dash(record.jobNumber || record.userId)} ), title: "用户", width: 220, }, { dataIndex: "status", key: "status", render: statusTag, title: "状态", width: 90, }, { key: "method", render: (_, record) => ( {dash(record.method)} {dash(record.source)} ), title: "方式 / 来源", width: 160, }, { dataIndex: "sourceAppType", key: "sourceAppType", render: value => dash(value), title: "来源应用", width: 140, }, { key: "client", render: (_, record) => ( {dash(record.ipAddress)} {dash(record.userAgent)} ), title: "客户端", width: 300, }, { key: "failure", render: (_, record) => record.status === "failure" ? ( {dash(record.failureCode)} {dash(record.failureReason)} ) : ( "-" ), title: "失败原因", width: 320, }, { fixed: "right", key: "action", render: (_, record) => ( ), title: "操作", width: 90, }, ], [], ); const handleSearch = (values: FilterValues) => { setQuery(current => ({ ...current, filters: normalizeFilters(values), page: 1, })); }; const handleReset = () => { form.resetFields(); setQuery(current => ({ ...current, filters: {}, page: 1 })); }; const handleTableChange: NonNullable["onChange"]> = pagination => { setQuery(current => ({ ...current, limit: pagination.pageSize || current.limit, page: pagination.current || 1, })); }; const openDetail = async (id: string) => { setDetailOpen(true); setDetailLoading(true); setDetail(null); try { const response = await sdk.loginLog.get(id, { includeSensitive: true, }); setDetail(response.result || null); } catch (err) { setError(err instanceof Error ? err.message : "登录日志详情加载失败"); } finally { setDetailLoading(false); } }; return (
} title="总登录事件" value={stats.total} />
{error ? (
{error}
) : null}
columns={columns} dataSource={items} loading={loading} onChange={handleTableChange} pagination={{ current: query.page, pageSize: query.limit, showSizeChanger: true, total, }} rowKey="id" scroll={{ x: 1420 }} size="small" />
setDetailOpen(false)} open={detailOpen} title="登录日志详情" width={620} > {detail ? (
元数据
                {JSON.stringify(detail.metadata || {}, null, 2)}
              
) : ( 暂无详情 )}
); }