diff --git a/src/features/app-users/pages/AppUserLoginLogsPage.jsx b/src/features/app-users/pages/AppUserLoginLogsPage.jsx index 3d07922..7726eef 100644 --- a/src/features/app-users/pages/AppUserLoginLogsPage.jsx +++ b/src/features/app-users/pages/AppUserLoginLogsPage.jsx @@ -1,16 +1,25 @@ +import AddModeratorOutlined from "@mui/icons-material/AddModeratorOutlined"; +import Tooltip from "@mui/material/Tooltip"; +import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useSearchParams } from "react-router-dom"; import { toPageQuery } from "@/shared/api/query"; +import { FormValidationError, parseForm } from "@/shared/forms/validation"; import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx"; import { DataState } from "@/shared/ui/DataState.jsx"; import { DataTable } from "@/shared/ui/DataTable.jsx"; +import { IconButton } from "@/shared/ui/IconButton.jsx"; import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js"; import { mergePaginatedItems } from "@/shared/hooks/usePaginatedQuery.js"; import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js"; +import { useToast } from "@/shared/ui/ToastProvider.jsx"; import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx"; import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js"; import { formatMillis } from "@/shared/utils/time.js"; import { listAppUserLoginLogs } from "@/features/app-users/api"; +import { addRegionBlockWhitelistIP } from "@/features/region-blocks/api"; +import { useRegionBlockAbilities } from "@/features/region-blocks/permissions.js"; +import { regionBlockIPSchema } from "@/features/region-blocks/schema"; import styles from "@/features/app-users/app-users.module.css"; const pageSize = 50; @@ -23,10 +32,14 @@ const resultOptions = [ export function AppUserLoginLogsPage() { const [searchParams, setSearchParams] = useSearchParams(); + const queryClient = useQueryClient(); + const { showToast } = useToast(); + const regionBlockAbilities = useRegionBlockAbilities(); const [query, setQuery] = useState(""); const [result, setResult] = useState(""); const [regionId, setRegionId] = useState(""); const [page, setPage] = useState(1); + const [addingWhitelistIP, setAddingWhitelistIP] = useState(""); const [mergedData, setMergedData] = useState(emptyData); const { loadingRegions, regionOptions } = useRegionOptions(); const userFilter = searchParams.get("user_id") || ""; @@ -132,6 +145,27 @@ export function AppUserLoginLogsPage() { [changeQuery, setSearchParams, userFilter], ); + const addLoginIPToWhitelist = useCallback( + async (log) => { + if (!regionBlockAbilities.canUpdate || addingWhitelistIP) { + return; + } + + try { + const ipAddress = parseForm(regionBlockIPSchema, log.loginIp); + setAddingWhitelistIP(ipAddress); + const result = await addRegionBlockWhitelistIP(ipAddress); + await queryClient.invalidateQueries({ queryKey: ["region-blocks"] }); + showToast(result.added ? "IP 已添加到白名单" : "白名单 IP 已存在", result.added ? "success" : "warning"); + } catch (err) { + showValidationError(err, showToast, "添加白名单 IP 失败"); + } finally { + setAddingWhitelistIP(""); + } + }, + [addingWhitelistIP, queryClient, regionBlockAbilities.canUpdate, showToast], + ); + const selectedUserLabel = useMemo(() => { if (!userFilter) { return ""; @@ -233,8 +267,23 @@ export function AppUserLoginLogsPage() { width: "180px", render: (log) => {formatMillis(log.createdAtMs)}, }, + { + key: "actions", + label: "操作", + width: "120px", + render: (log) => ( + + ), + }, ], [ + addLoginIPToWhitelist, + addingWhitelistIP, changeIdentityFilter, changeRegionId, changeResult, @@ -244,6 +293,7 @@ export function AppUserLoginLogsPage() { regionId, resetIdentityFilter, result, + regionBlockAbilities.canUpdate, selectedUserLabel, userFilter, ], @@ -258,7 +308,7 @@ export function AppUserLoginLogsPage() { columns={columns} emptyLabel="暂无登录日志" items={mergedData.items || []} - minWidth="1380px" + minWidth="1500px" getRowProps={(log) => loginLogRowProps(log, showUserHistory)} rowKey={(log) => log.id} pagination={ @@ -279,6 +329,33 @@ export function AppUserLoginLogsPage() { ); } +function LoginLogActions({ addingWhitelistIP, canUpdateWhitelist, log, onAddToWhitelist }) { + const ipAddress = String(log.loginIp || "").trim(); + const adding = Boolean(addingWhitelistIP) && addingWhitelistIP === ipAddress; + const disabled = !canUpdateWhitelist || !ipAddress || Boolean(addingWhitelistIP); + const title = !canUpdateWhitelist ? "无白名单更新权限" : ipAddress ? "添加IP到白名单" : "该日志没有IP"; + + return ( +
event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + + + onAddToWhitelist(log)} + > + + + + +
+ ); +} + function loginLogRowProps(log, onOpenHistory) { if (!log.userId) { return {}; @@ -297,6 +374,14 @@ function loginLogRowProps(log, onOpenHistory) { }; } +function showValidationError(err, showToast, fallback) { + if (err instanceof FormValidationError) { + showToast(err.message, "error"); + return; + } + showToast(err?.message || fallback, "error"); +} + function ResultBadge({ log }) { const result = String(log.result || "").toLowerCase(); const blocked = log.blocked || result === "blocked"; diff --git a/src/features/region-blocks/api.ts b/src/features/region-blocks/api.ts index 3817810..fc23bda 100644 --- a/src/features/region-blocks/api.ts +++ b/src/features/region-blocks/api.ts @@ -24,6 +24,11 @@ export interface RegionBlockPayload { whitelist_ips?: string[]; } +export interface AddRegionBlockWhitelistIPResult { + added: boolean; + config: RegionBlockConfigDto; +} + export interface RegionBlockConfigDto extends ApiList { whitelistItems: IPWhitelistDto[]; whitelistTotal: number; @@ -51,6 +56,24 @@ export function replaceRegionBlocks(payload: RegionBlockPayload): Promise { + const current = await listRegionBlocks(); + const keywords = (current.items || []).map((item) => item.keyword).filter(Boolean); + const whitelistIPs = (current.whitelistItems || []).map((item) => item.ipAddress).filter(Boolean); + const normalizedIP = stringValue(ipAddress).trim(); + + // 后端当前只提供整份配置替换接口;快捷追加前先读取最新配置并合并,避免把已保存的屏蔽词或其他 IP 覆盖掉。 + if (whitelistIPs.some((item) => normalizeKey(item) === normalizeKey(normalizedIP))) { + return { added: false, config: current }; + } + + const config = await replaceRegionBlocks({ + keywords, + whitelist_ips: [...whitelistIPs, normalizedIP], + }); + return { added: true, config }; +} + function normalizeRegionBlockConfig(data: RawRegionBlockConfig): RegionBlockConfigDto { const rawWhitelist = (data.whitelistItems ?? data.whitelist_items ?? []) as RawIPWhitelist[]; return { @@ -86,6 +109,10 @@ function stringValue(value: unknown) { return value === undefined || value === null ? "" : String(value); } +function normalizeKey(value: unknown) { + return String(value || "").trim().toLowerCase(); +} + function numberValue(value: unknown) { const number = Number(value || 0); return Number.isFinite(number) ? number : 0; diff --git a/src/features/region-blocks/hooks/useRegionBlockPage.js b/src/features/region-blocks/hooks/useRegionBlockPage.js index 8eae524..33e2c2b 100644 --- a/src/features/region-blocks/hooks/useRegionBlockPage.js +++ b/src/features/region-blocks/hooks/useRegionBlockPage.js @@ -17,6 +17,7 @@ export function useRegionBlockPage() { const [draftKeywords, setDraftKeywords] = useState([]); const [draftWhitelistIPs, setDraftWhitelistIPs] = useState([]); const [dirty, setDirty] = useState(false); + const [whitelistDialogOpen, setWhitelistDialogOpen] = useState(false); const [saving, setSaving] = useState(false); const { data = emptyData, @@ -188,6 +189,7 @@ export function useRegionBlockPage() { ipInput, keywordInput, loading, + openWhitelistDialog: () => setWhitelistDialogOpen(true), reload, removeIP, removeKeyword, @@ -198,6 +200,8 @@ export function useRegionBlockPage() { setKeywordInput, submitIPInput, submitInput, + whitelistDialogOpen, + closeWhitelistDialog: () => setWhitelistDialogOpen(false), }; } diff --git a/src/features/region-blocks/pages/RegionBlockPage.jsx b/src/features/region-blocks/pages/RegionBlockPage.jsx index 503522a..fc7e383 100644 --- a/src/features/region-blocks/pages/RegionBlockPage.jsx +++ b/src/features/region-blocks/pages/RegionBlockPage.jsx @@ -1,8 +1,13 @@ import AddOutlined from "@mui/icons-material/AddOutlined"; import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined"; +import FormatListBulletedOutlined from "@mui/icons-material/FormatListBulletedOutlined"; import RefreshOutlined from "@mui/icons-material/RefreshOutlined"; import RestartAltOutlined from "@mui/icons-material/RestartAltOutlined"; import SaveOutlined from "@mui/icons-material/SaveOutlined"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogTitle from "@mui/material/DialogTitle"; import TextField from "@mui/material/TextField"; import Tooltip from "@mui/material/Tooltip"; import { Button } from "@/shared/ui/Button.jsx"; @@ -88,6 +93,13 @@ export function RegionBlockPage() { 添加IP + } /> @@ -113,6 +125,7 @@ export function RegionBlockPage() { + ); } @@ -195,6 +208,54 @@ function buildWhitelistColumns(page) { ]; } +function buildWhitelistDialogColumns() { + return [ + { + key: "ipAddress", + label: "IP", + width: "minmax(220px, 1fr)", + render: (item) => , + }, + { + key: "updatedAtMs", + label: "更新时间", + width: "minmax(180px, 0.8fr)", + render: (item) => (item.updatedAtMs ? formatMillis(item.updatedAtMs) : "-"), + }, + ]; +} + +function WhitelistDialog({ page }) { + const columns = buildWhitelistDialogColumns(); + + return ( + + + 白名单IP列表 + 共 {page.draftWhitelistItems.length} 个 + + + item.whitelistId || item.ipAddress} + /> + + + + + + ); +} + function KeywordCell({ item }) { return (
diff --git a/src/features/region-blocks/region-blocks.module.css b/src/features/region-blocks/region-blocks.module.css index 510855c..6771780 100644 --- a/src/features/region-blocks/region-blocks.module.css +++ b/src/features/region-blocks/region-blocks.module.css @@ -56,6 +56,50 @@ font-size: var(--admin-font-size); } +.whitelistDialogPaper { + max-height: min(720px, calc(100dvh - 48px)); + overflow: hidden; +} + +.whitelistDialogTitle { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + border-bottom: 1px solid var(--border); + color: var(--text-primary); + font-size: 18px; + font-weight: 750; +} + +.whitelistDialogTitle small { + color: var(--text-tertiary); + font-size: var(--admin-font-size); + font-weight: 650; +} + +.whitelistDialogContent { + display: flex; + min-height: 320px; + padding: 0 !important; +} + +.whitelistDialogContent :global(.table-frame) { + flex: 1; + min-height: 0; + border: 0; + border-radius: 0; + background: transparent; +} + +.whitelistDialogContent :global(.table-scroll) { + overflow: auto; +} + +.whitelistDialogActions { + border-top: 1px solid var(--border); +} + @media (max-width: 760px) { .forms { align-items: stretch;