diff --git a/src/features/region-blocks/api.ts b/src/features/region-blocks/api.ts index 088c6f2..3817810 100644 --- a/src/features/region-blocks/api.ts +++ b/src/features/region-blocks/api.ts @@ -11,34 +11,54 @@ export interface RegionBlockDto { updatedAtMs: number; } +export interface IPWhitelistDto { + whitelistId: number; + ipAddress: string; + enabled: boolean; + createdAtMs: number; + updatedAtMs: number; +} + export interface RegionBlockPayload { keywords: string[]; + whitelist_ips?: string[]; +} + +export interface RegionBlockConfigDto extends ApiList { + whitelistItems: IPWhitelistDto[]; + whitelistTotal: number; } type RawRegionBlock = RegionBlockDto & Record; +type RawIPWhitelist = IPWhitelistDto & Record; +type RawRegionBlockConfig = ApiList & Record; -export function listRegionBlocks(): Promise> { +export function listRegionBlocks(): Promise { const endpoint = API_ENDPOINTS.listRegionBlocks; - return apiRequest>(apiEndpointPath(API_OPERATIONS.listRegionBlocks), { + return apiRequest(apiEndpointPath(API_OPERATIONS.listRegionBlocks), { method: endpoint.method, - }).then((data) => ({ - items: (data.items || []).map(normalizeRegionBlock), - total: Number(data.total || 0), - })); + }).then(normalizeRegionBlockConfig); } -export function replaceRegionBlocks(payload: RegionBlockPayload): Promise> { +export function replaceRegionBlocks(payload: RegionBlockPayload): Promise { const endpoint = API_ENDPOINTS.replaceRegionBlocks; - return apiRequest, RegionBlockPayload>( + return apiRequest( apiEndpointPath(API_OPERATIONS.replaceRegionBlocks), { body: payload, method: endpoint.method, }, - ).then((data) => ({ + ).then(normalizeRegionBlockConfig); +} + +function normalizeRegionBlockConfig(data: RawRegionBlockConfig): RegionBlockConfigDto { + const rawWhitelist = (data.whitelistItems ?? data.whitelist_items ?? []) as RawIPWhitelist[]; + return { items: (data.items || []).map(normalizeRegionBlock), total: Number(data.total || 0), - })); + whitelistItems: rawWhitelist.map(normalizeIPWhitelist), + whitelistTotal: numberValue(data.whitelistTotal ?? data.whitelist_total), + }; } function normalizeRegionBlock(item: RawRegionBlock): RegionBlockDto { @@ -52,6 +72,16 @@ function normalizeRegionBlock(item: RawRegionBlock): RegionBlockDto { }; } +function normalizeIPWhitelist(item: RawIPWhitelist): IPWhitelistDto { + return { + whitelistId: numberValue(item.whitelistId ?? item.whitelist_id), + ipAddress: stringValue(item.ipAddress ?? item.ip_address), + enabled: Boolean(item.enabled), + createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms), + updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms), + }; +} + function stringValue(value: unknown) { return value === undefined || value === null ? "" : String(value); } diff --git a/src/features/region-blocks/hooks/useRegionBlockPage.js b/src/features/region-blocks/hooks/useRegionBlockPage.js index 4e1bd24..8eae524 100644 --- a/src/features/region-blocks/hooks/useRegionBlockPage.js +++ b/src/features/region-blocks/hooks/useRegionBlockPage.js @@ -4,16 +4,18 @@ import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js"; import { useToast } from "@/shared/ui/ToastProvider.jsx"; import { listRegionBlocks, replaceRegionBlocks } from "@/features/region-blocks/api"; import { useRegionBlockAbilities } from "@/features/region-blocks/permissions.js"; -import { regionBlockKeywordSchema, regionBlockReplaceSchema } from "@/features/region-blocks/schema"; +import { regionBlockIPSchema, regionBlockKeywordSchema, regionBlockReplaceSchema } from "@/features/region-blocks/schema"; -const emptyData = { items: [], total: 0 }; +const emptyData = { items: [], total: 0, whitelistItems: [], whitelistTotal: 0 }; const countryCodePattern = /^[A-Za-z]{2,3}$/; export function useRegionBlockPage() { const abilities = useRegionBlockAbilities(); const { showToast } = useToast(); const [keywordInput, setKeywordInput] = useState(""); + const [ipInput, setIPInput] = useState(""); const [draftKeywords, setDraftKeywords] = useState([]); + const [draftWhitelistIPs, setDraftWhitelistIPs] = useState([]); const [dirty, setDirty] = useState(false); const [saving, setSaving] = useState(false); const { @@ -27,18 +29,29 @@ export function useRegionBlockPage() { queryKey: ["region-blocks"], }); const serverKeywords = useMemo(() => (data.items || []).map((item) => item.keyword).filter(Boolean), [data.items]); + const serverWhitelistIPs = useMemo( + () => (data.whitelistItems || []).map((item) => item.ipAddress).filter(Boolean), + [data.whitelistItems], + ); const serverKeywordKey = serverKeywords.join("\n"); + const serverWhitelistKey = serverWhitelistIPs.join("\n"); const serverBlockByKeyword = useMemo(() => { const blocks = new Map(); (data.items || []).forEach((item) => blocks.set(normalizeKey(item.keyword), item)); return blocks; }, [data.items]); + const serverWhitelistByIP = useMemo(() => { + const items = new Map(); + (data.whitelistItems || []).forEach((item) => items.set(normalizeKey(item.ipAddress), item)); + return items; + }, [data.whitelistItems]); useEffect(() => { if (!dirty) { setDraftKeywords(serverKeywords); + setDraftWhitelistIPs(serverWhitelistIPs); } - }, [dirty, serverKeywordKey, serverKeywords]); + }, [dirty, serverKeywordKey, serverKeywords, serverWhitelistIPs, serverWhitelistKey]); const draftItems = useMemo( () => @@ -57,6 +70,22 @@ export function useRegionBlockPage() { }), [draftKeywords, serverBlockByKeyword], ); + const draftWhitelistItems = useMemo( + () => + draftWhitelistIPs.map((ip, index) => { + const saved = serverWhitelistByIP.get(normalizeKey(ip)); + return ( + saved || { + whitelistId: `draft-${index}-${ip}`, + ipAddress: ip, + createdAtMs: 0, + enabled: true, + updatedAtMs: 0, + } + ); + }), + [draftWhitelistIPs, serverWhitelistByIP], + ); const addKeyword = () => { if (!abilities.canUpdate) { @@ -81,6 +110,29 @@ export function useRegionBlockPage() { addKeyword(); }; + const addIP = () => { + if (!abilities.canUpdate) { + return; + } + try { + const ip = parseForm(regionBlockIPSchema, ipInput); + if (draftWhitelistIPs.some((item) => normalizeKey(item) === normalizeKey(ip))) { + showToast("白名单 IP 已存在", "warning"); + return; + } + setDraftWhitelistIPs([...draftWhitelistIPs, ip]); + setIPInput(""); + setDirty(true); + } catch (err) { + showValidationError(err, showToast, "IP 参数不正确"); + } + }; + + const submitIPInput = (event) => { + event.preventDefault(); + addIP(); + }; + const removeKeyword = (keyword) => { if (!abilities.canUpdate) { return; @@ -89,9 +141,19 @@ export function useRegionBlockPage() { setDirty(true); }; + const removeIP = (ip) => { + if (!abilities.canUpdate) { + return; + } + setDraftWhitelistIPs(draftWhitelistIPs.filter((item) => normalizeKey(item) !== normalizeKey(ip))); + setDirty(true); + }; + const resetDraft = () => { setDraftKeywords(serverKeywords); + setDraftWhitelistIPs(serverWhitelistIPs); setKeywordInput(""); + setIPInput(""); setDirty(false); }; @@ -101,9 +163,10 @@ export function useRegionBlockPage() { } setSaving(true); try { - const payload = parseForm(regionBlockReplaceSchema, { keywords: draftKeywords }); + const payload = parseForm(regionBlockReplaceSchema, { keywords: draftKeywords, whitelist_ips: draftWhitelistIPs }); const saved = await replaceRegionBlocks(payload); setDraftKeywords((saved.items || []).map((item) => item.keyword).filter(Boolean)); + setDraftWhitelistIPs((saved.whitelistItems || []).map((item) => item.ipAddress).filter(Boolean)); setDirty(false); showToast("地区屏蔽配置已保存", "success"); await reload(); @@ -116,18 +179,24 @@ export function useRegionBlockPage() { return { abilities, + addIP, addKeyword, dirty, draftItems, + draftWhitelistItems, error, + ipInput, keywordInput, loading, reload, + removeIP, removeKeyword, resetDraft, save, saving, + setIPInput, setKeywordInput, + submitIPInput, submitInput, }; } diff --git a/src/features/region-blocks/pages/RegionBlockPage.jsx b/src/features/region-blocks/pages/RegionBlockPage.jsx index df9b76b..503522a 100644 --- a/src/features/region-blocks/pages/RegionBlockPage.jsx +++ b/src/features/region-blocks/pages/RegionBlockPage.jsx @@ -17,6 +17,7 @@ import styles from "@/features/region-blocks/region-blocks.module.css"; export function RegionBlockPage() { const page = useRegionBlockPage(); const columns = buildColumns(page); + const whitelistColumns = buildWhitelistColumns(page); return ( @@ -48,35 +49,68 @@ export function RegionBlockPage() { } filters={ -
- page.setKeywordInput(event.target.value)} - /> - - +
+
+ page.setKeywordInput(event.target.value)} + /> + + +
+ page.setIPInput(event.target.value)} + /> + + +
} /> - item.blockId || item.keyword} - /> +
+

地区屏蔽

+ item.blockId || item.keyword} + /> +
+
+

IP白名单

+ item.whitelistId || item.ipAddress} + /> +
@@ -125,6 +159,42 @@ function buildColumns(page) { ]; } +function buildWhitelistColumns(page) { + 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) : "-"), + }, + { + key: "actions", + label: "操作", + width: "minmax(96px, 0.4fr)", + render: (item) => ( + + + page.removeIP(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 682712e..510855c 100644 --- a/src/features/region-blocks/region-blocks.module.css +++ b/src/features/region-blocks/region-blocks.module.css @@ -1,3 +1,10 @@ +.forms { + display: flex; + min-width: 0; + flex-wrap: wrap; + gap: var(--space-3); +} + .addForm { display: flex; min-width: 0; @@ -9,6 +16,27 @@ flex: 0 1 320px; } +.ipInput { + flex: 0 1 220px; +} + +.tableGroup { + display: grid; + min-width: 0; + gap: var(--space-3); +} + +.tableGroup + .tableGroup { + margin-top: var(--space-5); +} + +.tableTitle { + margin: 0; + color: var(--text-primary); + font-size: var(--admin-font-size-lg); + font-weight: 700; +} + .keywordCell { display: grid; min-width: 0; @@ -29,12 +57,18 @@ } @media (max-width: 760px) { + .forms { + align-items: stretch; + flex-direction: column; + } + .addForm { align-items: stretch; flex-direction: column; } - .keywordInput { + .keywordInput, + .ipInput { flex: 1 1 auto; width: 100%; } diff --git a/src/features/region-blocks/schema.ts b/src/features/region-blocks/schema.ts index 888326d..7ba55cf 100644 --- a/src/features/region-blocks/schema.ts +++ b/src/features/region-blocks/schema.ts @@ -6,8 +6,34 @@ export const regionBlockKeywordSchema = z .min(1, "请输入屏蔽词") .max(128, "屏蔽词不能超过 128 个字符"); +export const regionBlockIPSchema = z + .string() + .trim() + .min(1, "请输入 IP") + .max(64, "IP 不能超过 64 个字符") + .refine((value) => isIPv4(value) || isIPv6(value), "请输入正确的 IP 地址"); + export const regionBlockReplaceSchema = z.object({ keywords: z.array(regionBlockKeywordSchema).max(200, "最多配置 200 个屏蔽词"), + whitelist_ips: z.array(regionBlockIPSchema).max(500, "最多配置 500 个白名单 IP"), }); export type RegionBlockReplaceForm = z.infer; + +function isIPv4(value: string) { + const parts = value.split("."); + return ( + parts.length === 4 && + parts.every((part) => { + if (!/^\d{1,3}$/.test(part)) { + return false; + } + const number = Number(part); + return number >= 0 && number <= 255 && String(number) === part; + }) + ); +} + +function isIPv6(value: string) { + return value.includes(":") && /^[0-9a-fA-F:.]+$/.test(value) && value.split(":").length >= 3; +}