diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index c80ad89..d92bb7b 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -3871,6 +3871,43 @@ }, "put": { "operationId": "replaceRegionBlocks", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["keywords"], + "properties": { + "keywords": { + "type": "array", + "maxItems": 200, + "items": { + "type": "string", + "maxLength": 128 + } + }, + "whitelist_ips": { + "type": "array", + "maxItems": 500, + "items": { + "type": "string", + "maxLength": 64 + } + }, + "whitelist_user_ids": { + "type": "array", + "maxItems": 500, + "items": { + "type": "string", + "pattern": "^[1-9][0-9]*$", + "maxLength": 20 + } + } + } + } + } + } + }, "responses": { "200": { "$ref": "#/components/responses/EmptyResponse" diff --git a/src/features/region-blocks/api.ts b/src/features/region-blocks/api.ts index fc23bda..623d224 100644 --- a/src/features/region-blocks/api.ts +++ b/src/features/region-blocks/api.ts @@ -19,9 +19,18 @@ export interface IPWhitelistDto { updatedAtMs: number; } +export interface UserWhitelistDto { + whitelistId: number; + userId: string; + enabled: boolean; + createdAtMs: number; + updatedAtMs: number; +} + export interface RegionBlockPayload { keywords: string[]; whitelist_ips?: string[]; + whitelist_user_ids?: string[]; } export interface AddRegionBlockWhitelistIPResult { @@ -32,10 +41,13 @@ export interface AddRegionBlockWhitelistIPResult { export interface RegionBlockConfigDto extends ApiList { whitelistItems: IPWhitelistDto[]; whitelistTotal: number; + whitelistUserItems: UserWhitelistDto[]; + whitelistUserTotal: number; } type RawRegionBlock = RegionBlockDto & Record; type RawIPWhitelist = IPWhitelistDto & Record; +type RawUserWhitelist = UserWhitelistDto & Record; type RawRegionBlockConfig = ApiList & Record; export function listRegionBlocks(): Promise { @@ -76,11 +88,14 @@ export async function addRegionBlockWhitelistIP(ipAddress: string): Promise (data.whitelistItems || []).map((item) => item.ipAddress).filter(Boolean), [data.whitelistItems], ); + const serverWhitelistUserIDs = useMemo( + () => (data.whitelistUserItems || []).map((item) => item.userId).filter(Boolean), + [data.whitelistUserItems], + ); const serverKeywordKey = serverKeywords.join("\n"); const serverWhitelistKey = serverWhitelistIPs.join("\n"); + const serverWhitelistUserKey = serverWhitelistUserIDs.join("\n"); const serverBlockByKeyword = useMemo(() => { const blocks = new Map(); (data.items || []).forEach((item) => blocks.set(normalizeKey(item.keyword), item)); @@ -46,13 +58,19 @@ export function useRegionBlockPage() { (data.whitelistItems || []).forEach((item) => items.set(normalizeKey(item.ipAddress), item)); return items; }, [data.whitelistItems]); + const serverWhitelistByUserID = useMemo(() => { + const items = new Map(); + (data.whitelistUserItems || []).forEach((item) => items.set(normalizeKey(item.userId), item)); + return items; + }, [data.whitelistUserItems]); useEffect(() => { if (!dirty) { setDraftKeywords(serverKeywords); setDraftWhitelistIPs(serverWhitelistIPs); + setDraftWhitelistUserIDs(serverWhitelistUserIDs); } - }, [dirty, serverKeywordKey, serverKeywords, serverWhitelistIPs, serverWhitelistKey]); + }, [dirty, serverKeywordKey, serverKeywords, serverWhitelistIPs, serverWhitelistKey, serverWhitelistUserIDs, serverWhitelistUserKey]); const draftItems = useMemo( () => @@ -87,6 +105,22 @@ export function useRegionBlockPage() { }), [draftWhitelistIPs, serverWhitelistByIP], ); + const draftWhitelistUserItems = useMemo( + () => + draftWhitelistUserIDs.map((userID, index) => { + const saved = serverWhitelistByUserID.get(normalizeKey(userID)); + return ( + saved || { + whitelistId: `draft-user-${index}-${userID}`, + userId: userID, + createdAtMs: 0, + enabled: true, + updatedAtMs: 0, + } + ); + }), + [draftWhitelistUserIDs, serverWhitelistByUserID], + ); const addKeyword = () => { if (!abilities.canUpdate) { @@ -134,6 +168,29 @@ export function useRegionBlockPage() { addIP(); }; + const addUserID = () => { + if (!abilities.canUpdate) { + return; + } + try { + const userID = parseForm(regionBlockUserIDSchema, userIDInput); + if (draftWhitelistUserIDs.some((item) => normalizeKey(item) === normalizeKey(userID))) { + showToast("白名单用户已存在", "warning"); + return; + } + setDraftWhitelistUserIDs([...draftWhitelistUserIDs, userID]); + setUserIDInput(""); + setDirty(true); + } catch (err) { + showValidationError(err, showToast, "用户 ID 参数不正确"); + } + }; + + const submitUserIDInput = (event) => { + event.preventDefault(); + addUserID(); + }; + const removeKeyword = (keyword) => { if (!abilities.canUpdate) { return; @@ -150,11 +207,21 @@ export function useRegionBlockPage() { setDirty(true); }; + const removeUserID = (userID) => { + if (!abilities.canUpdate) { + return; + } + setDraftWhitelistUserIDs(draftWhitelistUserIDs.filter((item) => normalizeKey(item) !== normalizeKey(userID))); + setDirty(true); + }; + const resetDraft = () => { setDraftKeywords(serverKeywords); setDraftWhitelistIPs(serverWhitelistIPs); + setDraftWhitelistUserIDs(serverWhitelistUserIDs); setKeywordInput(""); setIPInput(""); + setUserIDInput(""); setDirty(false); }; @@ -164,10 +231,15 @@ export function useRegionBlockPage() { } setSaving(true); try { - const payload = parseForm(regionBlockReplaceSchema, { keywords: draftKeywords, whitelist_ips: draftWhitelistIPs }); + const payload = parseForm(regionBlockReplaceSchema, { + keywords: draftKeywords, + whitelist_ips: draftWhitelistIPs, + whitelist_user_ids: draftWhitelistUserIDs, + }); const saved = await replaceRegionBlocks(payload); setDraftKeywords((saved.items || []).map((item) => item.keyword).filter(Boolean)); setDraftWhitelistIPs((saved.whitelistItems || []).map((item) => item.ipAddress).filter(Boolean)); + setDraftWhitelistUserIDs((saved.whitelistUserItems || []).map((item) => item.userId).filter(Boolean)); setDirty(false); showToast("地区屏蔽配置已保存", "success"); await reload(); @@ -182,9 +254,11 @@ export function useRegionBlockPage() { abilities, addIP, addKeyword, + addUserID, dirty, draftItems, draftWhitelistItems, + draftWhitelistUserItems, error, ipInput, keywordInput, @@ -193,13 +267,17 @@ export function useRegionBlockPage() { reload, removeIP, removeKeyword, + removeUserID, resetDraft, save, saving, setIPInput, setKeywordInput, + setUserIDInput, submitIPInput, submitInput, + submitUserIDInput, + userIDInput, whitelistDialogOpen, closeWhitelistDialog: () => setWhitelistDialogOpen(false), }; diff --git a/src/features/region-blocks/pages/RegionBlockPage.jsx b/src/features/region-blocks/pages/RegionBlockPage.jsx index fc7e383..5add838 100644 --- a/src/features/region-blocks/pages/RegionBlockPage.jsx +++ b/src/features/region-blocks/pages/RegionBlockPage.jsx @@ -23,6 +23,7 @@ export function RegionBlockPage() { const page = useRegionBlockPage(); const columns = buildColumns(page); const whitelistColumns = buildWhitelistColumns(page); + const userWhitelistColumns = buildUserWhitelistColumns(page); return ( @@ -100,6 +101,25 @@ export function RegionBlockPage() { > 白名单IP列表 +
+ page.setUserIDInput(event.target.value)} + /> + + } /> @@ -123,6 +143,15 @@ export function RegionBlockPage() { rowKey={(item) => item.whitelistId || item.ipAddress} /> +
+

用户ID白名单

+ item.whitelistId || item.userId} + /> +
@@ -208,6 +237,42 @@ function buildWhitelistColumns(page) { ]; } +function buildUserWhitelistColumns(page) { + return [ + { + key: "userId", + label: "用户ID", + 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.removeUserID(item.userId)} + > + + + + + ), + }, + ]; +} + function buildWhitelistDialogColumns() { return [ { diff --git a/src/features/region-blocks/region-blocks.module.css b/src/features/region-blocks/region-blocks.module.css index 6771780..17706c0 100644 --- a/src/features/region-blocks/region-blocks.module.css +++ b/src/features/region-blocks/region-blocks.module.css @@ -20,6 +20,10 @@ flex: 0 1 220px; } +.userInput { + flex: 0 1 220px; +} + .tableGroup { display: grid; min-width: 0; @@ -112,7 +116,8 @@ } .keywordInput, - .ipInput { + .ipInput, + .userInput { flex: 1 1 auto; width: 100%; } diff --git a/src/features/region-blocks/schema.ts b/src/features/region-blocks/schema.ts index 7ba55cf..120dc7c 100644 --- a/src/features/region-blocks/schema.ts +++ b/src/features/region-blocks/schema.ts @@ -13,9 +13,17 @@ export const regionBlockIPSchema = z .max(64, "IP 不能超过 64 个字符") .refine((value) => isIPv4(value) || isIPv6(value), "请输入正确的 IP 地址"); +export const regionBlockUserIDSchema = z + .string() + .trim() + .min(1, "请输入用户 ID") + .max(20, "用户 ID 不能超过 20 位") + .regex(/^[1-9]\d*$/, "请输入正确的用户 ID"); + export const regionBlockReplaceSchema = z.object({ keywords: z.array(regionBlockKeywordSchema).max(200, "最多配置 200 个屏蔽词"), whitelist_ips: z.array(regionBlockIPSchema).max(500, "最多配置 500 个白名单 IP"), + whitelist_user_ids: z.array(regionBlockUserIDSchema).max(500, "最多配置 500 个白名单用户"), }); export type RegionBlockReplaceForm = z.infer; diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index b878728..a814045 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -7051,7 +7051,15 @@ export interface operations { path?: never; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": { + keywords: string[]; + whitelist_ips?: string[]; + whitelist_user_ids?: string[]; + }; + }; + }; responses: { 200: components["responses"]["EmptyResponse"]; };