2026-06-09 15:13:03 +08:00

714 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Add from "@mui/icons-material/Add";
import CodeOutlined from "@mui/icons-material/CodeOutlined";
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import EditOutlined from "@mui/icons-material/EditOutlined";
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
import SportsEsportsOutlined from "@mui/icons-material/SportsEsportsOutlined";
import SyncOutlined from "@mui/icons-material/SyncOutlined";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import Checkbox from "@mui/material/Checkbox";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { AdminFormDialog, AdminFormFieldGrid, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { JsonEditorField } from "@/shared/ui/JsonEditorField.jsx";
import {
AdminActionIconButton,
AdminListBody,
AdminListPage,
AdminListToolbar,
AdminRowActions,
} from "@/shared/ui/AdminListLayout.jsx";
import { createOptionsColumnFilter } from "@/shared/ui/tableFilters.js";
import { UploadField } from "@/shared/ui/UploadField.jsx";
import { formatMillis } from "@/shared/utils/time.js";
import { useGamesPage } from "@/features/games/hooks/useGamesPage.js";
import styles from "@/features/games/games.module.css";
const statusOptions = [
["", "全部状态"],
["active", "Active"],
["maintenance", "Maintenance"],
["disabled", "Disabled"],
];
const orientationOptions = [
["portrait", "Portrait"],
["landscape", "Landscape"],
];
const launchModeOptions = [
["full_screen", "全屏"],
["half_screen", "半屏"],
["three_quarter_screen", "3/4屏"],
];
// 这里的值必须和 game-service 的 adapter_type 白名单一致;新增厂商先后端登记,再放到后台可选项。
const adapterTypeOptions = [
["yomi_v4", "小游 Yomi V4"],
["leadercc_v1", "灵仙 LeaderCC V1"],
["zeeone_v1", "ZeeOne V1"],
["baishun_v1", "百顺 BAISHUN V1"],
["vivagames_v1", "VIVAGAMES V1"],
["reyou_v1", "热游 Reyou V1"],
];
const baseColumns = [
{
key: "game",
label: "游戏",
width: "minmax(290px, 1.5fr)",
render: (game) => <GameIdentity game={game} />,
},
{
key: "platform",
label: "平台",
width: "minmax(160px, 0.75fr)",
render: (game) => (
<div className={styles.stack}>
<span>{game.platformCode}</span>
<span className={styles.meta}>{game.providerGameId}</span>
</div>
),
},
{
key: "launch",
label: "启动",
width: "minmax(150px, 0.75fr)",
render: (game) => (
<div className={styles.stack}>
<span>{launchModeLabel(game.launchMode)}</span>
<span className={styles.meta}>
{game.orientation} · 安全高 {formatNumber(game.safeHeight)}
</span>
</div>
),
},
{
key: "coin",
label: "最低金币",
width: "minmax(116px, 0.55fr)",
render: (game) => formatNumber(game.minCoin),
},
{
key: "status",
label: "状态",
width: "minmax(120px, 0.6fr)",
},
{
key: "updatedAt",
label: "更新时间",
width: "minmax(170px, 0.9fr)",
render: (game) => formatMillis(game.updatedAtMs || game.createdAtMs),
},
{
key: "actions",
label: "操作",
width: "minmax(112px, 0.5fr)",
},
];
export function GameListPage() {
const page = useGamesPage();
const items = page.data.items || [];
const platformFilterOptions = [["", "全部平台"], ...page.platformOptions];
const columns = baseColumns.map((column) => {
if (column.key === "platform") {
return {
...column,
filter: createOptionsColumnFilter({
options: platformFilterOptions,
placeholder: "搜索平台",
value: page.platformCode,
onChange: page.setPlatformCode,
}),
};
}
if (column.key === "status") {
return {
...column,
filter: createOptionsColumnFilter({
options: statusOptions,
placeholder: "搜索状态",
value: page.status,
onChange: page.setStatus,
}),
render: (game) => <GameStatusSwitch game={game} page={page} />,
};
}
if (column.key === "actions") {
return {
...column,
render: (game) => <GameRowActions game={game} page={page} />,
};
}
return column;
});
return (
<AdminListPage>
<AdminListToolbar
actions={
<>
{page.abilities.canUpdate ? (
<AdminActionIconButton label="JS 桥接" onClick={page.openBridgeScript}>
<CodeOutlined fontSize="small" />
</AdminActionIconButton>
) : null}
{page.abilities.canUpdate ? (
<AdminActionIconButton label="平台配置" onClick={page.openManagePlatforms}>
<SettingsOutlined fontSize="small" />
</AdminActionIconButton>
) : null}
{page.abilities.canUpdate ? (
<AdminActionIconButton label="添加平台" onClick={page.openCreatePlatform}>
<SportsEsportsOutlined fontSize="small" />
</AdminActionIconButton>
) : null}
{page.abilities.canCreate ? (
<AdminActionIconButton label="添加游戏" primary onClick={page.openCreateGame}>
<Add fontSize="small" />
</AdminActionIconButton>
) : null}
</>
}
/>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<AdminListBody>
<DataTable
columns={columns}
infiniteScroll={{
doneLabel: "",
hasMore: Boolean(page.data.nextCursor),
loading: page.loadingMore,
loadingLabel: "加载中...",
onLoadMore: page.loadNextPage,
}}
items={items}
minWidth="1240px"
pagination={{
hasNextPage: Boolean(page.data.nextCursor),
itemCount: items.length,
page: page.page,
pageSize: page.pageSize,
total: page.data.total,
totalPages: page.data.totalPages,
}}
rowKey={(game) => game.gameId}
/>
</AdminListBody>
</DataState>
<GameFormDialog
form={page.gameForm}
loading={page.loadingAction === page.activeAction}
mode={page.activeAction === "edit-game" ? "edit" : "create"}
open={page.activeAction === "create-game" || page.activeAction === "edit-game"}
page={page}
onClose={page.closeAction}
onSubmit={page.submitGame}
/>
<PlatformFormDialog
form={page.platformForm}
loading={page.loadingAction === page.activeAction}
mode={page.activeAction === "edit-platform" ? "edit" : "create"}
open={page.activeAction === "create-platform" || page.activeAction === "edit-platform"}
page={page}
onClose={page.closeAction}
onSubmit={page.submitPlatform}
/>
<BridgeScriptDialog
form={page.bridgeScriptForm}
loading={page.loadingAction === "bridge-script"}
open={page.activeAction === "bridge-script"}
page={page}
onClose={page.closeAction}
onSubmit={page.submitBridgeScript}
/>
<PlatformManageDialog
open={page.activeAction === "manage-platforms"}
page={page}
onClose={page.closeAction}
/>
<ProviderGameListDialog open={page.activeAction === "sync-games"} page={page} onClose={page.closeAction} />
</AdminListPage>
);
}
function GameIdentity({ game }) {
return (
<div className={styles.identity}>
{game.iconUrl ? (
<img alt="" className={styles.icon} src={game.iconUrl} />
) : (
<span className={styles.icon} />
)}
<div className={styles.identityText}>
<span className={styles.name}>{game.gameName || game.gameId}</span>
<span className={styles.meta}>{game.gameId}</span>
</div>
</div>
);
}
function GameStatusSwitch({ page, game }) {
const disabled = !page.abilities.canStatus || page.loadingAction === `status-${game.gameId}`;
return (
<AdminSwitch
checked={game.status === "active"}
checkedLabel="启用"
disabled={disabled}
label={`${game.gameName || game.gameId} 状态`}
size="small"
uncheckedLabel={game.status === "disabled" ? "下架" : "维护"}
onChange={(event) => page.changeGameStatus(game, event.target.checked)}
/>
);
}
function GameRowActions({ page, game }) {
return (
<AdminRowActions>
<AdminActionIconButton
disabled={!page.abilities.canUpdate}
label="编辑游戏"
onClick={() => page.openEditGame(game)}
>
<EditOutlined fontSize="small" />
</AdminActionIconButton>
<AdminActionIconButton
disabled={!page.abilities.canDelete || page.loadingAction === `delete-${game.gameId}`}
label="删除游戏"
onClick={() => page.deleteGame(game)}
>
<DeleteOutlineOutlined fontSize="small" />
</AdminActionIconButton>
</AdminRowActions>
);
}
function GameFormDialog({ form, loading, mode, open, page, onClose, onSubmit }) {
const disabled = mode === "edit" ? !page.abilities.canUpdate : !page.abilities.canCreate;
return (
<AdminFormDialog
disabled={disabled}
loading={loading}
open={open}
size="wide"
submitLabel={mode === "edit" ? "保存游戏" : "创建游戏"}
title={mode === "edit" ? "编辑游戏" : "添加游戏"}
onClose={onClose}
onSubmit={onSubmit}
>
<AdminFormSection title="基础信息">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<TextField
disabled={mode === "edit"}
label="游戏 ID"
required
value={form.gameId}
onChange={(event) => page.setGameForm({ ...form, gameId: event.target.value })}
/>
<TextField
label="平台"
required
select={page.platformOptions.length > 0}
value={form.platformCode}
onChange={(event) => page.setGameForm({ ...form, platformCode: event.target.value })}
>
{page.platformOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label || value}
</MenuItem>
))}
</TextField>
<TextField
label="平台游戏 ID"
required
value={form.providerGameId}
onChange={(event) => page.setGameForm({ ...form, providerGameId: event.target.value })}
/>
<TextField
label="游戏名称"
required
value={form.gameName}
onChange={(event) => page.setGameForm({ ...form, gameName: event.target.value })}
/>
<TextField
label="类目"
value={form.category}
onChange={(event) => page.setGameForm({ ...form, category: event.target.value })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="启动与状态">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<TextField
label="启动模式"
select
value={form.launchMode}
onChange={(event) => page.setGameForm({ ...form, launchMode: event.target.value })}
>
{launchModeOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
label="方向"
select
value={form.orientation}
onChange={(event) => page.setGameForm({ ...form, orientation: event.target.value })}
>
{orientationOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
label="安全高"
type="number"
value={form.safeHeight}
onChange={(event) => page.setGameForm({ ...form, safeHeight: event.target.value })}
/>
<TextField
label="游戏 URL"
className={styles.fullField}
minRows={2}
multiline
placeholder="填写当前游戏的 H5 测试/正式 URL"
value={form.gameUrl}
onChange={(event) => page.setGameForm({ ...form, gameUrl: event.target.value })}
/>
<TextField
label="最低金币"
type="number"
value={form.minCoin}
onChange={(event) => page.setGameForm({ ...form, minCoin: event.target.value })}
/>
<TextField
label="排序"
type="number"
value={form.sortOrder}
onChange={(event) => page.setGameForm({ ...form, sortOrder: event.target.value })}
/>
<SwitchOnlyField
checked={form.status === "active"}
checkedLabel="启用"
disabled={disabled}
label="游戏启用状态"
uncheckedLabel="维护"
onChange={(checked) =>
page.setGameForm({
...form,
status: checked ? "active" : form.status === "disabled" ? "disabled" : "maintenance",
})
}
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="素材与标签">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<UploadField
disabled={disabled || !page.abilities.canUpload}
label="图标"
value={form.iconUrl}
onChange={(iconUrl) => page.setGameForm({ ...form, iconUrl })}
/>
<UploadField
disabled={disabled || !page.abilities.canUpload}
label="封面"
value={form.coverUrl}
onChange={(coverUrl) => page.setGameForm({ ...form, coverUrl })}
/>
<TextField
className={styles.fullField}
label="标签"
value={form.tagsText}
onChange={(event) => page.setGameForm({ ...form, tagsText: event.target.value })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
</AdminFormDialog>
);
}
function BridgeScriptDialog({ form, loading, open, page, onClose, onSubmit }) {
const disabled = !page.abilities.canUpdate;
const submitDisabled = disabled || loading || !form.bridgeScriptUrl.trim() || !form.bridgeScriptVersion.trim();
return (
<AdminFormDialog
disabled={disabled}
loading={loading}
open={open}
submitDisabled={submitDisabled}
submitLabel="保存桥接"
title="JS 桥接配置"
onClose={onClose}
onSubmit={onSubmit}
>
<AdminFormSection title="通用桥接脚本">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<TextField
label="bridge_script_version"
required
value={form.bridgeScriptVersion}
onChange={(event) =>
page.setBridgeScriptForm({ ...form, bridgeScriptVersion: event.target.value })
}
/>
<TextField
className={styles.fullField}
label="bridge_script_url"
minRows={2}
multiline
required
value={form.bridgeScriptUrl}
onChange={(event) => page.setBridgeScriptForm({ ...form, bridgeScriptUrl: event.target.value })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
</AdminFormDialog>
);
}
function PlatformFormDialog({ form, loading, mode, open, page, onClose, onSubmit }) {
const isLeaderCC = form.adapterType === "leadercc_v1";
return (
<AdminFormDialog
disabled={!page.abilities.canUpdate}
loading={loading}
open={open}
submitLabel={mode === "edit" ? "保存平台" : "创建平台"}
title={mode === "edit" ? "编辑平台" : "添加平台"}
onClose={onClose}
onSubmit={onSubmit}
>
<AdminFormSection title="平台信息">
<AdminFormFieldGrid>
<TextField
disabled={mode === "edit"}
label="平台 Code"
required
value={form.platformCode}
onChange={(event) => page.setPlatformForm({ ...form, platformCode: event.target.value })}
/>
<TextField
label="平台名称"
required
value={form.platformName}
onChange={(event) => page.setPlatformForm({ ...form, platformName: event.target.value })}
/>
<SwitchOnlyField
checked={form.status === "active"}
checkedLabel="启用"
disabled={!page.abilities.canUpdate}
label="游戏平台启用状态"
uncheckedLabel="维护"
onChange={(checked) =>
page.setPlatformForm({
...form,
status: checked ? "active" : form.status === "disabled" ? "disabled" : "maintenance",
})
}
/>
<TextField
label="排序"
type="number"
value={form.sortOrder}
onChange={(event) => page.setPlatformForm({ ...form, sortOrder: event.target.value })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="接入配置">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
{/* 适配器决定服务端启动参数、签名/解密和错误码映射。 */}
<TextField
label="适配器"
select
value={form.adapterType}
onChange={(event) => page.setPlatformForm({ ...form, adapterType: event.target.value })}
>
{adapterTypeOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
{!isLeaderCC ? (
<TextField
label="H5/Auth/API URL"
required={form.adapterType === "yomi_v4" || form.adapterType === "baishun_v1"}
value={form.apiBaseUrl}
onChange={(event) => page.setPlatformForm({ ...form, apiBaseUrl: event.target.value })}
/>
) : null}
{/* 后台直接回显当前厂商 key清空保存时后端保持旧密钥输入新值才轮换。 */}
<TextField
label="回调密钥"
value={form.callbackSecret}
onChange={(event) => page.setPlatformForm({ ...form, callbackSecret: event.target.value })}
/>
{!isLeaderCC ? (
<TextField
label="IP 白名单"
minRows={3}
multiline
value={form.callbackIpWhitelistText}
onChange={(event) =>
page.setPlatformForm({ ...form, callbackIpWhitelistText: event.target.value })
}
/>
) : null}
{/* 厂商差异字段放 JSON如 uid_mode、default_lang、token_ttl_seconds、game_level_uid。 */}
<JsonEditorField
className={styles.fullField}
label="适配器配置 JSON"
minRows={5}
value={form.adapterConfigJson}
onChange={(adapterConfigJson) => page.setPlatformForm({ ...form, adapterConfigJson })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
</AdminFormDialog>
);
}
function PlatformManageDialog({ open, page, onClose }) {
const platforms = page.platformData.items || [];
return (
<AdminFormDialog
disabled={false}
loading={false}
open={open}
submitLabel="关闭"
title="平台配置"
onClose={onClose}
onSubmit={(event) => {
event.preventDefault();
onClose();
}}
>
<div className={styles.platformList}>
{platforms.map((platform) => (
<div className={styles.platformRow} key={platform.platformCode}>
<div className={styles.stack}>
<span className={styles.name}>{platform.platformName || platform.platformCode}</span>
<span className={styles.meta}>
{platform.platformCode} / {platform.adapterType || "未配置"}
</span>
{platform.apiBaseUrl ? <span className={styles.meta}>{platform.apiBaseUrl}</span> : null}
<span className={`${styles.meta} ${styles.secretMeta}`}>
密钥{platform.callbackSecret || "未配置"}
</span>
</div>
<div className={styles.platformActions}>
<span className={styles.meta}>
{platform.callbackSecretSet ? "密钥已配置" : "未配置密钥"}
</span>
<AdminActionIconButton
disabled={!page.abilities.canUpdate}
label="编辑平台"
onClick={() => page.openEditPlatform(platform)}
>
<EditOutlined fontSize="small" />
</AdminActionIconButton>
<AdminActionIconButton
disabled={
!page.abilities.canUpdate ||
page.loadingAction === `fetch-games-${platform.platformCode}`
}
label="获取游戏列表"
onClick={() => page.fetchPlatformGames(platform)}
>
<SyncOutlined fontSize="small" />
</AdminActionIconButton>
</div>
</div>
))}
</div>
</AdminFormDialog>
);
}
function ProviderGameListDialog({ open, page, onClose }) {
const games = page.syncCandidates || [];
const selected = new Set(page.selectedSyncGameIds || []);
return (
<AdminFormDialog
loading={page.loadingAction === "import-sync-games"}
open={open}
size="wide"
submitDisabled={selected.size === 0 || page.loadingAction === "import-sync-games"}
submitLabel={`添加选中 (${selected.size})`}
title={`厂商游戏列表 ${page.syncPlatform?.platformName || page.syncPlatform?.platformCode || ""}`}
onClose={onClose}
onSubmit={page.importSelectedSyncGames}
>
<AdminFormSection
title="选择要添加的游戏"
actions={
<Button disabled={games.length === 0} onClick={page.toggleAllSyncGames}>
{page.allSyncGamesSelected ? "取消全选" : "全选"}
</Button>
}
>
<div className={styles.syncGameList}>
{games.length > 0 ? (
games.map((game) => (
<div className={styles.syncGameRow} key={game.providerGameId || game.gameId}>
<Checkbox
checked={selected.has(game.providerGameId)}
inputProps={{ "aria-label": `${game.gameName || game.gameId} 选择状态` }}
onChange={() => page.toggleSyncGame(game.providerGameId)}
/>
<GameIdentity game={game} />
<div className={styles.syncGameMeta}>
<span className={styles.meta}>{game.providerGameId}</span>
<span className={styles.meta}>默认下架</span>
{game.launchUrl ? (
<span className={`${styles.meta} ${styles.syncGameURL}`}>{game.launchUrl}</span>
) : null}
</div>
<AdminActionIconButton
disabled={page.loadingAction === `import-sync-game-${game.providerGameId}`}
label="添加单个游戏"
onClick={() => page.importSingleSyncGame(game)}
>
<Add fontSize="small" />
</AdminActionIconButton>
</div>
))
) : (
<span className={styles.meta}>暂无可添加游戏</span>
)}
</div>
</AdminFormSection>
</AdminFormDialog>
);
}
function SwitchOnlyField({ checked, checkedLabel, disabled, label, onChange, uncheckedLabel }) {
return (
<span className={styles.switchOnlyField}>
<AdminSwitch
checked={checked}
checkedLabel={checkedLabel}
disabled={disabled}
label={label}
uncheckedLabel={uncheckedLabel}
onChange={(event) => onChange(event.target.checked, event)}
/>
</span>
);
}
function formatNumber(value) {
return Number(value || 0).toLocaleString();
}
function launchModeLabel(value) {
return launchModeOptions.find(([mode]) => mode === value)?.[1] || "全屏";
}