Compare commits
3 Commits
57231bc95a
...
05412e477a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05412e477a | ||
|
|
17251552d9 | ||
|
|
8c23d7e6a7 |
@ -5500,6 +5500,12 @@
|
||||
"type": "object",
|
||||
"required": ["countryCode", "maxRoomCount"],
|
||||
"properties": {
|
||||
"allowedOwnerIds": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"countryCode": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@ -118,6 +118,8 @@ test("renders cp config route with an authenticated session", async () => {
|
||||
renderWithRoute("/activities/cp-config");
|
||||
|
||||
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", () => {
|
||||
|
||||
@ -93,7 +93,6 @@ export function CPConfigPage() {
|
||||
rowKey={(item) =>
|
||||
isApplicationsTab ? item.applicationId : item.relationshipId || `${item.userA?.userId}-${item.userB?.userId}`
|
||||
}
|
||||
total={activePage.total}
|
||||
/>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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%;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -70,6 +70,7 @@ function emptyHumanConfigForm() {
|
||||
|
||||
function emptyHumanCountryRule(countryOptions = [], existingRules = []) {
|
||||
return {
|
||||
allowedOwnerIds: "",
|
||||
countryCode: firstAvailableCountryCode(countryOptions, existingRules),
|
||||
maxRoomCount: "1",
|
||||
};
|
||||
@ -82,7 +83,6 @@ export function useRobotRoomsPage() {
|
||||
const { showToast } = useToast();
|
||||
const [activeAction, setActiveAction] = useState("");
|
||||
const [availableRobotsState, setAvailableRobotsState] = useState({ error: "", loading: false, options: [] });
|
||||
const [candidatesTouched, setCandidatesTouched] = useState(false);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [giftOptionsState, setGiftOptionsState] = useState({ error: "", loading: false, options: [] });
|
||||
const [humanConfigState, setHumanConfigState] = useState({ config: null, error: "", loading: false });
|
||||
@ -151,14 +151,21 @@ export function useRobotRoomsPage() {
|
||||
}, [activeAction]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeAction !== "create" || candidatesTouched) {
|
||||
if (activeAction !== "create") {
|
||||
return;
|
||||
}
|
||||
const ids = availableRobotsState.options.map((item) => item.userId).filter(Boolean);
|
||||
if (ids.length > 0) {
|
||||
setForm((current) => ({ ...current, candidateRobotUserIds: ids }));
|
||||
}
|
||||
}, [activeAction, availableRobotsState.options, candidatesTouched]);
|
||||
const ownerRobotUserId = String(form.ownerRobotUserId || "");
|
||||
const ids = availableRobotsState.options
|
||||
.map((item) => item.userId)
|
||||
.filter((userId) => userId && String(userId) !== ownerRobotUserId);
|
||||
setForm((current) => {
|
||||
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) => {
|
||||
setStatus(value || "active");
|
||||
@ -172,7 +179,6 @@ export function useRobotRoomsPage() {
|
||||
|
||||
const openCreate = () => {
|
||||
setActiveAction("create");
|
||||
setCandidatesTouched(false);
|
||||
setForm(emptyForm());
|
||||
setAvailableRobotsState({ error: "", loading: false, options: [] });
|
||||
setGiftOptionsState({ error: "", loading: false, options: [] });
|
||||
@ -233,16 +239,6 @@ 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) => {
|
||||
setForm((current) => ({ ...current, [key]: gifts.map(giftId).filter(Boolean) }));
|
||||
};
|
||||
@ -313,14 +309,6 @@ export function useRobotRoomsPage() {
|
||||
() => availableRobotsState.options.find((item) => item.userId === String(form.ownerRobotUserId)) || null,
|
||||
[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(
|
||||
() => Object.fromEntries(countryOptions.map((option) => [option.value, option.label])),
|
||||
[countryOptions],
|
||||
@ -365,7 +353,6 @@ export function useRobotRoomsPage() {
|
||||
availableRobots: availableRobotsState.options,
|
||||
changeStatus,
|
||||
closeAction,
|
||||
candidateRobotOptions,
|
||||
countryOptions,
|
||||
data,
|
||||
error,
|
||||
@ -387,11 +374,9 @@ export function useRobotRoomsPage() {
|
||||
page,
|
||||
reload,
|
||||
resetFilters,
|
||||
selectCandidateRobots,
|
||||
selectGiftIds,
|
||||
selectHumanGiftIds,
|
||||
selectOwnerRobot,
|
||||
selectedCandidateRobots,
|
||||
selectedGiftOptions,
|
||||
selectedLuckyGiftOptions,
|
||||
selectedHumanGiftOptions,
|
||||
@ -421,6 +406,7 @@ function humanConfigToForm(config = null) {
|
||||
candidateRoomMaxOnline: String(config.candidateRoomMaxOnline ?? defaults.candidateRoomMaxOnline),
|
||||
countryLimitEnabled: Boolean(config.countryLimitEnabled),
|
||||
countryRules: (config.countryRules || []).map((rule) => ({
|
||||
allowedOwnerIds: (rule.allowedOwnerIds || []).map(String).join(","),
|
||||
countryCode: String(rule.countryCode || "").toUpperCase(),
|
||||
maxRoomCount: String(rule.maxRoomCount ?? "1"),
|
||||
})),
|
||||
|
||||
@ -7,7 +7,13 @@ import MenuItem from "@mui/material/MenuItem";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx";
|
||||
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
||||
import {
|
||||
AdminFormAmountField,
|
||||
AdminFormDialog,
|
||||
AdminFormFieldGrid,
|
||||
AdminFormSection,
|
||||
} from "@/shared/ui/AdminFormDialog.jsx";
|
||||
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
@ -190,190 +196,138 @@ export function RoomRobotPage() {
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitCreate}
|
||||
>
|
||||
<Autocomplete
|
||||
getOptionLabel={robotOptionLabel}
|
||||
isOptionEqualToValue={(option, value) => option.userId === value.userId}
|
||||
loading={page.availableRobotsLoading}
|
||||
noOptionsText="当前无数据"
|
||||
options={page.availableRobots}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
error={Boolean(page.availableRobotsError)}
|
||||
helperText={page.availableRobotsError}
|
||||
label="房主机器人"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
renderOption={(props, option) => (
|
||||
<li {...props} key={option.userId}>
|
||||
<OwnerRobotOption
|
||||
countryLabels={page.robotCountryLabels}
|
||||
loadingLocation={page.loadingRobotLocationOptions}
|
||||
option={option}
|
||||
regionLabels={page.robotRegionLabels}
|
||||
<AdminFormSection title="房主设置">
|
||||
<Autocomplete
|
||||
getOptionLabel={robotOptionLabel}
|
||||
isOptionEqualToValue={(option, value) => option.userId === value.userId}
|
||||
loading={page.availableRobotsLoading}
|
||||
noOptionsText="当前无数据"
|
||||
options={page.availableRobots}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
error={Boolean(page.availableRobotsError)}
|
||||
helperText={page.availableRobotsError}
|
||||
label="房主机器人"
|
||||
required
|
||||
/>
|
||||
</li>
|
||||
)}
|
||||
size="small"
|
||||
value={page.selectedOwnerRobot}
|
||||
onChange={(_, option) => page.selectOwnerRobot(option)}
|
||||
/>
|
||||
<Autocomplete
|
||||
multiple
|
||||
disableCloseOnSelect
|
||||
getOptionLabel={robotOptionLabel}
|
||||
isOptionEqualToValue={(option, value) => option.userId === value.userId}
|
||||
loading={page.availableRobotsLoading}
|
||||
noOptionsText="当前无数据"
|
||||
options={page.candidateRobotOptions}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
error={Boolean(page.availableRobotsError)}
|
||||
helperText={page.availableRobotsError}
|
||||
label={<RequiredLabel>候选机器人</RequiredLabel>}
|
||||
)}
|
||||
renderOption={(props, option) => (
|
||||
<li {...props} key={option.userId}>
|
||||
<OwnerRobotOption
|
||||
countryLabels={page.robotCountryLabels}
|
||||
loadingLocation={page.loadingRobotLocationOptions}
|
||||
option={option}
|
||||
regionLabels={page.robotRegionLabels}
|
||||
/>
|
||||
</li>
|
||||
)}
|
||||
size="small"
|
||||
value={page.selectedOwnerRobot}
|
||||
onChange={(_, option) => page.selectOwnerRobot(option)}
|
||||
/>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="进房规则">
|
||||
<AdminFormFieldGrid>
|
||||
<ConfigNumberField
|
||||
label="最少进房人数"
|
||||
value={page.form.minRobotCount}
|
||||
onChange={(value) => page.setForm({ ...page.form, minRobotCount: value })}
|
||||
/>
|
||||
)}
|
||||
renderOption={(props, option) => (
|
||||
<li {...props} key={option.userId}>
|
||||
<RobotIdentity openInAppUserDetail={false} user={option.user} fallbackId={option.userId} />
|
||||
</li>
|
||||
)}
|
||||
size="small"
|
||||
value={page.selectedCandidateRobots}
|
||||
onChange={(_, options) => page.selectCandidateRobots(options)}
|
||||
/>
|
||||
<div className={styles.formGrid}>
|
||||
<TextField
|
||||
label="最少进房人数"
|
||||
required
|
||||
type="number"
|
||||
value={page.form.minRobotCount}
|
||||
onChange={(event) => page.setForm({ ...page.form, minRobotCount: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="最多进房人数"
|
||||
required
|
||||
type="number"
|
||||
value={page.form.maxRobotCount}
|
||||
onChange={(event) => page.setForm({ ...page.form, maxRobotCount: 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="最多进房人数"
|
||||
value={page.form.maxRobotCount}
|
||||
onChange={(value) => page.setForm({ ...page.form, maxRobotCount: value })}
|
||||
/>
|
||||
)}
|
||||
size="small"
|
||||
value={page.selectedGiftOptions}
|
||||
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="最短停留(毫秒)"
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="普通礼物规则">
|
||||
<GiftAutocomplete
|
||||
error={Boolean(page.giftOptionsError)}
|
||||
helperText={page.giftOptionsError}
|
||||
label="礼物合集"
|
||||
loading={page.giftOptionsLoading}
|
||||
options={page.giftOptions}
|
||||
required
|
||||
type="number"
|
||||
value={page.form.robotStayMinMs}
|
||||
onChange={(event) => page.setForm({ ...page.form, robotStayMinMs: event.target.value })}
|
||||
value={page.selectedGiftOptions}
|
||||
onChange={(options) => page.selectGiftIds("giftIds", options)}
|
||||
/>
|
||||
<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>}
|
||||
<AdminFormFieldGrid>
|
||||
<ConfigNumberField
|
||||
label="普通礼物频次"
|
||||
unit="毫秒"
|
||||
value={page.form.normalGiftIntervalMs}
|
||||
onChange={(value) => page.setForm({ ...page.form, normalGiftIntervalMs: value })}
|
||||
/>
|
||||
)}
|
||||
size="small"
|
||||
value={page.selectedLuckyGiftOptions}
|
||||
onChange={(_, options) => page.selectGiftIds("luckyGiftIds", options)}
|
||||
/>
|
||||
<div className={styles.formGrid}>
|
||||
<TextField
|
||||
label="最少幸运连击"
|
||||
<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
|
||||
type="number"
|
||||
value={page.form.luckyComboMin}
|
||||
onChange={(event) => page.setForm({ ...page.form, luckyComboMin: event.target.value })}
|
||||
value={page.selectedLuckyGiftOptions}
|
||||
onChange={(options) => page.selectGiftIds("luckyGiftIds", options)}
|
||||
/>
|
||||
<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>
|
||||
<AdminFormFieldGrid>
|
||||
<ConfigNumberField
|
||||
label="最少幸运连击"
|
||||
value={page.form.luckyComboMin}
|
||||
onChange={(value) => page.setForm({ ...page.form, luckyComboMin: value })}
|
||||
/>
|
||||
<ConfigNumberField
|
||||
label="最多幸运连击"
|
||||
value={page.form.luckyComboMax}
|
||||
onChange={(value) => page.setForm({ ...page.form, luckyComboMax: value })}
|
||||
/>
|
||||
<ConfigNumberField
|
||||
label="最短幸运间隔"
|
||||
unit="毫秒"
|
||||
value={page.form.luckyPauseMinMs}
|
||||
onChange={(value) => page.setForm({ ...page.form, luckyPauseMinMs: value })}
|
||||
/>
|
||||
<ConfigNumberField
|
||||
label="最长幸运间隔"
|
||||
unit="毫秒"
|
||||
value={page.form.luckyPauseMaxMs}
|
||||
onChange={(value) => page.setForm({ ...page.form, luckyPauseMaxMs: value })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
</AdminFormDialog>
|
||||
<HumanConfigDialog page={page} />
|
||||
</section>
|
||||
@ -446,208 +400,222 @@ function HumanConfigDialog({ page }) {
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitHumanConfig}
|
||||
>
|
||||
<div className={styles.formGrid}>
|
||||
<label className={styles.switchField}>
|
||||
<AdminSwitch
|
||||
checked={Boolean(page.humanConfigForm.enabled)}
|
||||
checkedLabel="启用"
|
||||
label="启用真人房间机器人"
|
||||
uncheckedLabel="关闭"
|
||||
onChange={(_, checked) =>
|
||||
page.setHumanConfigForm({ ...page.humanConfigForm, enabled: checked })
|
||||
<AdminFormSection
|
||||
actions={
|
||||
<div className={styles.configSwitchActions}>
|
||||
<span className={styles.switchField}>
|
||||
<AdminSwitch
|
||||
checked={Boolean(page.humanConfigForm.enabled)}
|
||||
checkedLabel="启用"
|
||||
label="启用真人房间机器人"
|
||||
uncheckedLabel="关闭"
|
||||
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,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.switchField}>
|
||||
<AdminSwitch
|
||||
checked={Boolean(page.humanConfigForm.countryLimitEnabled)}
|
||||
checkedLabel="启用"
|
||||
label="启用国家配置"
|
||||
uncheckedLabel="关闭"
|
||||
onChange={(_, checked) => page.setHumanCountryLimitEnabled(checked)}
|
||||
<ConfigNumberField
|
||||
label="目标人数下限"
|
||||
value={page.humanConfigForm.roomTargetMinOnline}
|
||||
onChange={(value) =>
|
||||
page.setHumanConfigForm({ ...page.humanConfigForm, roomTargetMinOnline: value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<TextField
|
||||
label="真人房间低于人数"
|
||||
<ConfigNumberField
|
||||
label="目标人数上限"
|
||||
value={page.humanConfigForm.roomTargetMaxOnline}
|
||||
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
|
||||
type="number"
|
||||
value={page.humanConfigForm.candidateRoomMaxOnline}
|
||||
onChange={(event) =>
|
||||
page.setHumanConfigForm({
|
||||
...page.humanConfigForm,
|
||||
candidateRoomMaxOnline: event.target.value,
|
||||
})
|
||||
}
|
||||
value={page.selectedHumanGiftOptions.giftIds}
|
||||
onChange={(options) => page.selectHumanGiftIds("giftIds", options)}
|
||||
/>
|
||||
<TextField
|
||||
label="目标人数下限"
|
||||
required
|
||||
type="number"
|
||||
value={page.humanConfigForm.roomTargetMinOnline}
|
||||
onChange={(event) =>
|
||||
page.setHumanConfigForm({ ...page.humanConfigForm, roomTargetMinOnline: event.target.value })
|
||||
}
|
||||
<GiftAutocomplete
|
||||
label="幸运礼物合集"
|
||||
loading={page.giftOptionsLoading}
|
||||
options={page.giftOptions}
|
||||
value={page.selectedHumanGiftOptions.luckyGiftIds}
|
||||
onChange={(options) => page.selectHumanGiftIds("luckyGiftIds", options)}
|
||||
/>
|
||||
<TextField
|
||||
label="目标人数上限"
|
||||
required
|
||||
type="number"
|
||||
value={page.humanConfigForm.roomTargetMaxOnline}
|
||||
onChange={(event) =>
|
||||
page.setHumanConfigForm({ ...page.humanConfigForm, roomTargetMaxOnline: event.target.value })
|
||||
}
|
||||
<GiftAutocomplete
|
||||
label="超级幸运礼物合集"
|
||||
loading={page.giftOptionsLoading}
|
||||
options={page.giftOptions}
|
||||
value={page.selectedHumanGiftOptions.superLuckyGiftIds}
|
||||
onChange={(options) => page.selectHumanGiftIds("superLuckyGiftIds", options)}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.formGrid}>
|
||||
<TextField
|
||||
label="最短送礼间隔(毫秒)"
|
||||
required
|
||||
type="number"
|
||||
value={page.humanConfigForm.normalGiftIntervalMinMs}
|
||||
onChange={(event) =>
|
||||
page.setHumanConfigForm({
|
||||
...page.humanConfigForm,
|
||||
normalGiftIntervalMinMs: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
label="最长送礼间隔(毫秒)"
|
||||
required
|
||||
type="number"
|
||||
value={page.humanConfigForm.normalGiftIntervalMaxMs}
|
||||
onChange={(event) =>
|
||||
page.setHumanConfigForm({
|
||||
...page.humanConfigForm,
|
||||
normalGiftIntervalMaxMs: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
label="最短停留(毫秒)"
|
||||
required
|
||||
type="number"
|
||||
value={page.humanConfigForm.robotStayMinMs}
|
||||
onChange={(event) =>
|
||||
page.setHumanConfigForm({ ...page.humanConfigForm, robotStayMinMs: event.target.value })
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
label="最长停留(毫秒)"
|
||||
required
|
||||
type="number"
|
||||
value={page.humanConfigForm.robotStayMaxMs}
|
||||
onChange={(event) =>
|
||||
page.setHumanConfigForm({ ...page.humanConfigForm, robotStayMaxMs: event.target.value })
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
label="最短补位(毫秒)"
|
||||
required
|
||||
type="number"
|
||||
value={page.humanConfigForm.robotReplaceMinMs}
|
||||
onChange={(event) =>
|
||||
page.setHumanConfigForm({ ...page.humanConfigForm, robotReplaceMinMs: event.target.value })
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
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>
|
||||
</AdminFormSection>
|
||||
|
||||
<AdminFormSection title="幸运连击规则">
|
||||
<AdminFormFieldGrid>
|
||||
<ConfigNumberField
|
||||
label="最少幸运连击"
|
||||
value={page.humanConfigForm.luckyComboMin}
|
||||
onChange={(value) =>
|
||||
page.setHumanConfigForm({ ...page.humanConfigForm, luckyComboMin: value })
|
||||
}
|
||||
/>
|
||||
<ConfigNumberField
|
||||
label="最多幸运连击"
|
||||
value={page.humanConfigForm.luckyComboMax}
|
||||
onChange={(value) =>
|
||||
page.setHumanConfigForm({ ...page.humanConfigForm, luckyComboMax: value })
|
||||
}
|
||||
/>
|
||||
<ConfigNumberField
|
||||
label="最短幸运间隔"
|
||||
unit="毫秒"
|
||||
value={page.humanConfigForm.luckyPauseMinMs}
|
||||
onChange={(value) =>
|
||||
page.setHumanConfigForm({ ...page.humanConfigForm, luckyPauseMinMs: value })
|
||||
}
|
||||
/>
|
||||
<ConfigNumberField
|
||||
label="最长幸运间隔"
|
||||
unit="毫秒"
|
||||
value={page.humanConfigForm.luckyPauseMaxMs}
|
||||
onChange={(value) =>
|
||||
page.setHumanConfigForm({ ...page.humanConfigForm, luckyPauseMaxMs: value })
|
||||
}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
|
||||
<AdminFormSection
|
||||
actions={
|
||||
<div className={styles.configSwitchActions}>
|
||||
<span className={styles.switchField}>
|
||||
<AdminSwitch
|
||||
checked={Boolean(page.humanConfigForm.countryLimitEnabled)}
|
||||
checkedLabel="启用"
|
||||
label="启用国家配置"
|
||||
uncheckedLabel="关闭"
|
||||
onChange={(_, checked) => page.setHumanCountryLimitEnabled(checked)}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
title="国家进房配置"
|
||||
>
|
||||
{page.humanConfigForm.countryLimitEnabled ? <HumanCountryRules page={page} /> : null}
|
||||
</AdminFormSection>
|
||||
</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 }) {
|
||||
const rows = page.humanConfigForm.countryRules || [];
|
||||
return (
|
||||
<section className={styles.countryRulesSection}>
|
||||
<div className={styles.countryRulesHeader}>
|
||||
<div className={styles.primaryText}>国家进房配置</div>
|
||||
<div className={styles.meta}>按机器人账号国家限制同时进入真人房的房间数量</div>
|
||||
<Button
|
||||
startIcon={<AddOutlined fontSize="small" />}
|
||||
variant="secondary"
|
||||
@ -675,32 +643,42 @@ function HumanCountryRules({ page }) {
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
<ConfigNumberField
|
||||
label="进房数量"
|
||||
required
|
||||
size="small"
|
||||
type="number"
|
||||
value={rule.maxRoomCount}
|
||||
onChange={(value) => page.updateHumanCountryRule(index, { maxRoomCount: value })}
|
||||
/>
|
||||
<TextField
|
||||
className={styles.countryRuleOwners}
|
||||
label="房主ID列表"
|
||||
placeholder="输入房主ID,逗号分隔"
|
||||
size="small"
|
||||
value={rule.allowedOwnerIds || ""}
|
||||
onChange={(event) =>
|
||||
page.updateHumanCountryRule(index, { maxRoomCount: event.target.value })
|
||||
page.updateHumanCountryRule(index, { allowedOwnerIds: event.target.value })
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
startIcon={<DeleteOutlineOutlined fontSize="small" />}
|
||||
variant="secondary"
|
||||
onClick={() => page.removeHumanCountryRule(index)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
<Tooltip arrow title="删除">
|
||||
<span className={styles.countryRuleRemove}>
|
||||
<IconButton
|
||||
label="删除国家规则"
|
||||
tone="danger"
|
||||
onClick={() => page.removeHumanCountryRule(index)}
|
||||
>
|
||||
<DeleteOutlineOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
))}
|
||||
{rows.length === 0 ? <div className={styles.meta}>当前没有国家配置</div> : null}
|
||||
{rows.length === 0 ? <div className={styles.meta}>当前无数据</div> : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function GiftAutocomplete({ label, loading, onChange, options, required = false, value }) {
|
||||
function GiftAutocomplete({ error = false, helperText = "", label, loading, onChange, options, required = false, value }) {
|
||||
return (
|
||||
<Autocomplete
|
||||
multiple
|
||||
@ -711,7 +689,12 @@ function GiftAutocomplete({ label, loading, onChange, options, required = false,
|
||||
noOptionsText="当前无数据"
|
||||
options={options}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label={required ? <RequiredLabel>{label}</RequiredLabel> : label} />
|
||||
<TextField
|
||||
{...params}
|
||||
error={error}
|
||||
helperText={helperText}
|
||||
label={required ? <RequiredLabel>{label}</RequiredLabel> : label}
|
||||
/>
|
||||
)}
|
||||
size="small"
|
||||
value={value}
|
||||
|
||||
@ -286,13 +286,11 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.robotAutoPoolNotice {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--bg-card-strong);
|
||||
.configSwitchActions {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.countryRulesSection {
|
||||
@ -314,18 +312,37 @@
|
||||
|
||||
.countryRuleRows {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.countryRuleRow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 1fr) minmax(140px, 0.6fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
grid-template-columns: minmax(0, 1fr) minmax(128px, 160px) auto;
|
||||
align-items: start;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.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) {
|
||||
.countryRulesHeader,
|
||||
.configSwitchActions,
|
||||
.humanConfigActions {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
@ -334,6 +351,17 @@
|
||||
.countryRuleRow {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.countryRuleOwners {
|
||||
grid-column: auto;
|
||||
grid-row: auto;
|
||||
}
|
||||
|
||||
.countryRuleRemove {
|
||||
grid-column: auto;
|
||||
grid-row: auto;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
|
||||
@ -41,7 +41,7 @@ describe("human room robot config schema", () => {
|
||||
const payload = parseForm(humanRoomRobotConfigSchema, {
|
||||
candidateRoomMaxOnline: "5",
|
||||
countryLimitEnabled: true,
|
||||
countryRules: [{ countryCode: "sa", maxRoomCount: "2" }],
|
||||
countryRules: [{ allowedOwnerIds: "1001, 1002,,1001", countryCode: "sa", maxRoomCount: "2" }],
|
||||
enabled: true,
|
||||
giftIds: ["84"],
|
||||
luckyComboMax: "3",
|
||||
@ -68,7 +68,7 @@ describe("human room robot config schema", () => {
|
||||
expect(payload.normalGiftIntervalMaxMs).toBe(20000);
|
||||
expect(payload.giftIds).toEqual(["84"]);
|
||||
expect(payload.countryLimitEnabled).toBe(true);
|
||||
expect(payload.countryRules).toEqual([{ countryCode: "SA", maxRoomCount: 2 }]);
|
||||
expect(payload.countryRules).toEqual([{ allowedOwnerIds: ["1001", "1002"], countryCode: "SA", maxRoomCount: 2 }]);
|
||||
});
|
||||
|
||||
test("rejects duplicated country rules", () => {
|
||||
|
||||
@ -12,6 +12,21 @@ const countryCodeSchema = z
|
||||
.trim()
|
||||
.transform((value) => value.toUpperCase())
|
||||
.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
|
||||
.string()
|
||||
.trim()
|
||||
@ -146,6 +161,7 @@ export const humanRoomRobotConfigSchema = z
|
||||
countryRules: z
|
||||
.array(
|
||||
z.object({
|
||||
allowedOwnerIds: commaSeparatedTextListSchema,
|
||||
countryCode: countryCodeSchema,
|
||||
maxRoomCount: z.coerce.number().int().min(1, "国家进房数量不正确"),
|
||||
}),
|
||||
|
||||
1
src/shared/api/generated/schema.d.ts
vendored
1
src/shared/api/generated/schema.d.ts
vendored
@ -3350,6 +3350,7 @@ export interface components {
|
||||
userId: string;
|
||||
};
|
||||
HumanRoomRobotCountryRule: {
|
||||
allowedOwnerIds?: string[];
|
||||
countryCode: string;
|
||||
maxRoomCount: number;
|
||||
};
|
||||
|
||||
@ -1061,6 +1061,7 @@ export interface HumanRoomRobotConfigDto {
|
||||
}
|
||||
|
||||
export interface HumanRoomRobotCountryRuleDto {
|
||||
allowedOwnerIds?: string[];
|
||||
countryCode: string;
|
||||
maxRoomCount: number;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user