Compare commits

..

No commits in common. "05412e477a799178c596b4afc0ed66d613f4a4b5" and "57231bc95a1d63b7082dddba4941c450cc36c4ee" have entirely different histories.

15 changed files with 461 additions and 712 deletions

View File

@ -5500,12 +5500,6 @@
"type": "object", "type": "object",
"required": ["countryCode", "maxRoomCount"], "required": ["countryCode", "maxRoomCount"],
"properties": { "properties": {
"allowedOwnerIds": {
"items": {
"type": "string"
},
"type": "array"
},
"countryCode": { "countryCode": {
"type": "string" "type": "string"
}, },

View File

@ -118,8 +118,6 @@ test("renders cp config route with an authenticated session", async () => {
renderWithRoute("/activities/cp-config"); renderWithRoute("/activities/cp-config");
expect((await screen.findAllByText("CP配置")).length).toBeGreaterThan(0); expect((await screen.findAllByText("CP配置")).length).toBeGreaterThan(0);
expect(await screen.findByText("已加载 1 条 · 共 1 条")).toBeInTheDocument();
expect(screen.queryByText("共 1 条")).not.toBeInTheDocument();
}); });
test("admin routes declare menu code and permission", () => { test("admin routes declare menu code and permission", () => {

View File

@ -93,6 +93,7 @@ export function CPConfigPage() {
rowKey={(item) => rowKey={(item) =>
isApplicationsTab ? item.applicationId : item.relationshipId || `${item.userA?.userId}-${item.userB?.userId}` isApplicationsTab ? item.applicationId : item.relationshipId || `${item.userA?.userId}-${item.userB?.userId}`
} }
total={activePage.total}
/> />
</AdminListBody> </AdminListBody>
</DataState> </DataState>

View File

@ -11,54 +11,34 @@ export interface RegionBlockDto {
updatedAtMs: number; updatedAtMs: number;
} }
export interface IPWhitelistDto {
whitelistId: number;
ipAddress: string;
enabled: boolean;
createdAtMs: number;
updatedAtMs: number;
}
export interface RegionBlockPayload { export interface RegionBlockPayload {
keywords: string[]; keywords: string[];
whitelist_ips?: string[];
}
export interface RegionBlockConfigDto extends ApiList<RegionBlockDto> {
whitelistItems: IPWhitelistDto[];
whitelistTotal: number;
} }
type RawRegionBlock = RegionBlockDto & Record<string, unknown>; type RawRegionBlock = RegionBlockDto & Record<string, unknown>;
type RawIPWhitelist = IPWhitelistDto & Record<string, unknown>;
type RawRegionBlockConfig = ApiList<RawRegionBlock> & Record<string, unknown>;
export function listRegionBlocks(): Promise<RegionBlockConfigDto> { export function listRegionBlocks(): Promise<ApiList<RegionBlockDto>> {
const endpoint = API_ENDPOINTS.listRegionBlocks; const endpoint = API_ENDPOINTS.listRegionBlocks;
return apiRequest<RawRegionBlockConfig>(apiEndpointPath(API_OPERATIONS.listRegionBlocks), { return apiRequest<ApiList<RawRegionBlock>>(apiEndpointPath(API_OPERATIONS.listRegionBlocks), {
method: endpoint.method, method: endpoint.method,
}).then(normalizeRegionBlockConfig); }).then((data) => ({
items: (data.items || []).map(normalizeRegionBlock),
total: Number(data.total || 0),
}));
} }
export function replaceRegionBlocks(payload: RegionBlockPayload): Promise<RegionBlockConfigDto> { export function replaceRegionBlocks(payload: RegionBlockPayload): Promise<ApiList<RegionBlockDto>> {
const endpoint = API_ENDPOINTS.replaceRegionBlocks; const endpoint = API_ENDPOINTS.replaceRegionBlocks;
return apiRequest<RawRegionBlockConfig, RegionBlockPayload>( return apiRequest<ApiList<RawRegionBlock>, RegionBlockPayload>(
apiEndpointPath(API_OPERATIONS.replaceRegionBlocks), apiEndpointPath(API_OPERATIONS.replaceRegionBlocks),
{ {
body: payload, body: payload,
method: endpoint.method, method: endpoint.method,
}, },
).then(normalizeRegionBlockConfig); ).then((data) => ({
}
function normalizeRegionBlockConfig(data: RawRegionBlockConfig): RegionBlockConfigDto {
const rawWhitelist = (data.whitelistItems ?? data.whitelist_items ?? []) as RawIPWhitelist[];
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), }));
whitelistTotal: numberValue(data.whitelistTotal ?? data.whitelist_total),
};
} }
function normalizeRegionBlock(item: RawRegionBlock): RegionBlockDto { function normalizeRegionBlock(item: RawRegionBlock): RegionBlockDto {
@ -72,16 +52,6 @@ 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) { function stringValue(value: unknown) {
return value === undefined || value === null ? "" : String(value); return value === undefined || value === null ? "" : String(value);
} }

View File

@ -4,18 +4,16 @@ 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 { regionBlockKeywordSchema, regionBlockReplaceSchema } from "@/features/region-blocks/schema";
const emptyData = { items: [], total: 0, whitelistItems: [], whitelistTotal: 0 }; const emptyData = { items: [], total: 0 };
const countryCodePattern = /^[A-Za-z]{2,3}$/; const countryCodePattern = /^[A-Za-z]{2,3}$/;
export function useRegionBlockPage() { export function useRegionBlockPage() {
const abilities = useRegionBlockAbilities(); const abilities = useRegionBlockAbilities();
const { showToast } = useToast(); const { showToast } = useToast();
const [keywordInput, setKeywordInput] = useState(""); const [keywordInput, setKeywordInput] = useState("");
const [ipInput, setIPInput] = useState("");
const [draftKeywords, setDraftKeywords] = useState([]); const [draftKeywords, setDraftKeywords] = useState([]);
const [draftWhitelistIPs, setDraftWhitelistIPs] = useState([]);
const [dirty, setDirty] = useState(false); const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const { const {
@ -29,29 +27,18 @@ export function useRegionBlockPage() {
queryKey: ["region-blocks"], queryKey: ["region-blocks"],
}); });
const serverKeywords = useMemo(() => (data.items || []).map((item) => item.keyword).filter(Boolean), [data.items]); 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 serverKeywordKey = serverKeywords.join("\n");
const serverWhitelistKey = serverWhitelistIPs.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));
return blocks; return blocks;
}, [data.items]); }, [data.items]);
const serverWhitelistByIP = useMemo(() => {
const items = new Map();
(data.whitelistItems || []).forEach((item) => items.set(normalizeKey(item.ipAddress), item));
return items;
}, [data.whitelistItems]);
useEffect(() => { useEffect(() => {
if (!dirty) { if (!dirty) {
setDraftKeywords(serverKeywords); setDraftKeywords(serverKeywords);
setDraftWhitelistIPs(serverWhitelistIPs);
} }
}, [dirty, serverKeywordKey, serverKeywords, serverWhitelistIPs, serverWhitelistKey]); }, [dirty, serverKeywordKey, serverKeywords]);
const draftItems = useMemo( const draftItems = useMemo(
() => () =>
@ -70,22 +57,6 @@ export function useRegionBlockPage() {
}), }),
[draftKeywords, serverBlockByKeyword], [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 = () => { const addKeyword = () => {
if (!abilities.canUpdate) { if (!abilities.canUpdate) {
@ -110,29 +81,6 @@ export function useRegionBlockPage() {
addKeyword(); 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) => { const removeKeyword = (keyword) => {
if (!abilities.canUpdate) { if (!abilities.canUpdate) {
return; return;
@ -141,19 +89,9 @@ export function useRegionBlockPage() {
setDirty(true); setDirty(true);
}; };
const removeIP = (ip) => {
if (!abilities.canUpdate) {
return;
}
setDraftWhitelistIPs(draftWhitelistIPs.filter((item) => normalizeKey(item) !== normalizeKey(ip)));
setDirty(true);
};
const resetDraft = () => { const resetDraft = () => {
setDraftKeywords(serverKeywords); setDraftKeywords(serverKeywords);
setDraftWhitelistIPs(serverWhitelistIPs);
setKeywordInput(""); setKeywordInput("");
setIPInput("");
setDirty(false); setDirty(false);
}; };
@ -163,10 +101,9 @@ export function useRegionBlockPage() {
} }
setSaving(true); setSaving(true);
try { try {
const payload = parseForm(regionBlockReplaceSchema, { keywords: draftKeywords, whitelist_ips: draftWhitelistIPs }); const payload = parseForm(regionBlockReplaceSchema, { keywords: draftKeywords });
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));
setDirty(false); setDirty(false);
showToast("地区屏蔽配置已保存", "success"); showToast("地区屏蔽配置已保存", "success");
await reload(); await reload();
@ -179,24 +116,18 @@ export function useRegionBlockPage() {
return { return {
abilities, abilities,
addIP,
addKeyword, addKeyword,
dirty, dirty,
draftItems, draftItems,
draftWhitelistItems,
error, error,
ipInput,
keywordInput, keywordInput,
loading, loading,
reload, reload,
removeIP,
removeKeyword, removeKeyword,
resetDraft, resetDraft,
save, save,
saving, saving,
setIPInput,
setKeywordInput, setKeywordInput,
submitIPInput,
submitInput, submitInput,
}; };
} }

View File

@ -17,7 +17,6 @@ import styles from "@/features/region-blocks/region-blocks.module.css";
export function RegionBlockPage() { export function RegionBlockPage() {
const page = useRegionBlockPage(); const page = useRegionBlockPage();
const columns = buildColumns(page); const columns = buildColumns(page);
const whitelistColumns = buildWhitelistColumns(page);
return ( return (
<AdminListPage> <AdminListPage>
@ -49,68 +48,35 @@ export function RegionBlockPage() {
</> </>
} }
filters={ filters={
<div className={styles.forms}> <form className={styles.addForm} onSubmit={page.submitInput}>
<form className={styles.addForm} onSubmit={page.submitInput}> <TextField
<TextField className={styles.keywordInput}
className={styles.keywordInput} disabled={!page.abilities.canUpdate || page.saving}
disabled={!page.abilities.canUpdate || page.saving} label="屏蔽词"
label="屏蔽词" placeholder="国家码或国家名称"
placeholder="国家码或国家名称" size="small"
size="small" value={page.keywordInput}
value={page.keywordInput} onChange={(event) => page.setKeywordInput(event.target.value)}
onChange={(event) => page.setKeywordInput(event.target.value)} />
/> <Button
<Button disabled={!page.abilities.canUpdate || page.saving}
disabled={!page.abilities.canUpdate || page.saving} startIcon={<AddOutlined fontSize="small" />}
startIcon={<AddOutlined fontSize="small" />} type="submit"
type="submit" variant="primary"
variant="primary" >
> 添加
添加屏蔽词 </Button>
</Button> </form>
</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}> <DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<AdminListBody> <AdminListBody>
<div className={styles.tableGroup}> <DataTable
<h2 className={styles.tableTitle}>地区屏蔽</h2> columns={columns}
<DataTable items={page.draftItems}
columns={columns} minWidth="880px"
items={page.draftItems} rowKey={(item) => item.blockId || item.keyword}
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> </AdminListBody>
</DataState> </DataState>
</AdminListPage> </AdminListPage>
@ -159,42 +125,6 @@ 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 }) { function KeywordCell({ item }) {
return ( return (
<div className={styles.keywordCell}> <div className={styles.keywordCell}>

View File

@ -1,10 +1,3 @@
.forms {
display: flex;
min-width: 0;
flex-wrap: wrap;
gap: var(--space-3);
}
.addForm { .addForm {
display: flex; display: flex;
min-width: 0; min-width: 0;
@ -16,27 +9,6 @@
flex: 0 1 320px; 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 { .keywordCell {
display: grid; display: grid;
min-width: 0; min-width: 0;
@ -57,18 +29,12 @@
} }
@media (max-width: 760px) { @media (max-width: 760px) {
.forms {
align-items: stretch;
flex-direction: column;
}
.addForm { .addForm {
align-items: stretch; align-items: stretch;
flex-direction: column; flex-direction: column;
} }
.keywordInput, .keywordInput {
.ipInput {
flex: 1 1 auto; flex: 1 1 auto;
width: 100%; width: 100%;
} }

View File

@ -6,34 +6,8 @@ export const regionBlockKeywordSchema = z
.min(1, "请输入屏蔽词") .min(1, "请输入屏蔽词")
.max(128, "屏蔽词不能超过 128 个字符"); .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({ 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"),
}); });
export type RegionBlockReplaceForm = z.infer<typeof regionBlockReplaceSchema>; 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;
}

View File

@ -70,7 +70,6 @@ function emptyHumanConfigForm() {
function emptyHumanCountryRule(countryOptions = [], existingRules = []) { function emptyHumanCountryRule(countryOptions = [], existingRules = []) {
return { return {
allowedOwnerIds: "",
countryCode: firstAvailableCountryCode(countryOptions, existingRules), countryCode: firstAvailableCountryCode(countryOptions, existingRules),
maxRoomCount: "1", maxRoomCount: "1",
}; };
@ -83,6 +82,7 @@ export function useRobotRoomsPage() {
const { showToast } = useToast(); const { showToast } = useToast();
const [activeAction, setActiveAction] = useState(""); const [activeAction, setActiveAction] = useState("");
const [availableRobotsState, setAvailableRobotsState] = useState({ error: "", loading: false, options: [] }); const [availableRobotsState, setAvailableRobotsState] = useState({ error: "", loading: false, options: [] });
const [candidatesTouched, setCandidatesTouched] = useState(false);
const [form, setForm] = useState(emptyForm); const [form, setForm] = useState(emptyForm);
const [giftOptionsState, setGiftOptionsState] = useState({ error: "", loading: false, options: [] }); const [giftOptionsState, setGiftOptionsState] = useState({ error: "", loading: false, options: [] });
const [humanConfigState, setHumanConfigState] = useState({ config: null, error: "", loading: false }); const [humanConfigState, setHumanConfigState] = useState({ config: null, error: "", loading: false });
@ -151,21 +151,14 @@ export function useRobotRoomsPage() {
}, [activeAction]); }, [activeAction]);
useEffect(() => { useEffect(() => {
if (activeAction !== "create") { if (activeAction !== "create" || candidatesTouched) {
return; return;
} }
const ownerRobotUserId = String(form.ownerRobotUserId || ""); const ids = availableRobotsState.options.map((item) => item.userId).filter(Boolean);
const ids = availableRobotsState.options if (ids.length > 0) {
.map((item) => item.userId) setForm((current) => ({ ...current, candidateRobotUserIds: ids }));
.filter((userId) => userId && String(userId) !== ownerRobotUserId); }
setForm((current) => { }, [activeAction, availableRobotsState.options, candidatesTouched]);
const currentIds = (current.candidateRobotUserIds || []).map(String);
if (currentIds.join(",") === ids.map(String).join(",")) {
return current;
}
return { ...current, candidateRobotUserIds: ids };
});
}, [activeAction, availableRobotsState.options, form.ownerRobotUserId]);
const changeStatus = (value) => { const changeStatus = (value) => {
setStatus(value || "active"); setStatus(value || "active");
@ -179,6 +172,7 @@ export function useRobotRoomsPage() {
const openCreate = () => { const openCreate = () => {
setActiveAction("create"); setActiveAction("create");
setCandidatesTouched(false);
setForm(emptyForm()); setForm(emptyForm());
setAvailableRobotsState({ error: "", loading: false, options: [] }); setAvailableRobotsState({ error: "", loading: false, options: [] });
setGiftOptionsState({ error: "", loading: false, options: [] }); setGiftOptionsState({ error: "", loading: false, options: [] });
@ -239,6 +233,16 @@ export function useRobotRoomsPage() {
})); }));
}; };
const selectCandidateRobots = (robots) => {
setCandidatesTouched(true);
setForm((current) => ({
...current,
candidateRobotUserIds: robots
.map((robot) => robot.userId)
.filter((userId) => userId && String(userId) !== String(current.ownerRobotUserId)),
}));
};
const selectGiftIds = (key, gifts) => { const selectGiftIds = (key, gifts) => {
setForm((current) => ({ ...current, [key]: gifts.map(giftId).filter(Boolean) })); setForm((current) => ({ ...current, [key]: gifts.map(giftId).filter(Boolean) }));
}; };
@ -309,6 +313,14 @@ export function useRobotRoomsPage() {
() => availableRobotsState.options.find((item) => item.userId === String(form.ownerRobotUserId)) || null, () => availableRobotsState.options.find((item) => item.userId === String(form.ownerRobotUserId)) || null,
[availableRobotsState.options, form.ownerRobotUserId], [availableRobotsState.options, form.ownerRobotUserId],
); );
const selectedCandidateRobots = useMemo(() => {
const selected = new Set((form.candidateRobotUserIds || []).map(String));
return availableRobotsState.options.filter((item) => selected.has(String(item.userId)));
}, [availableRobotsState.options, form.candidateRobotUserIds]);
const candidateRobotOptions = useMemo(
() => availableRobotsState.options.filter((item) => String(item.userId) !== String(form.ownerRobotUserId)),
[availableRobotsState.options, form.ownerRobotUserId],
);
const countryLabels = useMemo( const countryLabels = useMemo(
() => Object.fromEntries(countryOptions.map((option) => [option.value, option.label])), () => Object.fromEntries(countryOptions.map((option) => [option.value, option.label])),
[countryOptions], [countryOptions],
@ -353,6 +365,7 @@ export function useRobotRoomsPage() {
availableRobots: availableRobotsState.options, availableRobots: availableRobotsState.options,
changeStatus, changeStatus,
closeAction, closeAction,
candidateRobotOptions,
countryOptions, countryOptions,
data, data,
error, error,
@ -374,9 +387,11 @@ export function useRobotRoomsPage() {
page, page,
reload, reload,
resetFilters, resetFilters,
selectCandidateRobots,
selectGiftIds, selectGiftIds,
selectHumanGiftIds, selectHumanGiftIds,
selectOwnerRobot, selectOwnerRobot,
selectedCandidateRobots,
selectedGiftOptions, selectedGiftOptions,
selectedLuckyGiftOptions, selectedLuckyGiftOptions,
selectedHumanGiftOptions, selectedHumanGiftOptions,
@ -406,7 +421,6 @@ function humanConfigToForm(config = null) {
candidateRoomMaxOnline: String(config.candidateRoomMaxOnline ?? defaults.candidateRoomMaxOnline), candidateRoomMaxOnline: String(config.candidateRoomMaxOnline ?? defaults.candidateRoomMaxOnline),
countryLimitEnabled: Boolean(config.countryLimitEnabled), countryLimitEnabled: Boolean(config.countryLimitEnabled),
countryRules: (config.countryRules || []).map((rule) => ({ countryRules: (config.countryRules || []).map((rule) => ({
allowedOwnerIds: (rule.allowedOwnerIds || []).map(String).join(","),
countryCode: String(rule.countryCode || "").toUpperCase(), countryCode: String(rule.countryCode || "").toUpperCase(),
maxRoomCount: String(rule.maxRoomCount ?? "1"), maxRoomCount: String(rule.maxRoomCount ?? "1"),
})), })),

View File

@ -7,13 +7,7 @@ import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip"; import Tooltip from "@mui/material/Tooltip";
import { Button } from "@/shared/ui/Button.jsx"; import { Button } from "@/shared/ui/Button.jsx";
import { IconButton } from "@/shared/ui/IconButton.jsx"; import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx";
import {
AdminFormAmountField,
AdminFormDialog,
AdminFormFieldGrid,
AdminFormSection,
} from "@/shared/ui/AdminFormDialog.jsx";
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx"; import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { DataState } from "@/shared/ui/DataState.jsx"; import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx"; import { DataTable } from "@/shared/ui/DataTable.jsx";
@ -196,138 +190,190 @@ export function RoomRobotPage() {
onClose={page.closeAction} onClose={page.closeAction}
onSubmit={page.submitCreate} onSubmit={page.submitCreate}
> >
<AdminFormSection title="房主设置"> <Autocomplete
<Autocomplete getOptionLabel={robotOptionLabel}
getOptionLabel={robotOptionLabel} isOptionEqualToValue={(option, value) => option.userId === value.userId}
isOptionEqualToValue={(option, value) => option.userId === value.userId} loading={page.availableRobotsLoading}
loading={page.availableRobotsLoading} noOptionsText="当前无数据"
noOptionsText="当前无数据" options={page.availableRobots}
options={page.availableRobots} renderInput={(params) => (
renderInput={(params) => ( <TextField
<TextField {...params}
{...params} error={Boolean(page.availableRobotsError)}
error={Boolean(page.availableRobotsError)} helperText={page.availableRobotsError}
helperText={page.availableRobotsError} label="房主机器人"
label="房主机器人" required
required />
)}
renderOption={(props, option) => (
<li {...props} key={option.userId}>
<OwnerRobotOption
countryLabels={page.robotCountryLabels}
loadingLocation={page.loadingRobotLocationOptions}
option={option}
regionLabels={page.robotRegionLabels}
/> />
)} </li>
renderOption={(props, option) => ( )}
<li {...props} key={option.userId}> size="small"
<OwnerRobotOption value={page.selectedOwnerRobot}
countryLabels={page.robotCountryLabels} onChange={(_, option) => page.selectOwnerRobot(option)}
loadingLocation={page.loadingRobotLocationOptions} />
option={option} <Autocomplete
regionLabels={page.robotRegionLabels} multiple
/> disableCloseOnSelect
</li> getOptionLabel={robotOptionLabel}
)} isOptionEqualToValue={(option, value) => option.userId === value.userId}
size="small" loading={page.availableRobotsLoading}
value={page.selectedOwnerRobot} noOptionsText="当前无数据"
onChange={(_, option) => page.selectOwnerRobot(option)} options={page.candidateRobotOptions}
/> renderInput={(params) => (
</AdminFormSection> <TextField
<AdminFormSection title="进房规则"> {...params}
<AdminFormFieldGrid> error={Boolean(page.availableRobotsError)}
<ConfigNumberField helperText={page.availableRobotsError}
label="最少进房人数" label={<RequiredLabel>候选机器人</RequiredLabel>}
value={page.form.minRobotCount}
onChange={(value) => page.setForm({ ...page.form, minRobotCount: value })}
/> />
<ConfigNumberField )}
label="最多进房人数" renderOption={(props, option) => (
value={page.form.maxRobotCount} <li {...props} key={option.userId}>
onChange={(value) => page.setForm({ ...page.form, maxRobotCount: value })} <RobotIdentity openInAppUserDetail={false} user={option.user} fallbackId={option.userId} />
/> </li>
</AdminFormFieldGrid> )}
</AdminFormSection> size="small"
<AdminFormSection title="普通礼物规则"> value={page.selectedCandidateRobots}
<GiftAutocomplete onChange={(_, options) => page.selectCandidateRobots(options)}
error={Boolean(page.giftOptionsError)} />
helperText={page.giftOptionsError} <div className={styles.formGrid}>
label="礼物合集" <TextField
loading={page.giftOptionsLoading} label="最少进房人数"
options={page.giftOptions}
required required
value={page.selectedGiftOptions} type="number"
onChange={(options) => page.selectGiftIds("giftIds", options)} value={page.form.minRobotCount}
onChange={(event) => page.setForm({ ...page.form, minRobotCount: event.target.value })}
/> />
<AdminFormFieldGrid> <TextField
<ConfigNumberField label="最多进房人数"
label="普通礼物频次"
unit="毫秒"
value={page.form.normalGiftIntervalMs}
onChange={(value) => page.setForm({ ...page.form, normalGiftIntervalMs: value })}
/>
<ConfigNumberField
label="最短停留"
unit="毫秒"
value={page.form.robotStayMinMs}
onChange={(value) => page.setForm({ ...page.form, robotStayMinMs: value })}
/>
<ConfigNumberField
label="最长停留"
unit="毫秒"
value={page.form.robotStayMaxMs}
onChange={(value) => page.setForm({ ...page.form, robotStayMaxMs: value })}
/>
<ConfigNumberField
label="最短补位"
min={0}
unit="毫秒"
value={page.form.robotReplaceMinMs}
onChange={(value) => page.setForm({ ...page.form, robotReplaceMinMs: value })}
/>
<ConfigNumberField
label="最长补位"
min={0}
unit="毫秒"
value={page.form.robotReplaceMaxMs}
onChange={(value) => page.setForm({ ...page.form, robotReplaceMaxMs: value })}
/>
<ConfigNumberField
label="同时送礼上限"
value={page.form.maxGiftSenders}
onChange={(value) => page.setForm({ ...page.form, maxGiftSenders: value })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="幸运礼物规则">
<GiftAutocomplete
error={Boolean(page.giftOptionsError)}
helperText={page.giftOptionsError}
label="幸运礼物合集"
loading={page.giftOptionsLoading}
options={page.giftOptions}
required required
value={page.selectedLuckyGiftOptions} type="number"
onChange={(options) => page.selectGiftIds("luckyGiftIds", options)} value={page.form.maxRobotCount}
onChange={(event) => page.setForm({ ...page.form, maxRobotCount: event.target.value })}
/> />
<AdminFormFieldGrid> </div>
<ConfigNumberField <Autocomplete
label="最少幸运连击" multiple
value={page.form.luckyComboMin} disableCloseOnSelect
onChange={(value) => page.setForm({ ...page.form, luckyComboMin: value })} getOptionLabel={giftOptionLabel}
isOptionEqualToValue={(option, value) => giftId(option) === giftId(value)}
loading={page.giftOptionsLoading}
noOptionsText="当前无数据"
options={page.giftOptions}
renderInput={(params) => (
<TextField
{...params}
error={Boolean(page.giftOptionsError)}
helperText={page.giftOptionsError}
label={<RequiredLabel>礼物合集</RequiredLabel>}
/> />
<ConfigNumberField )}
label="最多幸运连击" size="small"
value={page.form.luckyComboMax} value={page.selectedGiftOptions}
onChange={(value) => page.setForm({ ...page.form, luckyComboMax: value })} onChange={(_, options) => page.selectGiftIds("giftIds", options)}
/>
<TextField
label="普通礼物频次(毫秒)"
required
type="number"
value={page.form.normalGiftIntervalMs}
onChange={(event) => page.setForm({ ...page.form, normalGiftIntervalMs: event.target.value })}
/>
<div className={styles.formGrid}>
<TextField
label="最短停留(毫秒)"
required
type="number"
value={page.form.robotStayMinMs}
onChange={(event) => page.setForm({ ...page.form, robotStayMinMs: event.target.value })}
/>
<TextField
label="最长停留(毫秒)"
required
type="number"
value={page.form.robotStayMaxMs}
onChange={(event) => page.setForm({ ...page.form, robotStayMaxMs: event.target.value })}
/>
<TextField
label="最短补位(毫秒)"
required
type="number"
value={page.form.robotReplaceMinMs}
onChange={(event) => page.setForm({ ...page.form, robotReplaceMinMs: event.target.value })}
/>
<TextField
label="最长补位(毫秒)"
required
type="number"
value={page.form.robotReplaceMaxMs}
onChange={(event) => page.setForm({ ...page.form, robotReplaceMaxMs: event.target.value })}
/>
<TextField
label="同时送礼上限"
required
type="number"
value={page.form.maxGiftSenders}
onChange={(event) => page.setForm({ ...page.form, maxGiftSenders: event.target.value })}
/>
</div>
<Autocomplete
multiple
disableCloseOnSelect
getOptionLabel={giftOptionLabel}
isOptionEqualToValue={(option, value) => giftId(option) === giftId(value)}
loading={page.giftOptionsLoading}
noOptionsText="当前无数据"
options={page.giftOptions}
renderInput={(params) => (
<TextField
{...params}
error={Boolean(page.giftOptionsError)}
helperText={page.giftOptionsError}
label={<RequiredLabel>幸运礼物合集</RequiredLabel>}
/> />
<ConfigNumberField )}
label="最短幸运间隔" size="small"
unit="毫秒" value={page.selectedLuckyGiftOptions}
value={page.form.luckyPauseMinMs} onChange={(_, options) => page.selectGiftIds("luckyGiftIds", options)}
onChange={(value) => page.setForm({ ...page.form, luckyPauseMinMs: value })} />
/> <div className={styles.formGrid}>
<ConfigNumberField <TextField
label="最长幸运间隔" label="最少幸运连击"
unit="毫秒" required
value={page.form.luckyPauseMaxMs} type="number"
onChange={(value) => page.setForm({ ...page.form, luckyPauseMaxMs: value })} value={page.form.luckyComboMin}
/> onChange={(event) => page.setForm({ ...page.form, luckyComboMin: event.target.value })}
</AdminFormFieldGrid> />
</AdminFormSection> <TextField
label="最多幸运连击"
required
type="number"
value={page.form.luckyComboMax}
onChange={(event) => page.setForm({ ...page.form, luckyComboMax: event.target.value })}
/>
<TextField
label="最短幸运间隔(毫秒)"
required
type="number"
value={page.form.luckyPauseMinMs}
onChange={(event) => page.setForm({ ...page.form, luckyPauseMinMs: event.target.value })}
/>
<TextField
label="最长幸运间隔(毫秒)"
required
type="number"
value={page.form.luckyPauseMaxMs}
onChange={(event) => page.setForm({ ...page.form, luckyPauseMaxMs: event.target.value })}
/>
</div>
</AdminFormDialog> </AdminFormDialog>
<HumanConfigDialog page={page} /> <HumanConfigDialog page={page} />
</section> </section>
@ -400,222 +446,208 @@ function HumanConfigDialog({ page }) {
onClose={page.closeAction} onClose={page.closeAction}
onSubmit={page.submitHumanConfig} onSubmit={page.submitHumanConfig}
> >
<AdminFormSection <div className={styles.formGrid}>
actions={ <label className={styles.switchField}>
<div className={styles.configSwitchActions}> <AdminSwitch
<span className={styles.switchField}> checked={Boolean(page.humanConfigForm.enabled)}
<AdminSwitch checkedLabel="启用"
checked={Boolean(page.humanConfigForm.enabled)} label="启用真人房间机器人"
checkedLabel="启用" uncheckedLabel="关闭"
label="启用真人房间机器人" onChange={(_, checked) =>
uncheckedLabel="关闭" page.setHumanConfigForm({ ...page.humanConfigForm, enabled: checked })
onChange={(_, checked) =>
page.setHumanConfigForm({ ...page.humanConfigForm, enabled: checked })
}
/>
</span>
</div>
}
title="基础规则"
>
<AdminFormFieldGrid>
<ConfigNumberField
label="真人房间低于人数"
value={page.humanConfigForm.candidateRoomMaxOnline}
onChange={(value) =>
page.setHumanConfigForm({
...page.humanConfigForm,
candidateRoomMaxOnline: value,
})
} }
/> />
<ConfigNumberField </label>
label="目标人数下限" <label className={styles.switchField}>
value={page.humanConfigForm.roomTargetMinOnline} <AdminSwitch
onChange={(value) => checked={Boolean(page.humanConfigForm.countryLimitEnabled)}
page.setHumanConfigForm({ ...page.humanConfigForm, roomTargetMinOnline: value }) checkedLabel="启用"
} label="启用国家配置"
uncheckedLabel="关闭"
onChange={(_, checked) => page.setHumanCountryLimitEnabled(checked)}
/> />
<ConfigNumberField </label>
label="目标人数上限" <TextField
value={page.humanConfigForm.roomTargetMaxOnline} label="真人房间低于人数"
onChange={(value) =>
page.setHumanConfigForm({ ...page.humanConfigForm, roomTargetMaxOnline: value })
}
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="送礼机器人规则">
<AdminFormFieldGrid>
<ConfigNumberField
label="最短送礼间隔"
unit="毫秒"
value={page.humanConfigForm.normalGiftIntervalMinMs}
onChange={(value) =>
page.setHumanConfigForm({
...page.humanConfigForm,
normalGiftIntervalMinMs: value,
})
}
/>
<ConfigNumberField
label="最长送礼间隔"
unit="毫秒"
value={page.humanConfigForm.normalGiftIntervalMaxMs}
onChange={(value) =>
page.setHumanConfigForm({
...page.humanConfigForm,
normalGiftIntervalMaxMs: value,
})
}
/>
<ConfigNumberField
label="最短停留"
unit="毫秒"
value={page.humanConfigForm.robotStayMinMs}
onChange={(value) =>
page.setHumanConfigForm({ ...page.humanConfigForm, robotStayMinMs: value })
}
/>
<ConfigNumberField
label="最长停留"
unit="毫秒"
value={page.humanConfigForm.robotStayMaxMs}
onChange={(value) =>
page.setHumanConfigForm({ ...page.humanConfigForm, robotStayMaxMs: value })
}
/>
<ConfigNumberField
label="最短补位"
min={0}
unit="毫秒"
value={page.humanConfigForm.robotReplaceMinMs}
onChange={(value) =>
page.setHumanConfigForm({ ...page.humanConfigForm, robotReplaceMinMs: value })
}
/>
<ConfigNumberField
label="最长补位"
min={0}
unit="毫秒"
value={page.humanConfigForm.robotReplaceMaxMs}
onChange={(value) =>
page.setHumanConfigForm({ ...page.humanConfigForm, robotReplaceMaxMs: value })
}
/>
<ConfigNumberField
label="房间送礼机器人数"
value={page.humanConfigForm.maxGiftSenders}
onChange={(value) =>
page.setHumanConfigForm({ ...page.humanConfigForm, maxGiftSenders: value })
}
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="礼物合集">
<GiftAutocomplete
label="普通礼物合集"
loading={page.giftOptionsLoading}
options={page.giftOptions}
required required
value={page.selectedHumanGiftOptions.giftIds} type="number"
onChange={(options) => page.selectHumanGiftIds("giftIds", options)} value={page.humanConfigForm.candidateRoomMaxOnline}
onChange={(event) =>
page.setHumanConfigForm({
...page.humanConfigForm,
candidateRoomMaxOnline: event.target.value,
})
}
/> />
<GiftAutocomplete <TextField
label="幸运礼物合集" label="目标人数下限"
loading={page.giftOptionsLoading} required
options={page.giftOptions} type="number"
value={page.selectedHumanGiftOptions.luckyGiftIds} value={page.humanConfigForm.roomTargetMinOnline}
onChange={(options) => page.selectHumanGiftIds("luckyGiftIds", options)} onChange={(event) =>
page.setHumanConfigForm({ ...page.humanConfigForm, roomTargetMinOnline: event.target.value })
}
/> />
<GiftAutocomplete <TextField
label="超级幸运礼物合集" label="目标人数上限"
loading={page.giftOptionsLoading} required
options={page.giftOptions} type="number"
value={page.selectedHumanGiftOptions.superLuckyGiftIds} value={page.humanConfigForm.roomTargetMaxOnline}
onChange={(options) => page.selectHumanGiftIds("superLuckyGiftIds", options)} onChange={(event) =>
page.setHumanConfigForm({ ...page.humanConfigForm, roomTargetMaxOnline: event.target.value })
}
/> />
</AdminFormSection> </div>
<div className={styles.formGrid}>
<AdminFormSection title="幸运连击规则"> <TextField
<AdminFormFieldGrid> label="最短送礼间隔(毫秒)"
<ConfigNumberField required
label="最少幸运连击" type="number"
value={page.humanConfigForm.luckyComboMin} value={page.humanConfigForm.normalGiftIntervalMinMs}
onChange={(value) => onChange={(event) =>
page.setHumanConfigForm({ ...page.humanConfigForm, luckyComboMin: value }) page.setHumanConfigForm({
} ...page.humanConfigForm,
/> normalGiftIntervalMinMs: event.target.value,
<ConfigNumberField })
label="最多幸运连击" }
value={page.humanConfigForm.luckyComboMax} />
onChange={(value) => <TextField
page.setHumanConfigForm({ ...page.humanConfigForm, luckyComboMax: value }) label="最长送礼间隔(毫秒)"
} required
/> type="number"
<ConfigNumberField value={page.humanConfigForm.normalGiftIntervalMaxMs}
label="最短幸运间隔" onChange={(event) =>
unit="毫秒" page.setHumanConfigForm({
value={page.humanConfigForm.luckyPauseMinMs} ...page.humanConfigForm,
onChange={(value) => normalGiftIntervalMaxMs: event.target.value,
page.setHumanConfigForm({ ...page.humanConfigForm, luckyPauseMinMs: value }) })
} }
/> />
<ConfigNumberField <TextField
label="最长幸运间隔" label="最短停留(毫秒)"
unit="毫秒" required
value={page.humanConfigForm.luckyPauseMaxMs} type="number"
onChange={(value) => value={page.humanConfigForm.robotStayMinMs}
page.setHumanConfigForm({ ...page.humanConfigForm, luckyPauseMaxMs: value }) onChange={(event) =>
} page.setHumanConfigForm({ ...page.humanConfigForm, robotStayMinMs: event.target.value })
/> }
</AdminFormFieldGrid> />
</AdminFormSection> <TextField
label="最长停留(毫秒)"
<AdminFormSection required
actions={ type="number"
<div className={styles.configSwitchActions}> value={page.humanConfigForm.robotStayMaxMs}
<span className={styles.switchField}> onChange={(event) =>
<AdminSwitch page.setHumanConfigForm({ ...page.humanConfigForm, robotStayMaxMs: event.target.value })
checked={Boolean(page.humanConfigForm.countryLimitEnabled)} }
checkedLabel="启用" />
label="启用国家配置" <TextField
uncheckedLabel="关闭" label="最短补位(毫秒)"
onChange={(_, checked) => page.setHumanCountryLimitEnabled(checked)} required
/> type="number"
</span> value={page.humanConfigForm.robotReplaceMinMs}
</div> onChange={(event) =>
} page.setHumanConfigForm({ ...page.humanConfigForm, robotReplaceMinMs: event.target.value })
title="国家进房配置" }
> />
{page.humanConfigForm.countryLimitEnabled ? <HumanCountryRules page={page} /> : null} <TextField
</AdminFormSection> label="最长补位(毫秒)"
required
type="number"
value={page.humanConfigForm.robotReplaceMaxMs}
onChange={(event) =>
page.setHumanConfigForm({ ...page.humanConfigForm, robotReplaceMaxMs: event.target.value })
}
/>
<TextField
label="房间送礼机器人数"
required
type="number"
value={page.humanConfigForm.maxGiftSenders}
onChange={(event) =>
page.setHumanConfigForm({ ...page.humanConfigForm, maxGiftSenders: event.target.value })
}
/>
</div>
<GiftAutocomplete
label="普通礼物合集"
loading={page.giftOptionsLoading}
options={page.giftOptions}
required
value={page.selectedHumanGiftOptions.giftIds}
onChange={(options) => page.selectHumanGiftIds("giftIds", options)}
/>
<div className={styles.formGrid}>
<TextField
label="最少幸运连击"
required
type="number"
value={page.humanConfigForm.luckyComboMin}
onChange={(event) =>
page.setHumanConfigForm({ ...page.humanConfigForm, luckyComboMin: event.target.value })
}
/>
<TextField
label="最多幸运连击"
required
type="number"
value={page.humanConfigForm.luckyComboMax}
onChange={(event) =>
page.setHumanConfigForm({ ...page.humanConfigForm, luckyComboMax: event.target.value })
}
/>
<TextField
label="最短幸运间隔(毫秒)"
required
type="number"
value={page.humanConfigForm.luckyPauseMinMs}
onChange={(event) =>
page.setHumanConfigForm({ ...page.humanConfigForm, luckyPauseMinMs: event.target.value })
}
/>
<TextField
label="最长幸运间隔(毫秒)"
required
type="number"
value={page.humanConfigForm.luckyPauseMaxMs}
onChange={(event) =>
page.setHumanConfigForm({ ...page.humanConfigForm, luckyPauseMaxMs: event.target.value })
}
/>
</div>
<GiftAutocomplete
label="幸运礼物合集"
loading={page.giftOptionsLoading}
options={page.giftOptions}
value={page.selectedHumanGiftOptions.luckyGiftIds}
onChange={(options) => page.selectHumanGiftIds("luckyGiftIds", options)}
/>
<GiftAutocomplete
label="超级幸运礼物合集"
loading={page.giftOptionsLoading}
options={page.giftOptions}
value={page.selectedHumanGiftOptions.superLuckyGiftIds}
onChange={(options) => page.selectHumanGiftIds("superLuckyGiftIds", options)}
/>
{page.humanConfigForm.countryLimitEnabled ? <HumanCountryRules page={page} /> : null}
<section className={styles.robotAutoPoolNotice}>
<div className={styles.primaryText}>机器人池自动获取</div>
<div className={styles.meta}>
{page.humanConfigForm.countryLimitEnabled
? "国家配置启用后,只会读取已配置国家的机器人,并限制每个国家同时进入的真人房数量。"
: "系统会读取所有启用的全站机器人,并按机器人账号国家进入同国家真人房间。"}
</div>
</section>
</AdminFormDialog> </AdminFormDialog>
); );
} }
function ConfigNumberField({ label, min = 1, onChange, size, unit, value }) {
const fieldProps = {
label,
required: true,
size,
slotProps: { htmlInput: { min, step: 1 } },
type: "number",
value,
onChange: (event) => onChange(event.target.value),
};
return unit ? <AdminFormAmountField {...fieldProps} unit={unit} /> : <TextField {...fieldProps} />;
}
function HumanCountryRules({ page }) { function HumanCountryRules({ page }) {
const rows = page.humanConfigForm.countryRules || []; const rows = page.humanConfigForm.countryRules || [];
return ( return (
<section className={styles.countryRulesSection}> <section className={styles.countryRulesSection}>
<div className={styles.countryRulesHeader}> <div className={styles.countryRulesHeader}>
<div className={styles.meta}>按机器人账号国家限制同时进入真人房的房间数量</div> <div className={styles.primaryText}>国家进房配置</div>
<Button <Button
startIcon={<AddOutlined fontSize="small" />} startIcon={<AddOutlined fontSize="small" />}
variant="secondary" variant="secondary"
@ -643,42 +675,32 @@ function HumanCountryRules({ page }) {
</MenuItem> </MenuItem>
))} ))}
</TextField> </TextField>
<ConfigNumberField
label="进房数量"
size="small"
value={rule.maxRoomCount}
onChange={(value) => page.updateHumanCountryRule(index, { maxRoomCount: value })}
/>
<TextField <TextField
className={styles.countryRuleOwners} label="进房数量"
label="房主ID列表" required
placeholder="输入房主ID逗号分隔"
size="small" size="small"
value={rule.allowedOwnerIds || ""} type="number"
value={rule.maxRoomCount}
onChange={(event) => onChange={(event) =>
page.updateHumanCountryRule(index, { allowedOwnerIds: event.target.value }) page.updateHumanCountryRule(index, { maxRoomCount: event.target.value })
} }
/> />
<Tooltip arrow title="删除"> <Button
<span className={styles.countryRuleRemove}> startIcon={<DeleteOutlineOutlined fontSize="small" />}
<IconButton variant="secondary"
label="删除国家规则" onClick={() => page.removeHumanCountryRule(index)}
tone="danger" >
onClick={() => page.removeHumanCountryRule(index)} 删除
> </Button>
<DeleteOutlineOutlined fontSize="small" />
</IconButton>
</span>
</Tooltip>
</div> </div>
))} ))}
{rows.length === 0 ? <div className={styles.meta}>当前无数据</div> : null} {rows.length === 0 ? <div className={styles.meta}>当前没有国家配置</div> : null}
</div> </div>
</section> </section>
); );
} }
function GiftAutocomplete({ error = false, helperText = "", label, loading, onChange, options, required = false, value }) { function GiftAutocomplete({ label, loading, onChange, options, required = false, value }) {
return ( return (
<Autocomplete <Autocomplete
multiple multiple
@ -689,12 +711,7 @@ function GiftAutocomplete({ error = false, helperText = "", label, loading, onCh
noOptionsText="当前无数据" noOptionsText="当前无数据"
options={options} options={options}
renderInput={(params) => ( renderInput={(params) => (
<TextField <TextField {...params} label={required ? <RequiredLabel>{label}</RequiredLabel> : label} />
{...params}
error={error}
helperText={helperText}
label={required ? <RequiredLabel>{label}</RequiredLabel> : label}
/>
)} )}
size="small" size="small"
value={value} value={value}

View File

@ -286,11 +286,13 @@
align-items: center; align-items: center;
} }
.configSwitchActions { .robotAutoPoolNotice {
display: inline-flex; display: grid;
min-width: 0; gap: var(--space-3);
align-items: center; padding: var(--space-3);
gap: var(--space-2); border: 1px solid var(--border);
border-radius: var(--radius-control);
background: var(--bg-card-strong);
} }
.countryRulesSection { .countryRulesSection {
@ -312,37 +314,18 @@
.countryRuleRows { .countryRuleRows {
display: grid; display: grid;
gap: var(--space-3); gap: var(--space-2);
} }
.countryRuleRow { .countryRuleRow {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(128px, 160px) auto; grid-template-columns: minmax(180px, 1fr) minmax(140px, 0.6fr) auto;
align-items: start; align-items: center;
gap: var(--space-3); gap: var(--space-2);
}
.countryRuleRow > :global(.MuiTextField-root),
.countryRuleRow > :global(.MuiFormControl-root) {
width: 100%;
}
.countryRuleOwners {
grid-column: 1 / -1;
grid-row: 2;
}
.countryRuleRemove {
display: inline-flex;
grid-column: 3;
grid-row: 1;
align-self: center;
justify-content: flex-end;
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.countryRulesHeader, .countryRulesHeader,
.configSwitchActions,
.humanConfigActions { .humanConfigActions {
align-items: stretch; align-items: stretch;
flex-direction: column; flex-direction: column;
@ -351,17 +334,6 @@
.countryRuleRow { .countryRuleRow {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.countryRuleOwners {
grid-column: auto;
grid-row: auto;
}
.countryRuleRemove {
grid-column: auto;
grid-row: auto;
justify-content: flex-start;
}
} }
.rowActions { .rowActions {

View File

@ -41,7 +41,7 @@ describe("human room robot config schema", () => {
const payload = parseForm(humanRoomRobotConfigSchema, { const payload = parseForm(humanRoomRobotConfigSchema, {
candidateRoomMaxOnline: "5", candidateRoomMaxOnline: "5",
countryLimitEnabled: true, countryLimitEnabled: true,
countryRules: [{ allowedOwnerIds: "1001, 1002,,1001", countryCode: "sa", maxRoomCount: "2" }], countryRules: [{ countryCode: "sa", maxRoomCount: "2" }],
enabled: true, enabled: true,
giftIds: ["84"], giftIds: ["84"],
luckyComboMax: "3", luckyComboMax: "3",
@ -68,7 +68,7 @@ describe("human room robot config schema", () => {
expect(payload.normalGiftIntervalMaxMs).toBe(20000); expect(payload.normalGiftIntervalMaxMs).toBe(20000);
expect(payload.giftIds).toEqual(["84"]); expect(payload.giftIds).toEqual(["84"]);
expect(payload.countryLimitEnabled).toBe(true); expect(payload.countryLimitEnabled).toBe(true);
expect(payload.countryRules).toEqual([{ allowedOwnerIds: ["1001", "1002"], countryCode: "SA", maxRoomCount: 2 }]); expect(payload.countryRules).toEqual([{ countryCode: "SA", maxRoomCount: 2 }]);
}); });
test("rejects duplicated country rules", () => { test("rejects duplicated country rules", () => {

View File

@ -12,21 +12,6 @@ const countryCodeSchema = z
.trim() .trim()
.transform((value) => value.toUpperCase()) .transform((value) => value.toUpperCase())
.refine((value) => /^[A-Z]{2,3}$/.test(value), "国家码不正确"); .refine((value) => /^[A-Z]{2,3}$/.test(value), "国家码不正确");
const commaSeparatedTextListSchema = z
.union([z.string(), z.array(z.union([z.string(), z.number()]))])
.optional()
.default("")
.transform((value) => {
const raw = Array.isArray(value) ? value.join(",") : value;
return [
...new Set(
String(raw || "")
.split(",")
.map((item) => item.trim())
.filter(Boolean),
),
];
});
const pinTypeSchema = z const pinTypeSchema = z
.string() .string()
.trim() .trim()
@ -161,7 +146,6 @@ export const humanRoomRobotConfigSchema = z
countryRules: z countryRules: z
.array( .array(
z.object({ z.object({
allowedOwnerIds: commaSeparatedTextListSchema,
countryCode: countryCodeSchema, countryCode: countryCodeSchema,
maxRoomCount: z.coerce.number().int().min(1, "国家进房数量不正确"), maxRoomCount: z.coerce.number().int().min(1, "国家进房数量不正确"),
}), }),

View File

@ -3350,7 +3350,6 @@ export interface components {
userId: string; userId: string;
}; };
HumanRoomRobotCountryRule: { HumanRoomRobotCountryRule: {
allowedOwnerIds?: string[];
countryCode: string; countryCode: string;
maxRoomCount: number; maxRoomCount: number;
}; };

View File

@ -1061,7 +1061,6 @@ export interface HumanRoomRobotConfigDto {
} }
export interface HumanRoomRobotCountryRuleDto { export interface HumanRoomRobotCountryRuleDto {
allowedOwnerIds?: string[];
countryCode: string; countryCode: string;
maxRoomCount: number; maxRoomCount: number;
} }