335 lines
13 KiB
JavaScript
335 lines
13 KiB
JavaScript
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||
import Avatar from "@mui/material/Avatar";
|
||
import MenuItem from "@mui/material/MenuItem";
|
||
import Switch from "@mui/material/Switch";
|
||
import TextField from "@mui/material/TextField";
|
||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
import { PERMISSIONS } from "@/app/permissions";
|
||
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||
import { deleteDiceRobot, generateDiceRobots, listDiceRobots, setDiceRobotStatus } from "@/features/games/api";
|
||
import { useCountryOptions } from "@/features/host-org/hooks/useCountryOptions.js";
|
||
import styles from "@/features/games/games.module.css";
|
||
import {
|
||
AdminActionIconButton,
|
||
AdminFilterResetButton,
|
||
AdminFilterSelect,
|
||
AdminListBody,
|
||
AdminListPage,
|
||
AdminListToolbar,
|
||
AdminRowActions,
|
||
} from "@/shared/ui/AdminListLayout.jsx";
|
||
import { AdminFormDialog, AdminFormFieldGrid } from "@/shared/ui/AdminFormDialog.jsx";
|
||
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||
import { PageSkeleton } from "@/shared/ui/PageSkeleton.jsx";
|
||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||
import { formatMillis } from "@/shared/utils/time.js";
|
||
|
||
const defaultGenerateForm = {
|
||
count: 20,
|
||
country: "SA",
|
||
nicknameLanguage: "arabic",
|
||
};
|
||
const maxGenerateRobotCount = 1000;
|
||
|
||
export function GameRobotPage() {
|
||
const { can } = useAuth();
|
||
const confirm = useConfirm();
|
||
const { showToast } = useToast();
|
||
const canCreate = can(PERMISSIONS.gameCreate);
|
||
const canUpdate = can(PERMISSIONS.gameUpdate);
|
||
const canDelete = can(PERMISSIONS.gameDelete) || canUpdate;
|
||
const { countryOptions, loadingCountries } = useCountryOptions();
|
||
const [items, setItems] = useState([]);
|
||
const [status, setStatus] = useState("");
|
||
const [nextCursor, setNextCursor] = useState("");
|
||
const [loading, setLoading] = useState(true);
|
||
const [loadingAction, setLoadingAction] = useState("");
|
||
const [dialogOpen, setDialogOpen] = useState(false);
|
||
const [generateForm, setGenerateForm] = useState(defaultGenerateForm);
|
||
|
||
const load = useCallback(
|
||
async (cursor = "", append = false) => {
|
||
setLoading(!append);
|
||
try {
|
||
const result = await listDiceRobots({ status, cursor, pageSize: 50 });
|
||
setItems((current) => (append ? [...current, ...(result.items || [])] : result.items || []));
|
||
setNextCursor(result.nextCursor || "");
|
||
} catch (err) {
|
||
showToast(err.message || "加载全站机器人失败", "error");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
},
|
||
[showToast, status],
|
||
);
|
||
|
||
useEffect(() => {
|
||
load("", false);
|
||
}, [load]);
|
||
|
||
useEffect(() => {
|
||
setGenerateForm((current) => {
|
||
if (countryOptions.length === 0) {
|
||
return current;
|
||
}
|
||
const currentCountryEnabled = countryOptions.some((option) => option.value === current.country);
|
||
if (current.country && currentCountryEnabled) {
|
||
return current;
|
||
}
|
||
// 国家主数据由后端控制启用状态;默认优先沿用历史 SA,否则取当前启用列表第一项,避免提交空国家被 user-service 拒绝。
|
||
const defaultCountry = countryOptions.find((option) => option.value === "SA")?.value || countryOptions[0].value;
|
||
return { ...current, country: defaultCountry };
|
||
});
|
||
}, [countryOptions]);
|
||
|
||
const changeStatus = useCallback(
|
||
async (robot, checked) => {
|
||
setLoadingAction(`status-${robot.userId}`);
|
||
try {
|
||
await setDiceRobotStatus(robot.userId, checked ? "active" : "disabled");
|
||
await load("", false);
|
||
showToast("机器人状态已更新", "success");
|
||
} catch (err) {
|
||
showToast(err.message || "更新机器人状态失败", "error");
|
||
} finally {
|
||
setLoadingAction("");
|
||
}
|
||
},
|
||
[load, showToast],
|
||
);
|
||
|
||
const removeRobot = useCallback(
|
||
async (robot) => {
|
||
const ok = await confirm({
|
||
confirmText: "删除",
|
||
message: `${robot.nickname || robot.userId} 会从全站机器人列表移除,历史用户资料和对局记录保留。`,
|
||
title: "删除全站机器人",
|
||
tone: "danger",
|
||
});
|
||
if (!ok) {
|
||
return;
|
||
}
|
||
setLoadingAction(`delete-${robot.userId}`);
|
||
try {
|
||
await deleteDiceRobot(robot.userId);
|
||
await load("", false);
|
||
showToast("全站机器人已删除", "success");
|
||
} catch (err) {
|
||
showToast(err.message || "删除全站机器人失败", "error");
|
||
} finally {
|
||
setLoadingAction("");
|
||
}
|
||
},
|
||
[confirm, load, showToast],
|
||
);
|
||
|
||
const columns = useMemo(
|
||
() => [
|
||
{
|
||
key: "user",
|
||
label: "机器人用户",
|
||
width: "minmax(220px, 1fr)",
|
||
render: (robot) => (
|
||
<div className={styles.identity}>
|
||
<Avatar className={styles.robotAvatar} src={robot.avatar || undefined}>
|
||
{robotAvatarText(robot)}
|
||
</Avatar>
|
||
<div className={styles.identityText} title={`UID ${robot.userId}`}>
|
||
<span className={styles.name}>{robot.nickname || robot.userId}</span>
|
||
<span className={styles.meta}>{robot.shortId ? `短ID ${robot.shortId}` : "短ID -"}</span>
|
||
</div>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
key: "status",
|
||
label: "状态",
|
||
width: "120px",
|
||
render: (robot) => (
|
||
<Switch
|
||
checked={robot.status === "active"}
|
||
disabled={!canUpdate || loadingAction === `status-${robot.userId}`}
|
||
inputProps={{ "aria-label": `${robot.userId} 状态` }}
|
||
onChange={(event) => changeStatus(robot, event.target.checked)}
|
||
/>
|
||
),
|
||
},
|
||
{
|
||
key: "created",
|
||
label: "创建时间",
|
||
width: "minmax(170px, 1fr)",
|
||
render: (robot) => (robot.createdAtMs ? formatMillis(robot.createdAtMs) : "-"),
|
||
},
|
||
{
|
||
key: "updated",
|
||
label: "更新时间",
|
||
width: "minmax(170px, 1fr)",
|
||
render: (robot) => (robot.updatedAtMs ? formatMillis(robot.updatedAtMs) : "-"),
|
||
},
|
||
{
|
||
key: "actions",
|
||
label: "操作",
|
||
width: "96px",
|
||
render: (robot) => (
|
||
<AdminRowActions>
|
||
<AdminActionIconButton
|
||
disabled={
|
||
!canDelete || loadingAction === `delete-${robot.userId}` || Boolean(loadingAction)
|
||
}
|
||
label="删除"
|
||
tone="danger"
|
||
onClick={() => removeRobot(robot)}
|
||
>
|
||
<DeleteOutlineOutlined fontSize="small" />
|
||
</AdminActionIconButton>
|
||
</AdminRowActions>
|
||
),
|
||
},
|
||
],
|
||
[canDelete, canUpdate, changeStatus, loadingAction, removeRobot],
|
||
);
|
||
|
||
const submitGenerate = async (event) => {
|
||
event.preventDefault();
|
||
setLoadingAction("generate");
|
||
try {
|
||
const enabledCountry = countryOptions.some((option) => option.value === generateForm.country)
|
||
? generateForm.country
|
||
: countryOptions[0]?.value || generateForm.country || "SA";
|
||
const result = await generateDiceRobots({
|
||
country: enabledCountry,
|
||
count: Number(generateForm.count || 0),
|
||
nicknameLanguage: generateForm.nicknameLanguage || "arabic",
|
||
});
|
||
await load("", false);
|
||
showToast(`已批量创建 ${result.created || 0} 个机器人`, "success");
|
||
setDialogOpen(false);
|
||
} catch (err) {
|
||
showToast(err.message || "批量创建全站机器人失败", "error");
|
||
} finally {
|
||
setLoadingAction("");
|
||
}
|
||
};
|
||
|
||
if (loading) {
|
||
return <PageSkeleton />;
|
||
}
|
||
|
||
return (
|
||
<AdminListPage>
|
||
<AdminListToolbar
|
||
actions={
|
||
<>
|
||
<AdminActionIconButton label="刷新" onClick={() => load("", false)}>
|
||
<RefreshOutlined fontSize="small" />
|
||
</AdminActionIconButton>
|
||
<AdminActionIconButton
|
||
disabled={!canCreate}
|
||
label="批量创建"
|
||
primary
|
||
onClick={() => setDialogOpen(true)}
|
||
>
|
||
<AddOutlined fontSize="small" />
|
||
</AdminActionIconButton>
|
||
</>
|
||
}
|
||
filters={
|
||
<>
|
||
<AdminFilterSelect
|
||
label="状态"
|
||
value={status}
|
||
onChange={(event) => setStatus(event.target.value)}
|
||
>
|
||
<MenuItem value="">全部状态</MenuItem>
|
||
<MenuItem value="active">启用</MenuItem>
|
||
<MenuItem value="disabled">停用</MenuItem>
|
||
</AdminFilterSelect>
|
||
<AdminFilterResetButton disabled={!status} onClick={() => setStatus("")} />
|
||
</>
|
||
}
|
||
/>
|
||
<AdminListBody>
|
||
<DataTable
|
||
columns={columns}
|
||
infiniteScroll={{
|
||
hasMore: Boolean(nextCursor),
|
||
loading: false,
|
||
onLoadMore: () => load(nextCursor, true),
|
||
}}
|
||
items={items}
|
||
minWidth="760px"
|
||
rowKey={(robot) => robot.userId}
|
||
/>
|
||
</AdminListBody>
|
||
<GenerateDialog
|
||
form={generateForm}
|
||
countryOptions={countryOptions}
|
||
loadingCountries={loadingCountries}
|
||
loading={loadingAction === "generate"}
|
||
open={dialogOpen}
|
||
setForm={setGenerateForm}
|
||
onClose={() => setDialogOpen(false)}
|
||
onSubmit={submitGenerate}
|
||
/>
|
||
</AdminListPage>
|
||
);
|
||
}
|
||
|
||
function GenerateDialog({ countryOptions, form, loading, loadingCountries, open, setForm, onClose, onSubmit }) {
|
||
const selectCountryOptions = countryOptions.length > 0 ? countryOptions : [{ label: "SA", value: "SA" }];
|
||
const selectedCountry = selectCountryOptions.some((country) => country.value === form.country)
|
||
? form.country
|
||
: selectCountryOptions[0]?.value || "SA";
|
||
return (
|
||
<AdminFormDialog
|
||
loading={loading}
|
||
open={open}
|
||
submitLabel="创建"
|
||
title="批量创建全站机器人"
|
||
onClose={onClose}
|
||
onSubmit={onSubmit}
|
||
>
|
||
<AdminFormFieldGrid>
|
||
<TextField
|
||
helperText={`最多 ${maxGenerateRobotCount} 个`}
|
||
label="数量"
|
||
type="number"
|
||
value={form.count}
|
||
slotProps={{ htmlInput: { max: maxGenerateRobotCount, min: 1 } }}
|
||
onChange={(event) => setForm({ ...form, count: event.target.value })}
|
||
/>
|
||
<TextField
|
||
label="账号语言"
|
||
select
|
||
value={form.nicknameLanguage || "arabic"}
|
||
onChange={(event) => setForm({ ...form, nicknameLanguage: event.target.value })}
|
||
>
|
||
<MenuItem value="arabic">阿拉伯语账号</MenuItem>
|
||
<MenuItem value="english">英语账号</MenuItem>
|
||
</TextField>
|
||
<TextField
|
||
disabled={loadingCountries}
|
||
label="国家"
|
||
select
|
||
value={selectedCountry}
|
||
onChange={(event) => setForm({ ...form, country: event.target.value })}
|
||
>
|
||
{selectCountryOptions.map((country) => (
|
||
<MenuItem key={country.value} value={country.value}>
|
||
{country.label}
|
||
</MenuItem>
|
||
))}
|
||
</TextField>
|
||
</AdminFormFieldGrid>
|
||
</AdminFormDialog>
|
||
);
|
||
}
|
||
|
||
function robotAvatarText(robot) {
|
||
const value = String(robot.nickname || robot.shortId || robot.userId || "?").trim();
|
||
return value ? value.slice(0, 1).toUpperCase() : "?";
|
||
}
|