ip白名单

This commit is contained in:
zhx 2026-06-23 12:11:00 +08:00
parent 8c23d7e6a7
commit 17251552d9
5 changed files with 269 additions and 40 deletions

View File

@ -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<RegionBlockDto> {
whitelistItems: IPWhitelistDto[];
whitelistTotal: number;
}
type RawRegionBlock = RegionBlockDto & Record<string, unknown>;
type RawIPWhitelist = IPWhitelistDto & Record<string, unknown>;
type RawRegionBlockConfig = ApiList<RawRegionBlock> & Record<string, unknown>;
export function listRegionBlocks(): Promise<ApiList<RegionBlockDto>> {
export function listRegionBlocks(): Promise<RegionBlockConfigDto> {
const endpoint = API_ENDPOINTS.listRegionBlocks;
return apiRequest<ApiList<RawRegionBlock>>(apiEndpointPath(API_OPERATIONS.listRegionBlocks), {
return apiRequest<RawRegionBlockConfig>(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<ApiList<RegionBlockDto>> {
export function replaceRegionBlocks(payload: RegionBlockPayload): Promise<RegionBlockConfigDto> {
const endpoint = API_ENDPOINTS.replaceRegionBlocks;
return apiRequest<ApiList<RawRegionBlock>, RegionBlockPayload>(
return apiRequest<RawRegionBlockConfig, RegionBlockPayload>(
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);
}

View File

@ -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,
};
}

View File

@ -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 (
<AdminListPage>
@ -48,35 +49,68 @@ export function RegionBlockPage() {
</>
}
filters={
<form className={styles.addForm} onSubmit={page.submitInput}>
<TextField
className={styles.keywordInput}
disabled={!page.abilities.canUpdate || page.saving}
label="屏蔽词"
placeholder="国家码或国家名称"
size="small"
value={page.keywordInput}
onChange={(event) => page.setKeywordInput(event.target.value)}
/>
<Button
disabled={!page.abilities.canUpdate || page.saving}
startIcon={<AddOutlined fontSize="small" />}
type="submit"
variant="primary"
>
添加
</Button>
</form>
<div className={styles.forms}>
<form className={styles.addForm} onSubmit={page.submitInput}>
<TextField
className={styles.keywordInput}
disabled={!page.abilities.canUpdate || page.saving}
label="屏蔽词"
placeholder="国家码或国家名称"
size="small"
value={page.keywordInput}
onChange={(event) => page.setKeywordInput(event.target.value)}
/>
<Button
disabled={!page.abilities.canUpdate || page.saving}
startIcon={<AddOutlined fontSize="small" />}
type="submit"
variant="primary"
>
添加屏蔽词
</Button>
</form>
<form className={styles.addForm} onSubmit={page.submitIPInput}>
<TextField
className={styles.ipInput}
disabled={!page.abilities.canUpdate || page.saving}
label="IP白名单"
placeholder="203.0.113.10"
size="small"
value={page.ipInput}
onChange={(event) => page.setIPInput(event.target.value)}
/>
<Button
disabled={!page.abilities.canUpdate || page.saving}
startIcon={<AddOutlined fontSize="small" />}
type="submit"
variant="primary"
>
添加IP
</Button>
</form>
</div>
}
/>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<AdminListBody>
<DataTable
columns={columns}
items={page.draftItems}
minWidth="880px"
rowKey={(item) => item.blockId || item.keyword}
/>
<div className={styles.tableGroup}>
<h2 className={styles.tableTitle}>地区屏蔽</h2>
<DataTable
columns={columns}
items={page.draftItems}
minWidth="880px"
rowKey={(item) => item.blockId || item.keyword}
/>
</div>
<div className={styles.tableGroup}>
<h2 className={styles.tableTitle}>IP白名单</h2>
<DataTable
columns={whitelistColumns}
items={page.draftWhitelistItems}
minWidth="720px"
rowKey={(item) => item.whitelistId || item.ipAddress}
/>
</div>
</AdminListBody>
</DataState>
</AdminListPage>
@ -125,6 +159,42 @@ function buildColumns(page) {
];
}
function buildWhitelistColumns(page) {
return [
{
key: "ipAddress",
label: "IP",
width: "minmax(220px, 1fr)",
render: (item) => <KeywordCell item={{ keyword: item.ipAddress, enabled: item.enabled }} />,
},
{
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) => (
<Tooltip arrow title="移除">
<span>
<IconButton
disabled={!page.abilities.canUpdate || page.saving}
label="移除"
tone="danger"
onClick={() => page.removeIP(item.ipAddress)}
>
<DeleteOutlineOutlined fontSize="small" />
</IconButton>
</span>
</Tooltip>
),
},
];
}
function KeywordCell({ item }) {
return (
<div className={styles.keywordCell}>

View File

@ -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%;
}

View File

@ -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<typeof regionBlockReplaceSchema>;
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;
}