Compare commits

..

4 Commits

Author SHA1 Message Date
zhx
00b8020599 小优化 2026-06-30 11:38:58 +08:00
zhx
7080538a00 用户靓号白名单 2026-06-30 10:52:45 +08:00
zhx
7d08e7cfca 白名单userid 2026-06-30 10:47:27 +08:00
zhx
9e72ff52c7 强制用户输入联系方式 2026-06-29 18:27:34 +08:00
12 changed files with 267 additions and 6 deletions

View File

@ -3871,6 +3871,42 @@
}, },
"put": { "put": {
"operationId": "replaceRegionBlocks", "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",
"maxLength": 64
}
}
}
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"$ref": "#/components/responses/EmptyResponse" "$ref": "#/components/responses/EmptyResponse"

View File

@ -30,6 +30,12 @@ const agencyColumns = [
render: (item) => <AgencyOwner item={item} />, render: (item) => <AgencyOwner item={item} />,
width: "minmax(220px, 1.2fr)", width: "minmax(220px, 1.2fr)",
}, },
{
key: "contact",
label: "联系方式",
render: (item) => item.contact || "-",
width: "minmax(150px, 0.85fr)",
},
{ {
key: "activeHostCount", key: "activeHostCount",
label: "子 Host", label: "子 Host",

View File

@ -33,6 +33,12 @@ const bdLeaderBaseColumns = [
render: (item, _index, context) => regionName(item, context?.regionOptions), render: (item, _index, context) => regionName(item, context?.regionOptions),
width: "minmax(130px, 0.75fr)", width: "minmax(130px, 0.75fr)",
}, },
{
key: "contact",
label: "联系方式",
render: (item) => item.contact || "-",
width: "minmax(150px, 0.85fr)",
},
{ {
key: "salaryWallet", key: "salaryWallet",
label: "工资钱包", label: "工资钱包",

View File

@ -28,6 +28,12 @@ const bdColumns = [
render: (item) => <span className={styles.inlineValue}>{formatCount(item.agencyCount)}</span>, render: (item) => <span className={styles.inlineValue}>{formatCount(item.agencyCount)}</span>,
width: "minmax(110px, 0.65fr)", width: "minmax(110px, 0.65fr)",
}, },
{
key: "contact",
label: "联系方式",
render: (item) => item.contact || "-",
width: "minmax(150px, 0.85fr)",
},
{ key: "role", label: "角色", width: "minmax(100px, 0.65fr)" }, { key: "role", label: "角色", width: "minmax(100px, 0.65fr)" },
{ {
key: "salaryWallet", key: "salaryWallet",

View File

@ -24,6 +24,12 @@ const hostColumns = [
render: (item) => <HostStatusSwitch item={item} />, render: (item) => <HostStatusSwitch item={item} />,
width: "minmax(90px, 0.7fr)", width: "minmax(90px, 0.7fr)",
}, },
{
key: "contact",
label: "联系方式",
render: (item) => item.contact || "-",
width: "minmax(150px, 0.85fr)",
},
{ {
key: "diamond", key: "diamond",
label: "钻石", label: "钻石",

View File

@ -19,9 +19,19 @@ export interface IPWhitelistDto {
updatedAtMs: number; updatedAtMs: number;
} }
export interface UserWhitelistDto {
whitelistId: number;
userId: string;
displayUserId: string;
enabled: boolean;
createdAtMs: number;
updatedAtMs: number;
}
export interface RegionBlockPayload { export interface RegionBlockPayload {
keywords: string[]; keywords: string[];
whitelist_ips?: string[]; whitelist_ips?: string[];
whitelist_user_ids?: string[];
} }
export interface AddRegionBlockWhitelistIPResult { export interface AddRegionBlockWhitelistIPResult {
@ -32,10 +42,13 @@ export interface AddRegionBlockWhitelistIPResult {
export interface RegionBlockConfigDto extends ApiList<RegionBlockDto> { export interface RegionBlockConfigDto extends ApiList<RegionBlockDto> {
whitelistItems: IPWhitelistDto[]; whitelistItems: IPWhitelistDto[];
whitelistTotal: number; whitelistTotal: number;
whitelistUserItems: UserWhitelistDto[];
whitelistUserTotal: number;
} }
type RawRegionBlock = RegionBlockDto & Record<string, unknown>; type RawRegionBlock = RegionBlockDto & Record<string, unknown>;
type RawIPWhitelist = IPWhitelistDto & Record<string, unknown>; type RawIPWhitelist = IPWhitelistDto & Record<string, unknown>;
type RawUserWhitelist = UserWhitelistDto & Record<string, unknown>;
type RawRegionBlockConfig = ApiList<RawRegionBlock> & Record<string, unknown>; type RawRegionBlockConfig = ApiList<RawRegionBlock> & Record<string, unknown>;
export function listRegionBlocks(): Promise<RegionBlockConfigDto> { export function listRegionBlocks(): Promise<RegionBlockConfigDto> {
@ -76,11 +89,14 @@ export async function addRegionBlockWhitelistIP(ipAddress: string): Promise<AddR
function normalizeRegionBlockConfig(data: RawRegionBlockConfig): RegionBlockConfigDto { function normalizeRegionBlockConfig(data: RawRegionBlockConfig): RegionBlockConfigDto {
const rawWhitelist = (data.whitelistItems ?? data.whitelist_items ?? []) as RawIPWhitelist[]; const rawWhitelist = (data.whitelistItems ?? data.whitelist_items ?? []) as RawIPWhitelist[];
const rawUserWhitelist = (data.whitelistUserItems ?? data.whitelist_user_items ?? []) as RawUserWhitelist[];
return { return {
items: (data.items || []).map(normalizeRegionBlock), items: (data.items || []).map(normalizeRegionBlock),
total: Number(data.total || 0), total: Number(data.total || 0),
whitelistItems: rawWhitelist.map(normalizeIPWhitelist), whitelistItems: rawWhitelist.map(normalizeIPWhitelist),
whitelistTotal: numberValue(data.whitelistTotal ?? data.whitelist_total), whitelistTotal: numberValue(data.whitelistTotal ?? data.whitelist_total),
whitelistUserItems: rawUserWhitelist.map(normalizeUserWhitelist),
whitelistUserTotal: numberValue(data.whitelistUserTotal ?? data.whitelist_user_total),
}; };
} }
@ -105,6 +121,17 @@ function normalizeIPWhitelist(item: RawIPWhitelist): IPWhitelistDto {
}; };
} }
function normalizeUserWhitelist(item: RawUserWhitelist): UserWhitelistDto {
return {
whitelistId: numberValue(item.whitelistId ?? item.whitelist_id),
userId: stringValue(item.userId ?? item.user_id),
displayUserId: stringValue(item.displayUserId ?? item.display_user_id),
enabled: Boolean(item.enabled),
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
};
}
function stringValue(value: unknown) { function stringValue(value: unknown) {
return value === undefined || value === null ? "" : String(value); return value === undefined || value === null ? "" : String(value);
} }

View File

@ -4,9 +4,14 @@ import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
import { useToast } from "@/shared/ui/ToastProvider.jsx"; import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { listRegionBlocks, replaceRegionBlocks } from "@/features/region-blocks/api"; import { listRegionBlocks, replaceRegionBlocks } from "@/features/region-blocks/api";
import { useRegionBlockAbilities } from "@/features/region-blocks/permissions.js"; import { useRegionBlockAbilities } from "@/features/region-blocks/permissions.js";
import { regionBlockIPSchema, regionBlockKeywordSchema, regionBlockReplaceSchema } from "@/features/region-blocks/schema"; import {
regionBlockIPSchema,
regionBlockKeywordSchema,
regionBlockReplaceSchema,
regionBlockUserIDSchema,
} from "@/features/region-blocks/schema";
const emptyData = { items: [], total: 0, whitelistItems: [], whitelistTotal: 0 }; const emptyData = { items: [], total: 0, whitelistItems: [], whitelistTotal: 0, whitelistUserItems: [], whitelistUserTotal: 0 };
const countryCodePattern = /^[A-Za-z]{2,3}$/; const countryCodePattern = /^[A-Za-z]{2,3}$/;
export function useRegionBlockPage() { export function useRegionBlockPage() {
@ -14,8 +19,10 @@ export function useRegionBlockPage() {
const { showToast } = useToast(); const { showToast } = useToast();
const [keywordInput, setKeywordInput] = useState(""); const [keywordInput, setKeywordInput] = useState("");
const [ipInput, setIPInput] = useState(""); const [ipInput, setIPInput] = useState("");
const [userIDInput, setUserIDInput] = useState("");
const [draftKeywords, setDraftKeywords] = useState([]); const [draftKeywords, setDraftKeywords] = useState([]);
const [draftWhitelistIPs, setDraftWhitelistIPs] = useState([]); const [draftWhitelistIPs, setDraftWhitelistIPs] = useState([]);
const [draftWhitelistUserIDs, setDraftWhitelistUserIDs] = useState([]);
const [dirty, setDirty] = useState(false); const [dirty, setDirty] = useState(false);
const [whitelistDialogOpen, setWhitelistDialogOpen] = useState(false); const [whitelistDialogOpen, setWhitelistDialogOpen] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@ -34,8 +41,13 @@ export function useRegionBlockPage() {
() => (data.whitelistItems || []).map((item) => item.ipAddress).filter(Boolean), () => (data.whitelistItems || []).map((item) => item.ipAddress).filter(Boolean),
[data.whitelistItems], [data.whitelistItems],
); );
const serverWhitelistUserIDs = useMemo(
() => (data.whitelistUserItems || []).map((item) => item.userId).filter(Boolean),
[data.whitelistUserItems],
);
const serverKeywordKey = serverKeywords.join("\n"); const serverKeywordKey = serverKeywords.join("\n");
const serverWhitelistKey = serverWhitelistIPs.join("\n"); const serverWhitelistKey = serverWhitelistIPs.join("\n");
const serverWhitelistUserKey = serverWhitelistUserIDs.join("\n");
const serverBlockByKeyword = useMemo(() => { const serverBlockByKeyword = useMemo(() => {
const blocks = new Map(); const blocks = new Map();
(data.items || []).forEach((item) => blocks.set(normalizeKey(item.keyword), item)); (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)); (data.whitelistItems || []).forEach((item) => items.set(normalizeKey(item.ipAddress), item));
return items; return items;
}, [data.whitelistItems]); }, [data.whitelistItems]);
const serverWhitelistByUserID = useMemo(() => {
const items = new Map();
(data.whitelistUserItems || []).forEach((item) => items.set(normalizeKey(item.userId), item));
return items;
}, [data.whitelistUserItems]);
useEffect(() => { useEffect(() => {
if (!dirty) { if (!dirty) {
setDraftKeywords(serverKeywords); setDraftKeywords(serverKeywords);
setDraftWhitelistIPs(serverWhitelistIPs); setDraftWhitelistIPs(serverWhitelistIPs);
setDraftWhitelistUserIDs(serverWhitelistUserIDs);
} }
}, [dirty, serverKeywordKey, serverKeywords, serverWhitelistIPs, serverWhitelistKey]); }, [dirty, serverKeywordKey, serverKeywords, serverWhitelistIPs, serverWhitelistKey, serverWhitelistUserIDs, serverWhitelistUserKey]);
const draftItems = useMemo( const draftItems = useMemo(
() => () =>
@ -87,6 +105,23 @@ export function useRegionBlockPage() {
}), }),
[draftWhitelistIPs, serverWhitelistByIP], [draftWhitelistIPs, serverWhitelistByIP],
); );
const draftWhitelistUserItems = useMemo(
() =>
draftWhitelistUserIDs.map((userID, index) => {
const saved = serverWhitelistByUserID.get(normalizeKey(userID));
return (
saved || {
whitelistId: `draft-user-${index}-${userID}`,
userId: userID,
displayUserId: "",
createdAtMs: 0,
enabled: true,
updatedAtMs: 0,
}
);
}),
[draftWhitelistUserIDs, serverWhitelistByUserID],
);
const addKeyword = () => { const addKeyword = () => {
if (!abilities.canUpdate) { if (!abilities.canUpdate) {
@ -134,6 +169,29 @@ export function useRegionBlockPage() {
addIP(); 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/短 ID/靓号参数不正确");
}
};
const submitUserIDInput = (event) => {
event.preventDefault();
addUserID();
};
const removeKeyword = (keyword) => { const removeKeyword = (keyword) => {
if (!abilities.canUpdate) { if (!abilities.canUpdate) {
return; return;
@ -150,11 +208,21 @@ export function useRegionBlockPage() {
setDirty(true); setDirty(true);
}; };
const removeUserID = (userID) => {
if (!abilities.canUpdate) {
return;
}
setDraftWhitelistUserIDs(draftWhitelistUserIDs.filter((item) => normalizeKey(item) !== normalizeKey(userID)));
setDirty(true);
};
const resetDraft = () => { const resetDraft = () => {
setDraftKeywords(serverKeywords); setDraftKeywords(serverKeywords);
setDraftWhitelistIPs(serverWhitelistIPs); setDraftWhitelistIPs(serverWhitelistIPs);
setDraftWhitelistUserIDs(serverWhitelistUserIDs);
setKeywordInput(""); setKeywordInput("");
setIPInput(""); setIPInput("");
setUserIDInput("");
setDirty(false); setDirty(false);
}; };
@ -164,10 +232,15 @@ export function useRegionBlockPage() {
} }
setSaving(true); setSaving(true);
try { 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); const saved = await replaceRegionBlocks(payload);
setDraftKeywords((saved.items || []).map((item) => item.keyword).filter(Boolean)); setDraftKeywords((saved.items || []).map((item) => item.keyword).filter(Boolean));
setDraftWhitelistIPs((saved.whitelistItems || []).map((item) => item.ipAddress).filter(Boolean)); setDraftWhitelistIPs((saved.whitelistItems || []).map((item) => item.ipAddress).filter(Boolean));
setDraftWhitelistUserIDs((saved.whitelistUserItems || []).map((item) => item.userId).filter(Boolean));
setDirty(false); setDirty(false);
showToast("地区屏蔽配置已保存", "success"); showToast("地区屏蔽配置已保存", "success");
await reload(); await reload();
@ -182,9 +255,11 @@ export function useRegionBlockPage() {
abilities, abilities,
addIP, addIP,
addKeyword, addKeyword,
addUserID,
dirty, dirty,
draftItems, draftItems,
draftWhitelistItems, draftWhitelistItems,
draftWhitelistUserItems,
error, error,
ipInput, ipInput,
keywordInput, keywordInput,
@ -193,13 +268,17 @@ export function useRegionBlockPage() {
reload, reload,
removeIP, removeIP,
removeKeyword, removeKeyword,
removeUserID,
resetDraft, resetDraft,
save, save,
saving, saving,
setIPInput, setIPInput,
setKeywordInput, setKeywordInput,
setUserIDInput,
submitIPInput, submitIPInput,
submitInput, submitInput,
submitUserIDInput,
userIDInput,
whitelistDialogOpen, whitelistDialogOpen,
closeWhitelistDialog: () => setWhitelistDialogOpen(false), closeWhitelistDialog: () => setWhitelistDialogOpen(false),
}; };

View File

@ -23,6 +23,7 @@ export function RegionBlockPage() {
const page = useRegionBlockPage(); const page = useRegionBlockPage();
const columns = buildColumns(page); const columns = buildColumns(page);
const whitelistColumns = buildWhitelistColumns(page); const whitelistColumns = buildWhitelistColumns(page);
const userWhitelistColumns = buildUserWhitelistColumns(page);
return ( return (
<AdminListPage> <AdminListPage>
@ -100,6 +101,25 @@ export function RegionBlockPage() {
> >
白名单IP列表 白名单IP列表
</Button> </Button>
<form className={styles.addForm} onSubmit={page.submitUserIDInput}>
<TextField
className={styles.userInput}
disabled={!page.abilities.canUpdate || page.saving}
label="用户ID/短ID/靓号白名单"
placeholder="900025 或 VIP2026"
size="small"
value={page.userIDInput}
onChange={(event) => page.setUserIDInput(event.target.value)}
/>
<Button
disabled={!page.abilities.canUpdate || page.saving}
startIcon={<AddOutlined fontSize="small" />}
type="submit"
variant="primary"
>
添加用户
</Button>
</form>
</div> </div>
} }
/> />
@ -123,6 +143,15 @@ export function RegionBlockPage() {
rowKey={(item) => item.whitelistId || item.ipAddress} rowKey={(item) => item.whitelistId || item.ipAddress}
/> />
</div> </div>
<div className={styles.tableGroup}>
<h2 className={styles.tableTitle}>用户ID/短ID/靓号白名单</h2>
<DataTable
columns={userWhitelistColumns}
items={page.draftWhitelistUserItems}
minWidth="860px"
rowKey={(item) => item.whitelistId || item.userId}
/>
</div>
</AdminListBody> </AdminListBody>
</DataState> </DataState>
<WhitelistDialog page={page} /> <WhitelistDialog page={page} />
@ -208,6 +237,48 @@ function buildWhitelistColumns(page) {
]; ];
} }
function buildUserWhitelistColumns(page) {
return [
{
key: "userId",
label: "内部用户ID",
width: "minmax(220px, 1fr)",
render: (item) => <KeywordCell item={{ keyword: item.userId, enabled: item.enabled }} />,
},
{
key: "displayUserId",
label: "当前短ID/靓号",
width: "minmax(180px, 0.8fr)",
render: (item) => item.displayUserId || (String(item.whitelistId || "").startsWith("draft-user-") ? "保存后解析" : "-"),
},
{
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.removeUserID(item.userId)}
>
<DeleteOutlineOutlined fontSize="small" />
</IconButton>
</span>
</Tooltip>
),
},
];
}
function buildWhitelistDialogColumns() { function buildWhitelistDialogColumns() {
return [ return [
{ {

View File

@ -20,6 +20,10 @@
flex: 0 1 220px; flex: 0 1 220px;
} }
.userInput {
flex: 0 1 280px;
}
.tableGroup { .tableGroup {
display: grid; display: grid;
min-width: 0; min-width: 0;
@ -112,7 +116,8 @@
} }
.keywordInput, .keywordInput,
.ipInput { .ipInput,
.userInput {
flex: 1 1 auto; flex: 1 1 auto;
width: 100%; width: 100%;
} }

View File

@ -13,9 +13,17 @@ export const regionBlockIPSchema = z
.max(64, "IP 不能超过 64 个字符") .max(64, "IP 不能超过 64 个字符")
.refine((value) => isIPv4(value) || isIPv6(value), "请输入正确的 IP 地址"); .refine((value) => isIPv4(value) || isIPv6(value), "请输入正确的 IP 地址");
export const regionBlockUserIDSchema = z
.string()
.trim()
.min(1, "请输入用户 ID/短 ID/靓号")
.max(64, "用户 ID/短 ID/靓号不能超过 64 个字符")
.refine((value) => !/\s/.test(value), "用户 ID/短 ID/靓号不能包含空白");
export const regionBlockReplaceSchema = z.object({ export const regionBlockReplaceSchema = z.object({
keywords: z.array(regionBlockKeywordSchema).max(200, "最多配置 200 个屏蔽词"), keywords: z.array(regionBlockKeywordSchema).max(200, "最多配置 200 个屏蔽词"),
whitelist_ips: z.array(regionBlockIPSchema).max(500, "最多配置 500 个白名单 IP"), whitelist_ips: z.array(regionBlockIPSchema).max(500, "最多配置 500 个白名单 IP"),
whitelist_user_ids: z.array(regionBlockUserIDSchema).max(500, "最多配置 500 个白名单用户"),
}); });
export type RegionBlockReplaceForm = z.infer<typeof regionBlockReplaceSchema>; export type RegionBlockReplaceForm = z.infer<typeof regionBlockReplaceSchema>;

View File

@ -7051,7 +7051,15 @@ export interface operations {
path?: never; path?: never;
cookie?: never; cookie?: never;
}; };
requestBody?: never; requestBody?: {
content: {
"application/json": {
keywords: string[];
whitelist_ips?: string[];
whitelist_user_ids?: string[];
};
};
};
responses: { responses: {
200: components["responses"]["EmptyResponse"]; 200: components["responses"]["EmptyResponse"];
}; };

View File

@ -575,6 +575,7 @@ export interface BDProfileDto {
createdByDisplayUserId?: string; createdByDisplayUserId?: string;
createdByUsername?: string; createdByUsername?: string;
createdByUserId?: number; createdByUserId?: number;
contact?: string;
displayUserId?: string; displayUserId?: string;
prettyDisplayUserId?: string; prettyDisplayUserId?: string;
prettyId?: string; prettyId?: string;
@ -603,6 +604,7 @@ export interface AgencyDto {
maxHosts?: number; maxHosts?: number;
name?: string; name?: string;
ownerAvatar?: string; ownerAvatar?: string;
contact?: string;
ownerDisplayUserId?: string; ownerDisplayUserId?: string;
ownerUsername?: string; ownerUsername?: string;
ownerUserId?: string; ownerUserId?: string;
@ -678,6 +680,7 @@ export interface HostProfileDto {
currentAgencyOwnerDisplayUserId?: string; currentAgencyOwnerDisplayUserId?: string;
currentAgencyOwnerUserId?: string; currentAgencyOwnerUserId?: string;
currentAgencyOwnerUsername?: string; currentAgencyOwnerUsername?: string;
contact?: string;
currentMembershipId?: number; currentMembershipId?: number;
diamond?: number; diamond?: number;
displayUserId?: string; displayUserId?: string;