admin相关
This commit is contained in:
parent
e2d8d03260
commit
7b094f2b6f
@ -17,6 +17,8 @@
|
||||
13. 左侧菜单 active 样式优先级必须高于 hover 样式。
|
||||
14. 侧边栏展开/收起按钮必须跟随状态切换图标。
|
||||
15. 展开态 Logo 区域和侧边栏宽度保持 248px。
|
||||
16. 所有逻辑功能配置默认打开;只有密钥、地址、三方账号等必须由环境提供的敏感值使用占位符或环境配置,不在默认配置里伪造可用凭证。
|
||||
17. 所有业务逻辑、状态流转、权限判断、支付链路、异步流程都保持高密度逻辑注释,注释解释为什么这样处理和失败分支,不复述语法。
|
||||
|
||||
## 当前实现方式
|
||||
|
||||
@ -97,7 +99,7 @@ import { Refresh } from "@mui/icons-material";
|
||||
- 区域编辑弹窗必须同时包含区域基础字段和国家码字段;国家码使用可搜索多选下拉框,不再拆成独立“区域国家”弹窗。
|
||||
- 头像、图片、封面等图片资源字段必须使用 `src/shared/ui/UploadField.jsx` 上传表单组件,不使用普通文本输入框承载 URL。
|
||||
- 图片上传表单必须提供统一预览;`webp` 使用浏览器原生动效预览,`svga` 使用 SVGA 播放器预览,`pag` 使用 libpag + wasm 预览。
|
||||
- BD Leader 创建弹窗必须使用区域下拉选择 `regionId`;目标用户原区域可以不同,创建成功后后端会把目标用户当前区域同步到所选区域。
|
||||
- BD Leader 创建弹窗不选择区域、不提交 `regionId`;创建结果按目标用户当前区域生成,若区域不对,先修复用户国家/区域映射,再创建 BD Leader。
|
||||
- 区域创建和区域启用/禁用调用后台管理后端接口,不要求 user-service 新增对应 RPC。
|
||||
- 国家创建和国家启用/禁用调用后台管理后端接口,不要求 user-service 新增对应 RPC。
|
||||
|
||||
|
||||
@ -1607,6 +1607,130 @@
|
||||
"x-permissions": ["game:update"]
|
||||
}
|
||||
},
|
||||
"/admin/game/self-games": {
|
||||
"get": {
|
||||
"operationId": "listSelfGames",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "game:view",
|
||||
"x-permissions": ["game:view"]
|
||||
}
|
||||
},
|
||||
"/admin/game/self-games/{game_id}/config": {
|
||||
"patch": {
|
||||
"operationId": "updateDiceConfig",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "game_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"x-permission": "game:update",
|
||||
"x-permissions": ["game:update"]
|
||||
}
|
||||
},
|
||||
"/admin/game/self-games/{game_id}/pool/adjust": {
|
||||
"post": {
|
||||
"operationId": "adjustDicePool",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "game_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"x-permission": "game:update",
|
||||
"x-permissions": ["game:update"]
|
||||
}
|
||||
},
|
||||
"/admin/game/robots": {
|
||||
"get": {
|
||||
"operationId": "listDiceRobots",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "game:view",
|
||||
"x-permissions": ["game:view"]
|
||||
}
|
||||
},
|
||||
"/admin/game/robots/generate": {
|
||||
"post": {
|
||||
"operationId": "generateDiceRobots",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "game:create",
|
||||
"x-permissions": ["game:create"]
|
||||
}
|
||||
},
|
||||
"/admin/game/robots/{user_id}": {
|
||||
"delete": {
|
||||
"operationId": "deleteDiceRobot",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "user_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"x-permission": "game:delete",
|
||||
"x-permissions": ["game:delete"]
|
||||
}
|
||||
},
|
||||
"/admin/game/robots/{user_id}/status": {
|
||||
"patch": {
|
||||
"operationId": "setDiceRobotStatus",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "user_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"x-permission": "game:update",
|
||||
"x-permissions": ["game:update"]
|
||||
}
|
||||
},
|
||||
"/admin/gift-types": {
|
||||
"get": {
|
||||
"operationId": "listGiftTypes",
|
||||
@ -2079,6 +2203,62 @@
|
||||
"x-permissions": ["payment-product:delete"]
|
||||
}
|
||||
},
|
||||
"/admin/payment/third-party-channels": {
|
||||
"get": {
|
||||
"operationId": "listThirdPartyPaymentChannels",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "payment-third-party:view",
|
||||
"x-permissions": ["payment-third-party:view"]
|
||||
}
|
||||
},
|
||||
"/admin/payment/third-party-methods/{method_id}/status": {
|
||||
"patch": {
|
||||
"operationId": "setThirdPartyPaymentMethodStatus",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "method_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"x-permission": "payment-third-party:update",
|
||||
"x-permissions": ["payment-third-party:update"]
|
||||
}
|
||||
},
|
||||
"/admin/payment/third-party-rates/{method_id}": {
|
||||
"patch": {
|
||||
"operationId": "updateThirdPartyPaymentRate",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "method_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"x-permission": "payment-third-party:update",
|
||||
"x-permissions": ["payment-third-party:update"]
|
||||
}
|
||||
},
|
||||
"/admin/regions": {
|
||||
"get": {
|
||||
"operationId": "listRegions",
|
||||
@ -3521,6 +3701,118 @@
|
||||
},
|
||||
"x-permissions": ["user:export"]
|
||||
}
|
||||
},
|
||||
"/admin/users/pretty-id-pools": {
|
||||
"get": {
|
||||
"operationId": "listPrettyIdPools",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "pretty-id:view",
|
||||
"x-permissions": ["pretty-id:view"]
|
||||
},
|
||||
"post": {
|
||||
"operationId": "createPrettyIdPool",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "pretty-id:update",
|
||||
"x-permissions": ["pretty-id:update"]
|
||||
}
|
||||
},
|
||||
"/admin/users/pretty-id-pools/{pool_id}": {
|
||||
"put": {
|
||||
"operationId": "updatePrettyIdPool",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "pool_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"x-permission": "pretty-id:update",
|
||||
"x-permissions": ["pretty-id:update"]
|
||||
}
|
||||
},
|
||||
"/admin/users/pretty-id-pools/{pool_id}/generate": {
|
||||
"post": {
|
||||
"operationId": "generatePrettyIds",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "pool_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"x-permission": "pretty-id:generate",
|
||||
"x-permissions": ["pretty-id:generate"]
|
||||
}
|
||||
},
|
||||
"/admin/users/pretty-ids": {
|
||||
"get": {
|
||||
"operationId": "listPrettyIds",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "pretty-id:view",
|
||||
"x-permissions": ["pretty-id:view"]
|
||||
}
|
||||
},
|
||||
"/admin/users/pretty-ids/grant": {
|
||||
"post": {
|
||||
"operationId": "grantPrettyId",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "pretty-id:grant",
|
||||
"x-permissions": ["pretty-id:grant"]
|
||||
}
|
||||
},
|
||||
"/admin/users/pretty-ids/{pretty_id}/status": {
|
||||
"post": {
|
||||
"operationId": "setPrettyIdStatus",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "pretty_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"x-permission": "pretty-id:update",
|
||||
"x-permissions": ["pretty-id:update"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
|
||||
@ -15,6 +15,7 @@ import HubOutlined from "@mui/icons-material/HubOutlined";
|
||||
import ImageOutlined from "@mui/icons-material/ImageOutlined";
|
||||
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
|
||||
import LeaderboardOutlined from "@mui/icons-material/LeaderboardOutlined";
|
||||
import LocalOfferOutlined from "@mui/icons-material/LocalOfferOutlined";
|
||||
import LoginOutlined from "@mui/icons-material/LoginOutlined";
|
||||
import ManageAccountsOutlined from "@mui/icons-material/ManageAccountsOutlined";
|
||||
import MapOutlined from "@mui/icons-material/MapOutlined";
|
||||
@ -74,6 +75,7 @@ const iconMap = {
|
||||
sports_esports: SportsEsportsOutlined,
|
||||
star: StarBorderOutlined,
|
||||
storefront: StorefrontOutlined,
|
||||
tag: LocalOfferOutlined,
|
||||
task: TaskAltOutlined,
|
||||
users: ManageAccountsOutlined,
|
||||
wallet: WalletOutlined,
|
||||
@ -108,6 +110,7 @@ export const fallbackNavigation = [
|
||||
routeNavItem("app-user-list", { icon: ManageAccountsOutlined }),
|
||||
routeNavItem("app-user-bans", { icon: BlockOutlined }),
|
||||
routeNavItem("app-user-login-logs", { icon: LoginOutlined }),
|
||||
routeNavItem("app-user-pretty-ids", { icon: LocalOfferOutlined }),
|
||||
routeNavItem("app-user-region-blocks", { icon: PublicOutlined }),
|
||||
],
|
||||
},
|
||||
@ -148,7 +151,6 @@ export const fallbackNavigation = [
|
||||
routeNavItem("app-config-banners", { icon: ImageOutlined }),
|
||||
routeNavItem("app-config-splash-screens", { icon: ImageOutlined }),
|
||||
routeNavItem("app-config-explore", { icon: MapOutlined }),
|
||||
routeNavItem("payment-recharge-products", { icon: WalletOutlined }),
|
||||
routeNavItem("app-config-versions", { icon: SettingsApplicationsOutlined }),
|
||||
],
|
||||
},
|
||||
@ -185,7 +187,11 @@ export const fallbackNavigation = [
|
||||
icon: WalletOutlined,
|
||||
id: "payment",
|
||||
label: "支付管理",
|
||||
children: [routeNavItem("payment-bill-list", { icon: ReceiptLongOutlined })],
|
||||
children: [
|
||||
routeNavItem("payment-bill-list", { icon: ReceiptLongOutlined }),
|
||||
routeNavItem("payment-third-party", { icon: WalletOutlined }),
|
||||
routeNavItem("payment-recharge-products", { icon: WalletOutlined }),
|
||||
],
|
||||
},
|
||||
{
|
||||
code: "activities",
|
||||
@ -212,7 +218,11 @@ export const fallbackNavigation = [
|
||||
icon: SportsEsportsOutlined,
|
||||
id: "games",
|
||||
label: "游戏管理",
|
||||
children: [routeNavItem("game-list", { icon: SportsEsportsOutlined })],
|
||||
children: [
|
||||
routeNavItem("game-list", { icon: SportsEsportsOutlined }),
|
||||
routeNavItem("self-games", { icon: SettingsApplicationsOutlined }),
|
||||
routeNavItem("game-robots", { icon: ManageAccountsOutlined }),
|
||||
],
|
||||
},
|
||||
{
|
||||
code: "geo",
|
||||
@ -263,7 +273,7 @@ export function mapBackendMenus(menus = []) {
|
||||
}
|
||||
|
||||
function relocateBackendMenus(menus = []) {
|
||||
return relocateRechargeProductsToAppConfig(relocateLogMenusToSystem(menus));
|
||||
return relocateLogMenusToSystem(menus);
|
||||
}
|
||||
|
||||
function orderTopLevelMenus(menus = []) {
|
||||
@ -277,34 +287,6 @@ function orderTopLevelMenus(menus = []) {
|
||||
.map(({ item }) => item);
|
||||
}
|
||||
|
||||
function relocateRechargeProductsToAppConfig(menus = []) {
|
||||
const appConfigMenu = menus.find((item) => item.code === "app-config");
|
||||
const paymentMenu = menus.find((item) => item.code === "payment");
|
||||
const rechargeProductMenu = paymentMenu?.children?.find((item) => item.code === "payment-recharge-products");
|
||||
if (!appConfigMenu || !rechargeProductMenu) {
|
||||
return menus;
|
||||
}
|
||||
|
||||
return menus
|
||||
.map((item) => {
|
||||
if (item.code === "app-config") {
|
||||
const children = item.children || [];
|
||||
if (children.some((child) => child.code === "payment-recharge-products")) {
|
||||
return item;
|
||||
}
|
||||
return { ...item, children: [...children, rechargeProductMenu] };
|
||||
}
|
||||
if (item.code === "payment") {
|
||||
return {
|
||||
...item,
|
||||
children: (item.children || []).filter((child) => child.code !== "payment-recharge-products"),
|
||||
};
|
||||
}
|
||||
return item;
|
||||
})
|
||||
.filter((item) => item.code !== "payment" || item.path || item.children?.length);
|
||||
}
|
||||
|
||||
function relocateLogMenusToSystem(menus = []) {
|
||||
const systemMenu = menus.find((item) => item.code === "system");
|
||||
const logsMenu = menus.find((item) => item.code === "logs");
|
||||
|
||||
@ -84,7 +84,7 @@ describe("navigation menu helpers", () => {
|
||||
expect(flattenCodes(merged)).toContain("host-org-managers");
|
||||
});
|
||||
|
||||
test("places recharge products under app config even when backend menu is stale", () => {
|
||||
test("keeps payment children under payment menu when backend menu is current", () => {
|
||||
const mapped = mapBackendMenus([
|
||||
{ children: [], code: "app-config", icon: "settings", id: "app-config", label: "APP配置" },
|
||||
{
|
||||
@ -97,6 +97,14 @@ describe("navigation menu helpers", () => {
|
||||
path: "/payment/recharge-products",
|
||||
permissionCode: "payment-product:view",
|
||||
},
|
||||
{
|
||||
code: "payment-third-party",
|
||||
icon: "wallet",
|
||||
id: "payment-third-party",
|
||||
label: "第三方支付",
|
||||
path: "/payment/third-party",
|
||||
permissionCode: "payment-third-party:view",
|
||||
},
|
||||
],
|
||||
code: "payment",
|
||||
icon: "wallet",
|
||||
@ -107,11 +115,11 @@ describe("navigation menu helpers", () => {
|
||||
const appConfig = mapped.find((item) => item.code === "app-config");
|
||||
const payment = mapped.find((item) => item.code === "payment");
|
||||
|
||||
expect(appConfig.children.map((item) => item.code)).toContain("payment-recharge-products");
|
||||
expect(appConfig.children.find((item) => item.code === "payment-recharge-products").path).toBe(
|
||||
"/app-config/recharge-products",
|
||||
expect(appConfig.children || []).toEqual([]);
|
||||
expect(payment.children.map((item) => item.code)).toEqual(["payment-recharge-products", "payment-third-party"]);
|
||||
expect(payment.children.find((item) => item.code === "payment-recharge-products").path).toBe(
|
||||
"/payment/recharge-products",
|
||||
);
|
||||
expect(payment).toBeUndefined();
|
||||
});
|
||||
|
||||
test("places splash screens after banners when backend menu is stale", () => {
|
||||
@ -131,12 +139,6 @@ describe("navigation menu helpers", () => {
|
||||
label: "Explore配置",
|
||||
path: "/app-config/explore",
|
||||
},
|
||||
{
|
||||
code: "payment-recharge-products",
|
||||
id: "payment-recharge-products",
|
||||
label: "内购配置",
|
||||
path: "/app-config/recharge-products",
|
||||
},
|
||||
{
|
||||
code: "app-config-versions",
|
||||
id: "app-config-versions",
|
||||
@ -159,7 +161,6 @@ describe("navigation menu helpers", () => {
|
||||
"app-config-banners",
|
||||
"app-config-splash-screens",
|
||||
"app-config-explore",
|
||||
"payment-recharge-products",
|
||||
"app-config-versions",
|
||||
]);
|
||||
});
|
||||
|
||||
@ -50,6 +50,8 @@ export const PERMISSIONS = {
|
||||
paymentProductCreate: "payment-product:create",
|
||||
paymentProductUpdate: "payment-product:update",
|
||||
paymentProductDelete: "payment-product:delete",
|
||||
paymentThirdPartyView: "payment-third-party:view",
|
||||
paymentThirdPartyUpdate: "payment-third-party:update",
|
||||
gameView: "game:view",
|
||||
gameCreate: "game:create",
|
||||
gameUpdate: "game:update",
|
||||
@ -79,6 +81,10 @@ export const PERMISSIONS = {
|
||||
appUserUpdate: "app-user:update",
|
||||
appUserStatus: "app-user:status",
|
||||
appUserPassword: "app-user:password",
|
||||
prettyIdView: "pretty-id:view",
|
||||
prettyIdUpdate: "pretty-id:update",
|
||||
prettyIdGenerate: "pretty-id:generate",
|
||||
prettyIdGrant: "pretty-id:grant",
|
||||
levelConfigView: "level-config:view",
|
||||
levelConfigUpdate: "level-config:update",
|
||||
regionBlockView: "region-block:view",
|
||||
@ -169,6 +175,7 @@ export const MENU_CODES = {
|
||||
appUserLoginLogs: "app-user-login-logs",
|
||||
appUserLevelConfig: "app-user-level-config",
|
||||
appUserRegionBlocks: "app-user-region-blocks",
|
||||
appUserPrettyIds: "app-user-pretty-ids",
|
||||
rooms: "rooms",
|
||||
roomList: "room-list",
|
||||
roomPins: "room-pins",
|
||||
@ -222,8 +229,11 @@ export const MENU_CODES = {
|
||||
payment: "payment",
|
||||
paymentBillList: "payment-bill-list",
|
||||
paymentRechargeProducts: "payment-recharge-products",
|
||||
paymentThirdParty: "payment-third-party",
|
||||
games: "games",
|
||||
gameList: "game-list",
|
||||
selfGames: "self-games",
|
||||
gameRobots: "game-robots",
|
||||
} as const;
|
||||
|
||||
export type MenuCode = (typeof MENU_CODES)[keyof typeof MENU_CODES];
|
||||
|
||||
@ -20,6 +20,7 @@ const emptyForm = () => ({
|
||||
countryCode: "",
|
||||
coverUrl: "",
|
||||
description: "",
|
||||
displayDurationMs: "3000",
|
||||
endsAtMs: "",
|
||||
param: "",
|
||||
platform: "android",
|
||||
@ -180,6 +181,7 @@ function normalizeForm(form) {
|
||||
return {
|
||||
...form,
|
||||
countryCode: form.countryCode || "",
|
||||
displayDurationMs: form.displayDurationMs || 3000,
|
||||
endsAtMs: form.endsAtMs || 0,
|
||||
regionId: form.regionId || 0,
|
||||
sortOrder: form.sortOrder || 0,
|
||||
@ -192,6 +194,7 @@ function formFromSplashScreen(item) {
|
||||
countryCode: item.countryCode || "",
|
||||
coverUrl: item.coverUrl || "",
|
||||
description: item.description || "",
|
||||
displayDurationMs: String(item.displayDurationMs || 3000),
|
||||
endsAtMs: item.endsAtMs || "",
|
||||
param: item.param || "",
|
||||
platform: item.platform || "android",
|
||||
|
||||
@ -160,6 +160,14 @@ export function SplashScreenConfigPage() {
|
||||
value={page.form.sortOrder}
|
||||
onChange={(event) => page.setForm({ sortOrder: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="展示时长(ms)"
|
||||
slotProps={{ htmlInput: { min: 0, step: 100 } }}
|
||||
type="number"
|
||||
value={page.form.displayDurationMs}
|
||||
onChange={(event) => page.setForm({ displayDurationMs: event.target.value })}
|
||||
/>
|
||||
<AdminFormSwitchField
|
||||
checked={page.form.status === "active"}
|
||||
checkedLabel="启用"
|
||||
@ -272,6 +280,12 @@ function splashScreenColumns(page) {
|
||||
label: "排序",
|
||||
width: "minmax(80px, 0.4fr)",
|
||||
},
|
||||
{
|
||||
key: "displayDurationMs",
|
||||
label: "展示时长",
|
||||
render: (item) => durationLabel(item.displayDurationMs),
|
||||
width: "minmax(110px, 0.55fr)",
|
||||
},
|
||||
{
|
||||
key: "deliveryTime",
|
||||
label: "投放时间",
|
||||
@ -410,6 +424,14 @@ function statusLabel(status) {
|
||||
return "关闭";
|
||||
}
|
||||
|
||||
function durationLabel(value) {
|
||||
const duration = Number(value || 3000);
|
||||
if (!Number.isFinite(duration) || duration < 0) {
|
||||
return "3000 ms";
|
||||
}
|
||||
return `${duration} ms`;
|
||||
}
|
||||
|
||||
function regionLabel(options, value) {
|
||||
if (!value) {
|
||||
return "全部区域";
|
||||
|
||||
@ -33,15 +33,6 @@ export const appConfigRoutes = [
|
||||
path: "/app-config/explore",
|
||||
permission: PERMISSIONS.appConfigView,
|
||||
},
|
||||
{
|
||||
label: "内购配置",
|
||||
loader: () =>
|
||||
import("../payment/pages/RechargeProductConfigPage.jsx").then((module) => module.RechargeProductConfigPage),
|
||||
menuCode: MENU_CODES.paymentRechargeProducts,
|
||||
pageKey: "payment-recharge-products",
|
||||
path: "/app-config/recharge-products",
|
||||
permission: PERMISSIONS.paymentProductView,
|
||||
},
|
||||
{
|
||||
label: "版本管理",
|
||||
loader: () => import("./pages/AppVersionManagementPage.jsx").then((module) => module.AppVersionManagementPage),
|
||||
|
||||
@ -28,6 +28,7 @@ const validSplashScreenForm = {
|
||||
countryCode: "",
|
||||
coverUrl: "https://media.haiyihy.com/splash/launch.png",
|
||||
description: "",
|
||||
displayDurationMs: "4500",
|
||||
endsAtMs: "1800000000000",
|
||||
param: "https://h5.example.com/splash",
|
||||
platform: "ios",
|
||||
@ -102,6 +103,7 @@ describe("app config form schema", () => {
|
||||
const payload = parseForm(appSplashScreenSchema, validSplashScreenForm);
|
||||
|
||||
expect(payload.splashType).toBe("h5");
|
||||
expect(payload.displayDurationMs).toBe(4500);
|
||||
expect(payload.startsAtMs).toBe(1700000000000);
|
||||
expect(payload.endsAtMs).toBe(1800000000000);
|
||||
expect("displayScopes" in payload).toBe(false);
|
||||
@ -116,6 +118,15 @@ describe("app config form schema", () => {
|
||||
).toThrow(FormValidationError);
|
||||
});
|
||||
|
||||
test("rejects negative splash screen display duration", () => {
|
||||
expect(() =>
|
||||
parseForm(appSplashScreenSchema, {
|
||||
...validSplashScreenForm,
|
||||
displayDurationMs: "-1",
|
||||
}),
|
||||
).toThrow(FormValidationError);
|
||||
});
|
||||
|
||||
test("rejects inverted splash screen delivery time range", () => {
|
||||
expect(() =>
|
||||
parseForm(appSplashScreenSchema, {
|
||||
|
||||
@ -98,6 +98,7 @@ export const appSplashScreenSchema = z
|
||||
.max(1024, "封面图地址不能超过 1024 个字符")
|
||||
.refine((value) => !/\s/.test(value), "封面图地址不能包含空白字符"),
|
||||
description: z.string().trim().max(255, "描述不能超过 255 个字符"),
|
||||
displayDurationMs: z.coerce.number().int("展示时长必须是整数毫秒").min(0, "展示时长不能小于 0").default(3000),
|
||||
endsAtMs: z.coerce.number().int().min(0, "投放结束时间不正确").default(0),
|
||||
param: z.string().trim().max(2048, "参数不能超过 2048 个字符"),
|
||||
platform: z.enum(["android", "ios"]),
|
||||
|
||||
@ -1,12 +1,65 @@
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { setAccessToken } from "@/shared/api/request";
|
||||
import { listAppUsers } from "./api";
|
||||
import { listAppUsers, listPrettyDisplayIDs } from "./api";
|
||||
|
||||
afterEach(() => {
|
||||
setAccessToken("");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("listPrettyDisplayIDs uses admin endpoint and normalizes snake case fields", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
code: 0,
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
assigned_user_id: "10001",
|
||||
display_user_id: "VIP2026",
|
||||
pool: {
|
||||
level_track: "wealth",
|
||||
max_level: 10,
|
||||
min_level: 1,
|
||||
name: "财富池",
|
||||
pool_id: "pool-1",
|
||||
rule_type: "aabbcc",
|
||||
status: "active",
|
||||
},
|
||||
pool_id: "pool-1",
|
||||
pretty_id: "pretty-1",
|
||||
source: "pool",
|
||||
status: "assigned",
|
||||
},
|
||||
],
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
total: 1,
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await listPrettyDisplayIDs({ keyword: "VIP", page: 1, page_size: 20, status: "assigned" });
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0];
|
||||
|
||||
expect(String(url)).toContain("/api/v1/admin/users/pretty-ids?");
|
||||
expect(String(url)).toContain("keyword=VIP");
|
||||
expect(String(url)).toContain("status=assigned");
|
||||
expect(init?.method).toBe("GET");
|
||||
expect(result.pageSize).toBe(20);
|
||||
expect(result.items[0]).toMatchObject({
|
||||
assignedUserId: "10001",
|
||||
displayUserId: "VIP2026",
|
||||
pool: { levelTrack: "wealth", poolId: "pool-1" },
|
||||
prettyId: "pretty-1",
|
||||
});
|
||||
});
|
||||
|
||||
test("listAppUsers sends country region and time filters", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
|
||||
@ -7,8 +7,16 @@ import type {
|
||||
AppUserDto,
|
||||
AppUserPasswordPayload,
|
||||
AppUserUpdatePayload,
|
||||
AdminGrantPrettyDisplayIDResultDto,
|
||||
EntityId,
|
||||
GeneratePrettyDisplayIDsPayload,
|
||||
GrantPrettyDisplayIDPayload,
|
||||
PageQuery,
|
||||
PrettyDisplayIDDto,
|
||||
PrettyDisplayIDGenerationBatchDto,
|
||||
PrettyDisplayIDPoolDto,
|
||||
PrettyDisplayIDPoolPayload,
|
||||
SetPrettyDisplayIDStatusPayload,
|
||||
} from "@/shared/api/types";
|
||||
|
||||
export function listAppUsers(query: PageQuery = {}): Promise<ApiPage<AppUserDto>> {
|
||||
@ -70,3 +78,296 @@ export function setAppUserPassword(
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 靓号管理接口路径全部来自生成的 OpenAPI 端点,页面层只依赖这些函数,避免手写 URL 和契约漂移。
|
||||
export async function listPrettyDisplayIDPools(query: PageQuery = {}): Promise<ApiPage<PrettyDisplayIDPoolDto>> {
|
||||
const endpoint = API_ENDPOINTS.listPrettyIdPools;
|
||||
const data = await apiRequest<ApiPage<RawPrettyDisplayIDPool>>(apiEndpointPath(API_OPERATIONS.listPrettyIdPools), {
|
||||
method: endpoint.method,
|
||||
query,
|
||||
});
|
||||
return normalizePage(data, normalizePrettyDisplayIDPool);
|
||||
}
|
||||
|
||||
export async function createPrettyDisplayIDPool(payload: PrettyDisplayIDPoolPayload): Promise<PrettyDisplayIDPoolDto> {
|
||||
const endpoint = API_ENDPOINTS.createPrettyIdPool;
|
||||
const data = await apiRequest<RawPrettyDisplayIDPool, PrettyDisplayIDPoolPayload>(
|
||||
apiEndpointPath(API_OPERATIONS.createPrettyIdPool),
|
||||
{
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
},
|
||||
);
|
||||
return normalizePrettyDisplayIDPool(data);
|
||||
}
|
||||
|
||||
export async function updatePrettyDisplayIDPool(
|
||||
poolId: EntityId,
|
||||
payload: PrettyDisplayIDPoolPayload,
|
||||
): Promise<PrettyDisplayIDPoolDto> {
|
||||
const endpoint = API_ENDPOINTS.updatePrettyIdPool;
|
||||
const data = await apiRequest<RawPrettyDisplayIDPool, PrettyDisplayIDPoolPayload>(
|
||||
apiEndpointPath(API_OPERATIONS.updatePrettyIdPool, { pool_id: poolId }),
|
||||
{
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
},
|
||||
);
|
||||
return normalizePrettyDisplayIDPool(data);
|
||||
}
|
||||
|
||||
export async function generatePrettyDisplayIDs(
|
||||
poolId: EntityId,
|
||||
payload: GeneratePrettyDisplayIDsPayload,
|
||||
): Promise<PrettyDisplayIDGenerationBatchDto> {
|
||||
const endpoint = API_ENDPOINTS.generatePrettyIds;
|
||||
const data = await apiRequest<RawPrettyDisplayIDGenerationBatch, GeneratePrettyDisplayIDsPayload>(
|
||||
apiEndpointPath(API_OPERATIONS.generatePrettyIds, { pool_id: poolId }),
|
||||
{
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
},
|
||||
);
|
||||
return normalizePrettyDisplayIDGenerationBatch(data);
|
||||
}
|
||||
|
||||
export async function listPrettyDisplayIDs(query: PageQuery = {}): Promise<ApiPage<PrettyDisplayIDDto>> {
|
||||
const endpoint = API_ENDPOINTS.listPrettyIds;
|
||||
const data = await apiRequest<ApiPage<RawPrettyDisplayID>>(apiEndpointPath(API_OPERATIONS.listPrettyIds), {
|
||||
method: endpoint.method,
|
||||
query,
|
||||
});
|
||||
return normalizePage(data, normalizePrettyDisplayID);
|
||||
}
|
||||
|
||||
export async function grantPrettyDisplayID(
|
||||
payload: GrantPrettyDisplayIDPayload,
|
||||
): Promise<AdminGrantPrettyDisplayIDResultDto> {
|
||||
const endpoint = API_ENDPOINTS.grantPrettyId;
|
||||
const data = await apiRequest<RawAdminGrantPrettyDisplayIDResult, GrantPrettyDisplayIDPayload>(
|
||||
apiEndpointPath(API_OPERATIONS.grantPrettyId),
|
||||
{
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
},
|
||||
);
|
||||
return normalizeAdminGrantPrettyDisplayIDResult(data);
|
||||
}
|
||||
|
||||
export async function setPrettyDisplayIDStatus(
|
||||
prettyId: EntityId,
|
||||
payload: SetPrettyDisplayIDStatusPayload,
|
||||
): Promise<PrettyDisplayIDDto> {
|
||||
const endpoint = API_ENDPOINTS.setPrettyIdStatus;
|
||||
const data = await apiRequest<RawPrettyDisplayID, SetPrettyDisplayIDStatusPayload>(
|
||||
apiEndpointPath(API_OPERATIONS.setPrettyIdStatus, { pretty_id: prettyId }),
|
||||
{
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
},
|
||||
);
|
||||
return normalizePrettyDisplayID(data);
|
||||
}
|
||||
|
||||
// Go 后台当前按 snake_case JSON 返回,生成类型和页面代码使用 camelCase;Raw 类型同时接收两种形态,便于后续切换生成客户端。
|
||||
interface RawPrettyDisplayIDPool {
|
||||
app_code?: string;
|
||||
appCode?: string;
|
||||
created_at_ms?: number;
|
||||
createdAtMs?: number;
|
||||
created_by_admin_id?: EntityId;
|
||||
createdByAdminId?: EntityId;
|
||||
level_track?: string;
|
||||
levelTrack?: string;
|
||||
max_level?: number;
|
||||
maxLevel?: number;
|
||||
min_level?: number;
|
||||
minLevel?: number;
|
||||
name?: string;
|
||||
pool_id?: string;
|
||||
poolId?: string;
|
||||
rule_config_json?: string;
|
||||
ruleConfigJson?: string;
|
||||
rule_type?: string;
|
||||
ruleType?: string;
|
||||
sort_order?: number;
|
||||
sortOrder?: number;
|
||||
status?: string;
|
||||
updated_at_ms?: number;
|
||||
updatedAtMs?: number;
|
||||
updated_by_admin_id?: EntityId;
|
||||
updatedByAdminId?: EntityId;
|
||||
}
|
||||
|
||||
interface RawPrettyDisplayID {
|
||||
app_code?: string;
|
||||
appCode?: string;
|
||||
assigned_at_ms?: number;
|
||||
assignedAtMs?: number;
|
||||
assigned_lease_id?: string;
|
||||
assignedLeaseId?: string;
|
||||
assigned_user_id?: EntityId;
|
||||
assignedUserId?: EntityId;
|
||||
created_at_ms?: number;
|
||||
createdAtMs?: number;
|
||||
created_by_admin_id?: EntityId;
|
||||
createdByAdminId?: EntityId;
|
||||
display_user_id?: string;
|
||||
displayUserId?: string;
|
||||
generated_batch_id?: string;
|
||||
generatedBatchId?: string;
|
||||
pool?: RawPrettyDisplayIDPool | null;
|
||||
pool_id?: string;
|
||||
poolId?: string;
|
||||
pretty_id?: string;
|
||||
prettyId?: string;
|
||||
release_reason?: string;
|
||||
releaseReason?: string;
|
||||
released_at_ms?: number;
|
||||
releasedAtMs?: number;
|
||||
source?: string;
|
||||
status?: string;
|
||||
updated_at_ms?: number;
|
||||
updatedAtMs?: number;
|
||||
}
|
||||
|
||||
interface RawPrettyDisplayIDGenerationBatch {
|
||||
app_code?: string;
|
||||
appCode?: string;
|
||||
batch_id?: string;
|
||||
batchId?: string;
|
||||
created_at_ms?: number;
|
||||
createdAtMs?: number;
|
||||
generated_count?: number;
|
||||
generatedCount?: number;
|
||||
operator_admin_id?: EntityId;
|
||||
operatorAdminId?: EntityId;
|
||||
pool_id?: string;
|
||||
poolId?: string;
|
||||
request_id?: string;
|
||||
requestId?: string;
|
||||
requested_count?: number;
|
||||
requestedCount?: number;
|
||||
rule_config_json?: string;
|
||||
ruleConfigJson?: string;
|
||||
rule_type?: string;
|
||||
ruleType?: string;
|
||||
skipped_conflict_count?: number;
|
||||
skippedConflictCount?: number;
|
||||
status?: string;
|
||||
updated_at_ms?: number;
|
||||
updatedAtMs?: number;
|
||||
}
|
||||
|
||||
interface RawAdminGrantPrettyDisplayIDResult {
|
||||
identity?: {
|
||||
default_display_user_id?: string;
|
||||
defaultDisplayUserId?: string;
|
||||
display_user_id?: string;
|
||||
displayUserId?: string;
|
||||
pretty_display_user_id?: string;
|
||||
prettyDisplayUserId?: string;
|
||||
pretty_id?: string;
|
||||
prettyId?: string;
|
||||
user_id?: EntityId;
|
||||
userId?: EntityId;
|
||||
};
|
||||
lease_id?: string;
|
||||
leaseId?: string;
|
||||
pretty_id?: string;
|
||||
prettyId?: string;
|
||||
}
|
||||
|
||||
// 分页结构也做一次兼容,page_size 来自 Go 响应,pageSize 来自前端 ApiPage 约定。
|
||||
function normalizePage<TRaw, TItem>(
|
||||
data: ApiPage<TRaw> & { page_size?: number },
|
||||
normalize: (item: TRaw) => TItem,
|
||||
): ApiPage<TItem> {
|
||||
return {
|
||||
items: (data.items || []).map(normalize),
|
||||
page: Number(data.page || 1),
|
||||
pageSize: Number(data.pageSize || data.page_size || 20),
|
||||
total: Number(data.total || 0),
|
||||
};
|
||||
}
|
||||
|
||||
// 池 DTO 在这里完成字段名和数值兜底,页面表格不再关心后端是否省略可选字段。
|
||||
function normalizePrettyDisplayIDPool(item: RawPrettyDisplayIDPool): PrettyDisplayIDPoolDto {
|
||||
return {
|
||||
appCode: item.appCode ?? item.app_code ?? "",
|
||||
createdAtMs: item.createdAtMs ?? item.created_at_ms ?? 0,
|
||||
createdByAdminId: item.createdByAdminId ?? item.created_by_admin_id ?? "",
|
||||
levelTrack: item.levelTrack ?? item.level_track ?? "",
|
||||
maxLevel: Number(item.maxLevel ?? item.max_level ?? 0),
|
||||
minLevel: Number(item.minLevel ?? item.min_level ?? 0),
|
||||
name: item.name ?? "",
|
||||
poolId: item.poolId ?? item.pool_id ?? "",
|
||||
ruleConfigJson: item.ruleConfigJson ?? item.rule_config_json ?? "",
|
||||
ruleType: item.ruleType ?? item.rule_type ?? "",
|
||||
sortOrder: Number(item.sortOrder ?? item.sort_order ?? 0),
|
||||
status: item.status ?? "",
|
||||
updatedAtMs: item.updatedAtMs ?? item.updated_at_ms ?? 0,
|
||||
updatedByAdminId: item.updatedByAdminId ?? item.updated_by_admin_id ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
// 靓号明细包含池号和后台发放两类来源,pool 可能为空,归一化后页面只判断 source/status 即可。
|
||||
function normalizePrettyDisplayID(item: RawPrettyDisplayID): PrettyDisplayIDDto {
|
||||
return {
|
||||
appCode: item.appCode ?? item.app_code ?? "",
|
||||
assignedAtMs: item.assignedAtMs ?? item.assigned_at_ms ?? 0,
|
||||
assignedLeaseId: item.assignedLeaseId ?? item.assigned_lease_id ?? "",
|
||||
assignedUserId: item.assignedUserId ?? item.assigned_user_id ?? "",
|
||||
createdAtMs: item.createdAtMs ?? item.created_at_ms ?? 0,
|
||||
createdByAdminId: item.createdByAdminId ?? item.created_by_admin_id ?? "",
|
||||
displayUserId: item.displayUserId ?? item.display_user_id ?? "",
|
||||
generatedBatchId: item.generatedBatchId ?? item.generated_batch_id ?? "",
|
||||
pool: item.pool ? normalizePrettyDisplayIDPool(item.pool) : null,
|
||||
poolId: item.poolId ?? item.pool_id ?? "",
|
||||
prettyId: item.prettyId ?? item.pretty_id ?? "",
|
||||
releaseReason: item.releaseReason ?? item.release_reason ?? "",
|
||||
releasedAtMs: item.releasedAtMs ?? item.released_at_ms ?? 0,
|
||||
source: item.source ?? "",
|
||||
status: item.status ?? "",
|
||||
updatedAtMs: item.updatedAtMs ?? item.updated_at_ms ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
// 生成结果用于展示本次批量生成的真实落库数量和冲突跳过数量,数值统一转换,避免字符串数字影响统计。
|
||||
function normalizePrettyDisplayIDGenerationBatch(
|
||||
item: RawPrettyDisplayIDGenerationBatch,
|
||||
): PrettyDisplayIDGenerationBatchDto {
|
||||
return {
|
||||
appCode: item.appCode ?? item.app_code ?? "",
|
||||
batchId: item.batchId ?? item.batch_id ?? "",
|
||||
createdAtMs: item.createdAtMs ?? item.created_at_ms ?? 0,
|
||||
generatedCount: Number(item.generatedCount ?? item.generated_count ?? 0),
|
||||
operatorAdminId: item.operatorAdminId ?? item.operator_admin_id ?? "",
|
||||
poolId: item.poolId ?? item.pool_id ?? "",
|
||||
requestId: item.requestId ?? item.request_id ?? "",
|
||||
requestedCount: Number(item.requestedCount ?? item.requested_count ?? 0),
|
||||
ruleConfigJson: item.ruleConfigJson ?? item.rule_config_json ?? "",
|
||||
ruleType: item.ruleType ?? item.rule_type ?? "",
|
||||
skippedConflictCount: Number(item.skippedConflictCount ?? item.skipped_conflict_count ?? 0),
|
||||
status: item.status ?? "",
|
||||
updatedAtMs: item.updatedAtMs ?? item.updated_at_ms ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
// 后台发放返回的是新 lease 和用户当前身份快照,identity 归一化后能直接用于测试和后续结果提示。
|
||||
function normalizeAdminGrantPrettyDisplayIDResult(
|
||||
item: RawAdminGrantPrettyDisplayIDResult,
|
||||
): AdminGrantPrettyDisplayIDResultDto {
|
||||
const identity = item.identity || {};
|
||||
return {
|
||||
identity: {
|
||||
defaultDisplayUserId: identity.defaultDisplayUserId ?? identity.default_display_user_id ?? "",
|
||||
displayUserId: identity.displayUserId ?? identity.display_user_id ?? "",
|
||||
prettyDisplayUserId: identity.prettyDisplayUserId ?? identity.pretty_display_user_id ?? "",
|
||||
prettyId: identity.prettyId ?? identity.pretty_id ?? "",
|
||||
userId: String(identity.userId ?? identity.user_id ?? ""),
|
||||
},
|
||||
leaseId: item.leaseId ?? item.lease_id ?? "",
|
||||
prettyId: item.prettyId ?? item.pretty_id ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,273 +1,426 @@
|
||||
.root {
|
||||
display: flex;
|
||||
height: calc(100vh - var(--header-height) - var(--space-8) - var(--space-4));
|
||||
height: calc(100dvh - var(--header-height) - var(--space-8) - var(--space-4));
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
height: calc(100vh - var(--header-height) - var(--space-8) - var(--space-4));
|
||||
height: calc(100dvh - var(--header-height) - var(--space-8) - var(--space-4));
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.contentPanel {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--bg-card);
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.contentPanel :global(.data-state),
|
||||
.contentPanel :global(.table-frame) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.contentPanel :global(.table-scroll) {
|
||||
overflow: auto;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.prettyContentPanel {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.prettyWorkspace {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
grid-template-columns: minmax(380px, 0.78fr) minmax(0, 1.22fr);
|
||||
}
|
||||
|
||||
.prettyPane {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.prettyPoolPane {
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.prettyPane :global(.data-state) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.prettyPaneHeader {
|
||||
display: flex;
|
||||
min-height: 58px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
flex: 0 0 auto;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.prettyPaneTitle {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.prettyPaneTitle h2 {
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 16px;
|
||||
font-weight: 750;
|
||||
line-height: 1.25;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.prettyPaneTitle span {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.prettyFilters {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
flex: 0 0 auto;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
.compactTableFrame :global(.admin-row) {
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.compactTableFrame :global(.admin-row--head),
|
||||
.compactTableFrame :global(.admin-cell--head) {
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.compactTableFrame :global(.admin-cell) {
|
||||
padding-right: var(--space-3);
|
||||
padding-left: var(--space-3);
|
||||
}
|
||||
|
||||
.compactTableFrame :global(.pagination-bar) {
|
||||
min-height: 42px;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
flex: 0 0 auto;
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
flex: 0 0 auto;
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.toolbarLeft {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.toolbarActions {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.selectedMeta {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--admin-font-size);
|
||||
font-weight: 650;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--admin-font-size);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.timeFilter {
|
||||
flex: 0 1 280px;
|
||||
min-width: 220px;
|
||||
flex: 0 1 280px;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.selectColumn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: visible;
|
||||
padding-right: var(--space-2) !important;
|
||||
padding-left: var(--space-2) !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: visible;
|
||||
padding-right: var(--space-2) !important;
|
||||
padding-left: var(--space-2) !important;
|
||||
}
|
||||
|
||||
.selectColumn :global(.admin-cell__head-content) {
|
||||
justify-content: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sortHeader {
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: inherit;
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
.sortHeader:hover,
|
||||
.sortHeaderActive {
|
||||
color: var(--text-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sortHeader span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sortIcon {
|
||||
flex: 0 0 auto;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 18px;
|
||||
flex: 0 0 auto;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.sortHeaderActive .sortIcon {
|
||||
color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.search {
|
||||
flex: 0 1 320px;
|
||||
flex: 1 1 240px;
|
||||
}
|
||||
|
||||
.compactControl {
|
||||
flex: 0 1 150px;
|
||||
}
|
||||
|
||||
.userIDControl {
|
||||
flex: 0 1 130px;
|
||||
}
|
||||
|
||||
.statusSelect {
|
||||
flex: 0 0 150px;
|
||||
flex: 0 0 150px;
|
||||
}
|
||||
|
||||
.listBlock {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
background: var(--bg-card);
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.listBlock :global(.pagination-bar) {
|
||||
margin: var(--space-3) var(--space-5) var(--space-4);
|
||||
margin: var(--space-3) var(--space-5) var(--space-4);
|
||||
}
|
||||
|
||||
.identity {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
display: inline-flex;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
background: var(--bg-card-strong);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 700;
|
||||
display: inline-flex;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
background: var(--bg-card-strong);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.prettyIcon {
|
||||
display: inline-flex;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
background: var(--primary-surface);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.identityText {
|
||||
min-width: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nameLine {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--text-primary);
|
||||
font-weight: 650;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--text-primary);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gender {
|
||||
display: inline-flex;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.genderMale {
|
||||
background: rgba(37, 99, 235, 0.1);
|
||||
color: #2563eb;
|
||||
background: rgba(37, 99, 235, 0.1);
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.genderFemale {
|
||||
background: rgba(219, 39, 119, 0.12);
|
||||
color: #db2777;
|
||||
background: rgba(219, 39, 119, 0.12);
|
||||
color: #db2777;
|
||||
}
|
||||
|
||||
.genderUnknown {
|
||||
background: var(--neutral-surface);
|
||||
color: var(--text-tertiary);
|
||||
background: var(--neutral-surface);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--admin-font-size);
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--admin-font-size);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.primaryText {
|
||||
color: var(--text-primary);
|
||||
font-weight: 650;
|
||||
color: var(--text-primary);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.countryButton {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 650;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 650;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.countryButton:hover {
|
||||
color: var(--primary-strong);
|
||||
color: var(--primary-strong);
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.formGridTwo {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.toolbar,
|
||||
.toolbarLeft,
|
||||
.filters {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
.prettyWorkspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.search,
|
||||
.statusSelect,
|
||||
.timeFilter {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
}
|
||||
.prettyPoolPane {
|
||||
min-height: 42dvh;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.prettyIDPane {
|
||||
min-height: 50dvh;
|
||||
}
|
||||
|
||||
.prettyPaneHeader,
|
||||
.toolbar,
|
||||
.toolbarLeft,
|
||||
.toolbarActions,
|
||||
.filters,
|
||||
.prettyFilters {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.search,
|
||||
.compactControl,
|
||||
.userIDControl,
|
||||
.statusSelect,
|
||||
.timeFilter {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.formGridTwo {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,18 +1,103 @@
|
||||
export const appUserStatusFilters = [
|
||||
["", "全部"],
|
||||
["active", "正常"],
|
||||
["banned", "封禁"]
|
||||
["", "全部"],
|
||||
["active", "正常"],
|
||||
["banned", "封禁"],
|
||||
];
|
||||
|
||||
export const appUserStatusLabels = {
|
||||
active: "正常",
|
||||
banned: "封禁",
|
||||
disabled: "封禁"
|
||||
active: "正常",
|
||||
banned: "封禁",
|
||||
disabled: "封禁",
|
||||
};
|
||||
|
||||
export const genderOptions = [
|
||||
["", "未设置"],
|
||||
["male", "男"],
|
||||
["female", "女"],
|
||||
["unknown", "未知"]
|
||||
["", "未设置"],
|
||||
["male", "男"],
|
||||
["female", "女"],
|
||||
["unknown", "未知"],
|
||||
];
|
||||
|
||||
export const prettyLevelTrackOptions = [
|
||||
["wealth", "财富等级"],
|
||||
["game", "游戏等级"],
|
||||
["charm", "魅力等级"],
|
||||
];
|
||||
|
||||
export const prettyPoolStatusOptions = [
|
||||
["", "全部状态"],
|
||||
["active", "启用"],
|
||||
["disabled", "禁用"],
|
||||
];
|
||||
|
||||
export const prettyPoolStatusLabels = {
|
||||
active: "启用",
|
||||
disabled: "禁用",
|
||||
};
|
||||
|
||||
export const prettyIdStatusOptions = [
|
||||
["", "全部状态"],
|
||||
["available", "可申请"],
|
||||
["assigned", "已占用"],
|
||||
["disabled", "禁用"],
|
||||
["reserved", "保留"],
|
||||
["released", "已释放"],
|
||||
["deleted", "已删除"],
|
||||
];
|
||||
|
||||
export const prettyIdStatusLabels = {
|
||||
available: "可申请",
|
||||
assigned: "已占用",
|
||||
disabled: "禁用",
|
||||
reserved: "保留",
|
||||
released: "已释放",
|
||||
deleted: "已删除",
|
||||
};
|
||||
|
||||
export const prettyIdSourceOptions = [
|
||||
["", "全部来源"],
|
||||
["pool", "靓号池"],
|
||||
["admin_grant", "后台发放"],
|
||||
];
|
||||
|
||||
export const prettyIdSourceLabels = {
|
||||
admin_grant: "后台发放",
|
||||
pool: "靓号池",
|
||||
};
|
||||
|
||||
export const prettyRuleOptions = [
|
||||
["aa", "AA · 两连"],
|
||||
["aaa", "AAA · 三连"],
|
||||
["aaaa", "AAAA · 四连"],
|
||||
["aaaaa", "5A · 五连"],
|
||||
["aaaaaa", "6A · 六连"],
|
||||
["aaaaaaa", "7A · 七连"],
|
||||
["aabb", "AABB · 两对子"],
|
||||
["aabbcc", "AABBCC · 三对子"],
|
||||
["aabbccdd", "AABBCCDD · 四对子"],
|
||||
["abab", "ABAB · 交替"],
|
||||
["ababab", "ABABAB · 三交替"],
|
||||
["abba", "ABBA · 回文"],
|
||||
["abcba", "ABCBA · 五位回文"],
|
||||
["abccba", "ABCCBA · 六位回文"],
|
||||
["aaab", "AAAB · 三拖一"],
|
||||
["aaaab", "AAAAB · 四拖一"],
|
||||
["aaaaab", "AAAAAB · 五拖一"],
|
||||
["aaabbb", "AAABBB · 双豹子"],
|
||||
["aaaabbbb", "AAAABBBB · 双炸弹"],
|
||||
["abc", "ABC · 三顺"],
|
||||
["abcd", "ABCD · 四顺"],
|
||||
["abcde", "ABCDE · 五顺"],
|
||||
["abcdef", "ABCDEF · 六顺"],
|
||||
["cba", "CBA · 三倒顺"],
|
||||
["dcba", "DCBA · 四倒顺"],
|
||||
["edcba", "EDCBA · 五倒顺"],
|
||||
["fedcba", "FEDCBA · 六倒顺"],
|
||||
["abcabc", "ABCABC · 顺子重复"],
|
||||
["date_yyyymmdd", "YYYYMMDD · 生日"],
|
||||
["date_yymmdd", "YYMMDD · 生日"],
|
||||
["love", "520/1314 · 寓意"],
|
||||
["custom_values", "自定义候选"],
|
||||
["custom_pattern", "自定义模式"],
|
||||
];
|
||||
|
||||
export const prettyRuleValues = prettyRuleOptions.map(([value]) => value);
|
||||
|
||||
346
src/features/app-users/hooks/usePrettyIdsPage.js
Normal file
346
src/features/app-users/hooks/usePrettyIdsPage.js
Normal file
@ -0,0 +1,346 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { parseForm } from "@/shared/forms/validation";
|
||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
import {
|
||||
createPrettyDisplayIDPool,
|
||||
generatePrettyDisplayIDs,
|
||||
grantPrettyDisplayID,
|
||||
listPrettyDisplayIDPools,
|
||||
listPrettyDisplayIDs,
|
||||
setPrettyDisplayIDStatus,
|
||||
updatePrettyDisplayIDPool,
|
||||
} from "@/features/app-users/api";
|
||||
import { useAppUserAbilities } from "@/features/app-users/permissions.js";
|
||||
import {
|
||||
prettyDisplayIDGenerateSchema,
|
||||
prettyDisplayIDGrantSchema,
|
||||
prettyDisplayIDPoolSchema,
|
||||
prettyDisplayIDStatusSchema,
|
||||
} from "@/features/app-users/schema";
|
||||
|
||||
const pageSize = 20;
|
||||
const emptyPoolForm = () => ({
|
||||
levelTrack: "wealth",
|
||||
maxLevel: 10,
|
||||
minLevel: 1,
|
||||
name: "",
|
||||
ruleConfigJson: "",
|
||||
ruleType: "aabbcc",
|
||||
sortOrder: 0,
|
||||
status: "active",
|
||||
});
|
||||
const emptyGenerateForm = () => ({ count: 100, poolId: "", ruleConfigJson: "", ruleType: "aabbcc" });
|
||||
const emptyGrantForm = () => ({ displayUserId: "", durationDays: "", reason: "", targetUserId: "" });
|
||||
const emptyStatusForm = () => ({ prettyId: "", reason: "", status: "available" });
|
||||
|
||||
export function usePrettyIdsPage() {
|
||||
const abilities = useAppUserAbilities();
|
||||
const { showToast } = useToast();
|
||||
const [poolPage, setPoolPage] = useState(1);
|
||||
const [poolStatus, setPoolStatus] = useState("");
|
||||
const [poolTrack, setPoolTrack] = useState("");
|
||||
const [idPage, setIdPage] = useState(1);
|
||||
const [idKeyword, setIdKeyword] = useState("");
|
||||
const [idStatus, setIdStatus] = useState("");
|
||||
const [idSource, setIdSource] = useState("");
|
||||
const [idPoolId, setIdPoolId] = useState("");
|
||||
const [assignedUserId, setAssignedUserId] = useState("");
|
||||
const [activeDialog, setActiveDialog] = useState("");
|
||||
const [activePool, setActivePool] = useState(null);
|
||||
const [activePrettyID, setActivePrettyID] = useState(null);
|
||||
const [poolForm, setPoolForm] = useState(emptyPoolForm);
|
||||
const [generateForm, setGenerateForm] = useState(emptyGenerateForm);
|
||||
const [grantForm, setGrantForm] = useState(emptyGrantForm);
|
||||
const [statusForm, setStatusForm] = useState(emptyStatusForm);
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
|
||||
// 靓号池和靓号明细是两个独立分页接口,筛选条件保持服务端字段名,避免在 hook 内再做一次字段映射。
|
||||
const poolFilters = useMemo(
|
||||
() => ({
|
||||
level_track: poolTrack,
|
||||
status: poolStatus,
|
||||
}),
|
||||
[poolStatus, poolTrack],
|
||||
);
|
||||
// 靓号明细筛选会直接透传到后台列表接口,assigned_user_id 在输入阶段已 trim,keyword 保留原值用于模糊搜索。
|
||||
const idFilters = useMemo(
|
||||
() => ({
|
||||
assigned_user_id: assignedUserId,
|
||||
keyword: idKeyword,
|
||||
pool_id: idPoolId,
|
||||
source: idSource,
|
||||
status: idStatus,
|
||||
}),
|
||||
[assignedUserId, idKeyword, idPoolId, idSource, idStatus],
|
||||
);
|
||||
|
||||
const poolQuery = usePaginatedQuery({
|
||||
errorMessage: "加载靓号池失败",
|
||||
fetcher: listPrettyDisplayIDPools,
|
||||
filters: poolFilters,
|
||||
page: poolPage,
|
||||
pageSize,
|
||||
queryKey: ["pretty-id-pools", poolFilters, poolPage],
|
||||
});
|
||||
const idQuery = usePaginatedQuery({
|
||||
errorMessage: "加载靓号列表失败",
|
||||
fetcher: listPrettyDisplayIDs,
|
||||
filters: idFilters,
|
||||
page: idPage,
|
||||
pageSize,
|
||||
queryKey: ["pretty-ids", idFilters, idPage],
|
||||
});
|
||||
|
||||
// 生成弹窗和明细筛选只需要当前已加载池列表;没有额外拉全量,避免页面初始化时放大后台查询。
|
||||
const poolOptions = useMemo(
|
||||
() =>
|
||||
(poolQuery.data.items || []).map((pool) => ({
|
||||
label: `${pool.name} · ${pool.minLevel}-${pool.maxLevel}`,
|
||||
value: pool.poolId,
|
||||
})),
|
||||
[poolQuery.data.items],
|
||||
);
|
||||
|
||||
const openCreatePool = () => {
|
||||
setActivePool(null);
|
||||
setPoolForm(emptyPoolForm());
|
||||
setActiveDialog("pool");
|
||||
};
|
||||
|
||||
const openEditPool = (pool) => {
|
||||
setActivePool(pool);
|
||||
// 编辑弹窗以服务端当前值为准回填,缺失字段只兜底到首版规则,避免把旧数据误清空。
|
||||
setPoolForm({
|
||||
levelTrack: pool.levelTrack || "wealth",
|
||||
maxLevel: pool.maxLevel ?? 10,
|
||||
minLevel: pool.minLevel ?? 1,
|
||||
name: pool.name || "",
|
||||
ruleConfigJson: pool.ruleConfigJson || "",
|
||||
ruleType: pool.ruleType || "aabbcc",
|
||||
sortOrder: pool.sortOrder || 0,
|
||||
status: pool.status || "active",
|
||||
});
|
||||
setActiveDialog("pool");
|
||||
};
|
||||
|
||||
const openGenerate = (pool) => {
|
||||
setActivePool(pool || null);
|
||||
// 从池行进入时优先使用该池,从工具栏进入时沿用列表筛选池,减少管理员重复选择。
|
||||
setGenerateForm({
|
||||
...emptyGenerateForm(),
|
||||
poolId: pool?.poolId || idPoolId || "",
|
||||
ruleConfigJson: pool?.ruleConfigJson || "",
|
||||
ruleType: pool?.ruleType || "aabbcc",
|
||||
});
|
||||
setActiveDialog("generate");
|
||||
};
|
||||
|
||||
const openGrant = () => {
|
||||
setGrantForm(emptyGrantForm());
|
||||
setActiveDialog("grant");
|
||||
};
|
||||
|
||||
const openStatus = (item) => {
|
||||
setActivePrettyID(item);
|
||||
// 状态弹窗默认给出最常用的反向操作:禁用中的号码恢复可用,其余未占用号码默认禁用。
|
||||
setStatusForm({
|
||||
prettyId: item?.prettyId || "",
|
||||
reason: "",
|
||||
status: item?.status === "disabled" ? "available" : "disabled",
|
||||
});
|
||||
setActiveDialog("status");
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
setActiveDialog("");
|
||||
setActivePool(null);
|
||||
setActivePrettyID(null);
|
||||
};
|
||||
|
||||
const submitPool = async (event) => {
|
||||
event.preventDefault();
|
||||
await runAction("pool", activePool ? "靓号池已更新" : "靓号池已创建", async () => {
|
||||
// 表单字段使用前端 camelCase,提交边界统一转换为后端 JSON 的 snake_case。
|
||||
const form = parseForm(prettyDisplayIDPoolSchema, poolForm);
|
||||
const payload = {
|
||||
level_track: form.levelTrack,
|
||||
max_level: form.maxLevel,
|
||||
min_level: form.minLevel,
|
||||
name: form.name,
|
||||
rule_config_json: form.ruleConfigJson,
|
||||
rule_type: form.ruleType,
|
||||
sort_order: form.sortOrder,
|
||||
status: form.status,
|
||||
};
|
||||
if (activePool?.poolId) {
|
||||
await updatePrettyDisplayIDPool(activePool.poolId, payload);
|
||||
} else {
|
||||
await createPrettyDisplayIDPool(payload);
|
||||
}
|
||||
closeDialog();
|
||||
await reloadAll();
|
||||
});
|
||||
};
|
||||
|
||||
const submitGenerate = async (event) => {
|
||||
event.preventDefault();
|
||||
await runAction("generate", "靓号已生成", async () => {
|
||||
// 批量生成只让后台接收池 ID、规则和数量,冲突过滤与批次审计由 user-service 事务完成。
|
||||
const form = parseForm(prettyDisplayIDGenerateSchema, generateForm);
|
||||
await generatePrettyDisplayIDs(form.poolId, {
|
||||
count: form.count,
|
||||
rule_config_json: form.ruleConfigJson,
|
||||
rule_type: form.ruleType,
|
||||
});
|
||||
closeDialog();
|
||||
await reloadAll();
|
||||
});
|
||||
};
|
||||
|
||||
const submitGrant = async (event) => {
|
||||
event.preventDefault();
|
||||
await runAction("grant", "靓号已发放", async () => {
|
||||
const form = parseForm(prettyDisplayIDGrantSchema, grantForm);
|
||||
// 后台发放用 0 表示长期,正数天数在前端换算成毫秒,服务端只处理 duration_ms 这个统一单位。
|
||||
const days = Number(form.durationDays || 0);
|
||||
await grantPrettyDisplayID({
|
||||
display_user_id: form.displayUserId,
|
||||
duration_ms: days > 0 ? days * 24 * 60 * 60 * 1000 : 0,
|
||||
reason: form.reason,
|
||||
target_user_id: form.targetUserId,
|
||||
});
|
||||
closeDialog();
|
||||
await idQuery.reload();
|
||||
});
|
||||
};
|
||||
|
||||
const submitStatus = async (event) => {
|
||||
event.preventDefault();
|
||||
await runAction("status", "靓号状态已更新", async () => {
|
||||
// 状态变更只允许未分配号码,前端提交 pretty_id 和目标状态,真正可变状态由后端再次校验。
|
||||
const form = parseForm(prettyDisplayIDStatusSchema, statusForm);
|
||||
await setPrettyDisplayIDStatus(form.prettyId, {
|
||||
reason: form.reason,
|
||||
status: form.status,
|
||||
});
|
||||
closeDialog();
|
||||
await idQuery.reload();
|
||||
});
|
||||
};
|
||||
|
||||
const changePoolStatus = (value) => {
|
||||
setPoolStatus(value);
|
||||
// 任意筛选变化都回到第一页,避免旧页码在新条件下落到空页。
|
||||
setPoolPage(1);
|
||||
};
|
||||
const changePoolTrack = (value) => {
|
||||
setPoolTrack(value);
|
||||
setPoolPage(1);
|
||||
};
|
||||
const changeIDKeyword = (value) => {
|
||||
setIdKeyword(value);
|
||||
// 明细列表的每个筛选项都会改变总数,页码统一回到第一页。
|
||||
setIdPage(1);
|
||||
};
|
||||
const changeIDStatus = (value) => {
|
||||
setIdStatus(value);
|
||||
setIdPage(1);
|
||||
};
|
||||
const changeIDSource = (value) => {
|
||||
setIdSource(value);
|
||||
setIdPage(1);
|
||||
};
|
||||
const changeIDPool = (value) => {
|
||||
setIdPoolId(value);
|
||||
setIdPage(1);
|
||||
};
|
||||
const changeAssignedUserId = (value) => {
|
||||
setAssignedUserId(value.trim());
|
||||
setIdPage(1);
|
||||
};
|
||||
|
||||
const resetPoolFilters = () => {
|
||||
// 重置筛选后立即回到第一页,保持 URL 之外的页面状态和查询条件同步。
|
||||
setPoolStatus("");
|
||||
setPoolTrack("");
|
||||
setPoolPage(1);
|
||||
};
|
||||
const resetIDFilters = () => {
|
||||
// 靓号列表筛选项彼此独立,重置时一次性清空,避免残留 assigned_user_id 影响排查。
|
||||
setAssignedUserId("");
|
||||
setIdKeyword("");
|
||||
setIdPoolId("");
|
||||
setIdSource("");
|
||||
setIdStatus("");
|
||||
setIdPage(1);
|
||||
};
|
||||
|
||||
const reloadAll = async () => {
|
||||
// 池配置会影响生成入口和号码列表展示,池相关操作完成后两个分页一起刷新。
|
||||
await Promise.all([poolQuery.reload(), idQuery.reload()]);
|
||||
};
|
||||
|
||||
const runAction = async (action, successMessage, submitter) => {
|
||||
// 所有写操作共享 loading、toast 和异常展示;弹窗只在 submitter 内确认上游成功后关闭。
|
||||
setLoadingAction(action);
|
||||
try {
|
||||
await submitter();
|
||||
showToast(successMessage, "success");
|
||||
} catch (err) {
|
||||
showToast(err.message || "操作失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
activeDialog,
|
||||
activePool,
|
||||
activePrettyID,
|
||||
assignedUserId,
|
||||
changeAssignedUserId,
|
||||
changeIDKeyword,
|
||||
changeIDPool,
|
||||
changeIDSource,
|
||||
changeIDStatus,
|
||||
changePoolStatus,
|
||||
changePoolTrack,
|
||||
closeDialog,
|
||||
generateForm,
|
||||
grantForm,
|
||||
idKeyword,
|
||||
idPage,
|
||||
idPoolId,
|
||||
idQuery,
|
||||
idSource,
|
||||
idStatus,
|
||||
loadingAction,
|
||||
openCreatePool,
|
||||
openEditPool,
|
||||
openGenerate,
|
||||
openGrant,
|
||||
openStatus,
|
||||
poolForm,
|
||||
poolOptions,
|
||||
poolPage,
|
||||
poolQuery,
|
||||
poolStatus,
|
||||
poolTrack,
|
||||
reloadAll,
|
||||
resetIDFilters,
|
||||
resetPoolFilters,
|
||||
setGenerateForm,
|
||||
setGrantForm,
|
||||
setIdPage,
|
||||
setPoolForm,
|
||||
setPoolPage,
|
||||
setStatusForm,
|
||||
statusForm,
|
||||
submitGenerate,
|
||||
submitGrant,
|
||||
submitPool,
|
||||
submitStatus,
|
||||
};
|
||||
}
|
||||
652
src/features/app-users/pages/AppUserPrettyIdsPage.jsx
Normal file
652
src/features/app-users/pages/AppUserPrettyIdsPage.jsx
Normal file
@ -0,0 +1,652 @@
|
||||
import AddCircleOutlineOutlined from "@mui/icons-material/AddCircleOutlineOutlined";
|
||||
import AutoAwesomeOutlined from "@mui/icons-material/AutoAwesomeOutlined";
|
||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||
import RestartAltOutlined from "@mui/icons-material/RestartAltOutlined";
|
||||
import SendOutlined from "@mui/icons-material/SendOutlined";
|
||||
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import { AdminFormDialog, 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 { IconButton } from "@/shared/ui/IconButton.jsx";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import {
|
||||
prettyIdSourceLabels,
|
||||
prettyIdSourceOptions,
|
||||
prettyIdStatusLabels,
|
||||
prettyIdStatusOptions,
|
||||
prettyLevelTrackOptions,
|
||||
prettyPoolStatusLabels,
|
||||
prettyPoolStatusOptions,
|
||||
prettyRuleOptions,
|
||||
} from "@/features/app-users/constants.js";
|
||||
import { usePrettyIdsPage } from "@/features/app-users/hooks/usePrettyIdsPage.js";
|
||||
import styles from "@/features/app-users/app-users.module.css";
|
||||
|
||||
export function AppUserPrettyIdsPage() {
|
||||
const page = usePrettyIdsPage();
|
||||
const pools = page.poolQuery.data.items || [];
|
||||
const prettyIds = page.idQuery.data.items || [];
|
||||
|
||||
return (
|
||||
<section className={styles.root}>
|
||||
<div className={`${styles.contentPanel} ${styles.prettyContentPanel}`}>
|
||||
<div className={styles.prettyWorkspace}>
|
||||
<section className={`${styles.prettyPane} ${styles.prettyPoolPane}`}>
|
||||
<PaneHeader
|
||||
actions={
|
||||
<>
|
||||
{page.abilities.canPrettyGenerate ? (
|
||||
<Button
|
||||
startIcon={<AutoAwesomeOutlined fontSize="small" />}
|
||||
onClick={() => page.openGenerate(null)}
|
||||
>
|
||||
批量生成
|
||||
</Button>
|
||||
) : null}
|
||||
{page.abilities.canPrettyUpdate ? (
|
||||
<Button
|
||||
startIcon={<AddCircleOutlineOutlined fontSize="small" />}
|
||||
variant="primary"
|
||||
onClick={page.openCreatePool}
|
||||
>
|
||||
新增池
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
count={page.poolQuery.data.total}
|
||||
title="靓号池"
|
||||
/>
|
||||
<div className={styles.prettyFilters}>
|
||||
<TextField
|
||||
className={styles.compactControl}
|
||||
label="等级轨道"
|
||||
select
|
||||
size="small"
|
||||
value={page.poolTrack}
|
||||
onChange={(event) => page.changePoolTrack(event.target.value)}
|
||||
>
|
||||
<MenuItem value="">全部等级轨道</MenuItem>
|
||||
{prettyLevelTrackOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
className={styles.compactControl}
|
||||
label="状态"
|
||||
select
|
||||
size="small"
|
||||
value={page.poolStatus}
|
||||
onChange={(event) => page.changePoolStatus(event.target.value)}
|
||||
>
|
||||
{prettyPoolStatusOptions.map(([value, label]) => (
|
||||
<MenuItem key={value || "all"} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<Button startIcon={<RestartAltOutlined fontSize="small" />} onClick={page.resetPoolFilters}>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
<DataState
|
||||
error={page.poolQuery.error}
|
||||
loading={page.poolQuery.loading}
|
||||
onRetry={page.poolQuery.reload}
|
||||
>
|
||||
<DataTable
|
||||
className={styles.compactTableFrame}
|
||||
columns={poolColumns(page)}
|
||||
items={pools}
|
||||
minWidth="720px"
|
||||
pagination={
|
||||
page.poolQuery.data.total > 0
|
||||
? {
|
||||
page: page.poolPage,
|
||||
pageSize: page.poolQuery.data.pageSize || 20,
|
||||
total: page.poolQuery.data.total,
|
||||
onPageChange: page.setPoolPage,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
rowKey={(pool) => pool.poolId}
|
||||
/>
|
||||
</DataState>
|
||||
</section>
|
||||
|
||||
<section className={`${styles.prettyPane} ${styles.prettyIDPane}`}>
|
||||
<PaneHeader
|
||||
actions={
|
||||
page.abilities.canPrettyGrant ? (
|
||||
<Button
|
||||
startIcon={<SendOutlined fontSize="small" />}
|
||||
variant="primary"
|
||||
onClick={page.openGrant}
|
||||
>
|
||||
后台发放
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
count={page.idQuery.data.total}
|
||||
title="靓号列表"
|
||||
/>
|
||||
<div className={styles.prettyFilters}>
|
||||
<TextField
|
||||
className={styles.search}
|
||||
label="搜索"
|
||||
size="small"
|
||||
value={page.idKeyword}
|
||||
onChange={(event) => page.changeIDKeyword(event.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
className={styles.compactControl}
|
||||
label="靓号池"
|
||||
select
|
||||
size="small"
|
||||
value={page.idPoolId}
|
||||
onChange={(event) => page.changeIDPool(event.target.value)}
|
||||
>
|
||||
<MenuItem value="">全部靓号池</MenuItem>
|
||||
{page.poolOptions.map((option) => (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
className={styles.compactControl}
|
||||
label="来源"
|
||||
select
|
||||
size="small"
|
||||
value={page.idSource}
|
||||
onChange={(event) => page.changeIDSource(event.target.value)}
|
||||
>
|
||||
{prettyIdSourceOptions.map(([value, label]) => (
|
||||
<MenuItem key={value || "all"} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
className={styles.compactControl}
|
||||
label="状态"
|
||||
select
|
||||
size="small"
|
||||
value={page.idStatus}
|
||||
onChange={(event) => page.changeIDStatus(event.target.value)}
|
||||
>
|
||||
{prettyIdStatusOptions.map(([value, label]) => (
|
||||
<MenuItem key={value || "all"} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
className={styles.userIDControl}
|
||||
label="占用用户"
|
||||
size="small"
|
||||
value={page.assignedUserId}
|
||||
onChange={(event) => page.changeAssignedUserId(event.target.value)}
|
||||
/>
|
||||
<Button startIcon={<RestartAltOutlined fontSize="small" />} onClick={page.resetIDFilters}>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
<DataState
|
||||
error={page.idQuery.error}
|
||||
loading={page.idQuery.loading}
|
||||
onRetry={page.idQuery.reload}
|
||||
>
|
||||
<DataTable
|
||||
className={styles.compactTableFrame}
|
||||
columns={idColumns(page)}
|
||||
items={prettyIds}
|
||||
minWidth="980px"
|
||||
pagination={
|
||||
page.idQuery.data.total > 0
|
||||
? {
|
||||
page: page.idPage,
|
||||
pageSize: page.idQuery.data.pageSize || 20,
|
||||
total: page.idQuery.data.total,
|
||||
onPageChange: page.setIdPage,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
rowKey={(item) => item.prettyId}
|
||||
/>
|
||||
</DataState>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PoolDialog page={page} />
|
||||
<GenerateDialog page={page} />
|
||||
<GrantDialog page={page} />
|
||||
<StatusDialog page={page} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PaneHeader({ actions, count, title }) {
|
||||
return (
|
||||
<div className={styles.prettyPaneHeader}>
|
||||
<div className={styles.prettyPaneTitle}>
|
||||
<h2>{title}</h2>
|
||||
<span>共 {Number(count || 0)} 条</span>
|
||||
</div>
|
||||
<div className={styles.toolbarActions}>{actions}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function poolColumns(page) {
|
||||
return [
|
||||
{
|
||||
key: "pool",
|
||||
label: "靓号池",
|
||||
width: "minmax(220px, 1.2fr)",
|
||||
render: (pool) => (
|
||||
<div className={styles.stack}>
|
||||
<span className={styles.primaryText}>{pool.name || "-"}</span>
|
||||
<span className={styles.meta}>{pool.poolId}</span>
|
||||
<span className={styles.meta}>更新 {formatMillis(pool.updatedAtMs || pool.createdAtMs)}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "rule",
|
||||
label: "规则 / 等级",
|
||||
width: "minmax(160px, 0.9fr)",
|
||||
render: (pool) => (
|
||||
<div className={styles.stack}>
|
||||
<span>{ruleLabel(pool.ruleType)}</span>
|
||||
<span className={styles.meta}>
|
||||
{trackLabel(pool.levelTrack)} {pool.minLevel}-{pool.maxLevel}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
width: "minmax(110px, 0.6fr)",
|
||||
render: (pool) => <StatusBadge labels={prettyPoolStatusLabels} status={pool.status} />,
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
width: "104px",
|
||||
render: (pool) => (
|
||||
<div className={styles.rowActions}>
|
||||
{page.abilities.canPrettyUpdate ? (
|
||||
<ActionIcon label="编辑" onClick={() => page.openEditPool(pool)}>
|
||||
<EditOutlined fontSize="small" />
|
||||
</ActionIcon>
|
||||
) : null}
|
||||
{page.abilities.canPrettyGenerate ? (
|
||||
<ActionIcon label="生成" onClick={() => page.openGenerate(pool)}>
|
||||
<AutoAwesomeOutlined fontSize="small" />
|
||||
</ActionIcon>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function idColumns(page) {
|
||||
return [
|
||||
{
|
||||
key: "display",
|
||||
label: "靓号",
|
||||
width: "minmax(190px, 1fr)",
|
||||
render: (item) => (
|
||||
<div className={styles.stack}>
|
||||
<div className={styles.nameLine}>
|
||||
<span className={styles.name}>{item.displayUserId || "-"}</span>
|
||||
</div>
|
||||
<div className={styles.meta}>{item.prettyId}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "pool",
|
||||
label: "来源 / 池",
|
||||
width: "minmax(180px, 0.9fr)",
|
||||
render: (item) => (
|
||||
<div className={styles.stack}>
|
||||
<span>{prettyIdSourceLabels[item.source] || item.source || "-"}</span>
|
||||
<span className={styles.meta}>{item.pool?.name || item.poolId || "-"}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
width: "minmax(110px, 0.6fr)",
|
||||
render: (item) => <StatusBadge labels={prettyIdStatusLabels} status={item.status} />,
|
||||
},
|
||||
{
|
||||
key: "assigned",
|
||||
label: "占用 / 时间",
|
||||
width: "minmax(190px, 0.9fr)",
|
||||
render: (item) => (
|
||||
<div className={styles.stack}>
|
||||
<span>{formatEntityID(item.assignedUserId)}</span>
|
||||
<span className={styles.meta}>
|
||||
{item.assignedAtMs ? formatMillis(item.assignedAtMs) : formatMillis(item.createdAtMs)}
|
||||
</span>
|
||||
<span className={styles.meta}>
|
||||
{item.releasedAtMs ? `释放 ${formatMillis(item.releasedAtMs)}` : "-"}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
width: "minmax(96px, 0.5fr)",
|
||||
render: (item) =>
|
||||
page.abilities.canPrettyUpdate && canChangePrettyStatus(item) ? (
|
||||
<div className={styles.rowActions}>
|
||||
<ActionIcon label="状态" onClick={() => page.openStatus(item)}>
|
||||
<SettingsOutlined fontSize="small" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
) : (
|
||||
"-"
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function PoolDialog({ page }) {
|
||||
const editing = Boolean(page.activePool);
|
||||
return (
|
||||
<AdminFormDialog
|
||||
loading={page.loadingAction === "pool"}
|
||||
open={page.activeDialog === "pool"}
|
||||
submitDisabled={!page.abilities.canPrettyUpdate || page.loadingAction === "pool"}
|
||||
title={editing ? "编辑靓号池" : "新增靓号池"}
|
||||
onClose={page.closeDialog}
|
||||
onSubmit={page.submitPool}
|
||||
>
|
||||
<AdminFormSection title="池配置">
|
||||
<TextField
|
||||
label="名称"
|
||||
required
|
||||
value={page.poolForm.name}
|
||||
onChange={(event) => page.setPoolForm({ ...page.poolForm, name: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="等级轨道"
|
||||
required
|
||||
select
|
||||
value={page.poolForm.levelTrack}
|
||||
onChange={(event) => page.setPoolForm({ ...page.poolForm, levelTrack: event.target.value })}
|
||||
>
|
||||
{prettyLevelTrackOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<div className={styles.formGridTwo}>
|
||||
<TextField
|
||||
label="最低等级"
|
||||
required
|
||||
type="number"
|
||||
value={page.poolForm.minLevel}
|
||||
onChange={(event) => page.setPoolForm({ ...page.poolForm, minLevel: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="最高等级"
|
||||
required
|
||||
type="number"
|
||||
value={page.poolForm.maxLevel}
|
||||
onChange={(event) => page.setPoolForm({ ...page.poolForm, maxLevel: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.formGridTwo}>
|
||||
<TextField
|
||||
label="规则"
|
||||
required
|
||||
select
|
||||
value={page.poolForm.ruleType}
|
||||
onChange={(event) => page.setPoolForm({ ...page.poolForm, ruleType: event.target.value })}
|
||||
>
|
||||
{prettyRuleOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
label="状态"
|
||||
required
|
||||
select
|
||||
value={page.poolForm.status}
|
||||
onChange={(event) => page.setPoolForm({ ...page.poolForm, status: event.target.value })}
|
||||
>
|
||||
{prettyPoolStatusOptions
|
||||
.filter(([value]) => value)
|
||||
.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</div>
|
||||
<TextField
|
||||
label="排序"
|
||||
type="number"
|
||||
value={page.poolForm.sortOrder}
|
||||
onChange={(event) => page.setPoolForm({ ...page.poolForm, sortOrder: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="规则配置"
|
||||
minRows={3}
|
||||
multiline
|
||||
value={page.poolForm.ruleConfigJson}
|
||||
onChange={(event) => page.setPoolForm({ ...page.poolForm, ruleConfigJson: event.target.value })}
|
||||
/>
|
||||
</AdminFormSection>
|
||||
</AdminFormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function GenerateDialog({ page }) {
|
||||
return (
|
||||
<AdminFormDialog
|
||||
loading={page.loadingAction === "generate"}
|
||||
open={page.activeDialog === "generate"}
|
||||
submitDisabled={!page.abilities.canPrettyGenerate || page.loadingAction === "generate"}
|
||||
title="批量生成靓号"
|
||||
onClose={page.closeDialog}
|
||||
onSubmit={page.submitGenerate}
|
||||
>
|
||||
<AdminFormSection title="生成配置">
|
||||
<TextField
|
||||
label="靓号池"
|
||||
required
|
||||
select
|
||||
value={page.generateForm.poolId}
|
||||
onChange={(event) => page.setGenerateForm({ ...page.generateForm, poolId: event.target.value })}
|
||||
>
|
||||
{page.poolOptions.map((option) => (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
label="规则"
|
||||
required
|
||||
select
|
||||
value={page.generateForm.ruleType}
|
||||
onChange={(event) => page.setGenerateForm({ ...page.generateForm, ruleType: event.target.value })}
|
||||
>
|
||||
{prettyRuleOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
label="生成数量"
|
||||
required
|
||||
type="number"
|
||||
value={page.generateForm.count}
|
||||
onChange={(event) => page.setGenerateForm({ ...page.generateForm, count: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="规则配置"
|
||||
minRows={3}
|
||||
multiline
|
||||
value={page.generateForm.ruleConfigJson}
|
||||
onChange={(event) =>
|
||||
page.setGenerateForm({ ...page.generateForm, ruleConfigJson: event.target.value })
|
||||
}
|
||||
/>
|
||||
</AdminFormSection>
|
||||
</AdminFormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function GrantDialog({ page }) {
|
||||
return (
|
||||
<AdminFormDialog
|
||||
loading={page.loadingAction === "grant"}
|
||||
open={page.activeDialog === "grant"}
|
||||
submitDisabled={!page.abilities.canPrettyGrant || page.loadingAction === "grant"}
|
||||
title="后台发放靓号"
|
||||
onClose={page.closeDialog}
|
||||
onSubmit={page.submitGrant}
|
||||
>
|
||||
<AdminFormSection title="发放信息">
|
||||
<TextField
|
||||
label="用户 ID"
|
||||
required
|
||||
value={page.grantForm.targetUserId}
|
||||
onChange={(event) => page.setGrantForm({ ...page.grantForm, targetUserId: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="靓号内容"
|
||||
required
|
||||
value={page.grantForm.displayUserId}
|
||||
onChange={(event) => page.setGrantForm({ ...page.grantForm, displayUserId: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="有效天数"
|
||||
type="number"
|
||||
value={page.grantForm.durationDays}
|
||||
onChange={(event) => page.setGrantForm({ ...page.grantForm, durationDays: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="原因"
|
||||
value={page.grantForm.reason}
|
||||
onChange={(event) => page.setGrantForm({ ...page.grantForm, reason: event.target.value })}
|
||||
/>
|
||||
</AdminFormSection>
|
||||
</AdminFormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusDialog({ page }) {
|
||||
return (
|
||||
<AdminFormDialog
|
||||
loading={page.loadingAction === "status"}
|
||||
open={page.activeDialog === "status"}
|
||||
submitDisabled={!page.abilities.canPrettyUpdate || page.loadingAction === "status"}
|
||||
title="修改靓号状态"
|
||||
onClose={page.closeDialog}
|
||||
onSubmit={page.submitStatus}
|
||||
>
|
||||
<AdminFormSection title="状态信息">
|
||||
<TextField disabled label="靓号" value={page.activePrettyID?.displayUserId || ""} />
|
||||
<TextField
|
||||
label="状态"
|
||||
required
|
||||
select
|
||||
value={page.statusForm.status}
|
||||
onChange={(event) => page.setStatusForm({ ...page.statusForm, status: event.target.value })}
|
||||
>
|
||||
{prettyIdStatusOptions
|
||||
.filter(([value]) => ["available", "disabled", "reserved"].includes(value))
|
||||
.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
label="原因"
|
||||
value={page.statusForm.reason}
|
||||
onChange={(event) => page.setStatusForm({ ...page.statusForm, reason: event.target.value })}
|
||||
/>
|
||||
</AdminFormSection>
|
||||
</AdminFormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionIcon({ children, label, onClick }) {
|
||||
return (
|
||||
<Tooltip arrow title={label}>
|
||||
<span>
|
||||
<IconButton label={label} onClick={onClick}>
|
||||
{children}
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ labels, status }) {
|
||||
return (
|
||||
<span className={`status-badge status-badge--${statusTone(status)}`}>
|
||||
<span className="status-point" />
|
||||
{labels[status] || status || "-"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function statusTone(status) {
|
||||
if (status === "active" || status === "available") {
|
||||
return "running";
|
||||
}
|
||||
if (status === "assigned" || status === "reserved") {
|
||||
return "warning";
|
||||
}
|
||||
if (status === "released" || status === "deleted") {
|
||||
return "stopped";
|
||||
}
|
||||
return "danger";
|
||||
}
|
||||
|
||||
function trackLabel(track) {
|
||||
return prettyLevelTrackOptions.find(([value]) => value === track)?.[1] || track || "-";
|
||||
}
|
||||
|
||||
function ruleLabel(ruleType) {
|
||||
return prettyRuleOptions.find(([value]) => value === ruleType)?.[1] || ruleType || "-";
|
||||
}
|
||||
|
||||
function formatEntityID(value) {
|
||||
if (value === 0 || value === "0" || value === "" || value === undefined || value === null) {
|
||||
return "-";
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function canChangePrettyStatus(item) {
|
||||
const assigned = item.assignedUserId;
|
||||
const unassigned =
|
||||
assigned === 0 || assigned === "0" || assigned === "" || assigned === undefined || assigned === null;
|
||||
return ["available", "disabled", "reserved"].includes(item.status) && unassigned;
|
||||
}
|
||||
@ -2,13 +2,17 @@ import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||
import { PERMISSIONS } from "@/app/permissions";
|
||||
|
||||
export function useAppUserAbilities() {
|
||||
const { can } = useAuth();
|
||||
const { can } = useAuth();
|
||||
|
||||
return {
|
||||
canPassword: can(PERMISSIONS.appUserPassword),
|
||||
canStatus: can(PERMISSIONS.appUserStatus),
|
||||
canUpdate: can(PERMISSIONS.appUserUpdate),
|
||||
canUpload: can(PERMISSIONS.uploadCreate),
|
||||
canView: can(PERMISSIONS.appUserView)
|
||||
};
|
||||
return {
|
||||
canPassword: can(PERMISSIONS.appUserPassword),
|
||||
canPrettyGenerate: can(PERMISSIONS.prettyIdGenerate),
|
||||
canPrettyGrant: can(PERMISSIONS.prettyIdGrant),
|
||||
canPrettyUpdate: can(PERMISSIONS.prettyIdUpdate),
|
||||
canPrettyView: can(PERMISSIONS.prettyIdView),
|
||||
canStatus: can(PERMISSIONS.appUserStatus),
|
||||
canUpdate: can(PERMISSIONS.appUserUpdate),
|
||||
canUpload: can(PERMISSIONS.uploadCreate),
|
||||
canView: can(PERMISSIONS.appUserView),
|
||||
};
|
||||
}
|
||||
|
||||
@ -25,4 +25,12 @@ export const appUserRoutes = [
|
||||
path: "/app/users/bans",
|
||||
permission: PERMISSIONS.appUserView,
|
||||
},
|
||||
{
|
||||
label: "靓号管理",
|
||||
loader: () => import("./pages/AppUserPrettyIdsPage.jsx").then((module) => module.AppUserPrettyIdsPage),
|
||||
menuCode: MENU_CODES.appUserPrettyIds,
|
||||
pageKey: "app-user-pretty-ids",
|
||||
path: "/app/users/pretty-ids",
|
||||
permission: PERMISSIONS.prettyIdView,
|
||||
},
|
||||
];
|
||||
|
||||
@ -1,19 +1,120 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const appUserUpdateSchema = z.object({
|
||||
avatar: z.string().trim().max(512, "头像不能超过 512 个字符").optional().default(""),
|
||||
gender: z.string().trim().max(32, "性别不能超过 32 个字符").optional().default(""),
|
||||
username: z.string().trim().max(64, "用户名称不能超过 64 个字符").optional().default("")
|
||||
avatar: z.string().trim().max(512, "头像不能超过 512 个字符").optional().default(""),
|
||||
gender: z.string().trim().max(32, "性别不能超过 32 个字符").optional().default(""),
|
||||
username: z.string().trim().max(64, "用户名称不能超过 64 个字符").optional().default(""),
|
||||
});
|
||||
|
||||
export const appUserCountrySchema = z.object({
|
||||
country: z.string().trim().min(2, "请选择国家").max(16, "国家码不能超过 16 个字符")
|
||||
country: z.string().trim().min(2, "请选择国家").max(16, "国家码不能超过 16 个字符"),
|
||||
});
|
||||
|
||||
export const appUserPasswordSchema = z.object({
|
||||
password: z.string().trim().min(6, "密码至少 6 位").max(128, "密码不能超过 128 个字符")
|
||||
password: z.string().trim().min(6, "密码至少 6 位").max(128, "密码不能超过 128 个字符"),
|
||||
});
|
||||
|
||||
// 与服务端后台发放规则保持一致:只允许 Unicode 字母、数字和组合标记,空格、标点、emoji 都在前端先拦截。
|
||||
const prettyContentPattern = /^[\p{L}\p{N}\p{M}]+$/u;
|
||||
const prettyRuleValues = [
|
||||
"aa",
|
||||
"aaa",
|
||||
"aaaa",
|
||||
"aaaaa",
|
||||
"aaaaaa",
|
||||
"aaaaaaa",
|
||||
"aabb",
|
||||
"aabbcc",
|
||||
"aabbccdd",
|
||||
"abab",
|
||||
"ababab",
|
||||
"abba",
|
||||
"abcba",
|
||||
"abccba",
|
||||
"aaab",
|
||||
"aaaab",
|
||||
"aaaaab",
|
||||
"aaabbb",
|
||||
"aaaabbbb",
|
||||
"abc",
|
||||
"abcd",
|
||||
"abcde",
|
||||
"abcdef",
|
||||
"cba",
|
||||
"dcba",
|
||||
"edcba",
|
||||
"fedcba",
|
||||
"abcabc",
|
||||
"date_yyyymmdd",
|
||||
"date_yymmdd",
|
||||
"love",
|
||||
"custom_values",
|
||||
"custom_pattern",
|
||||
] as const;
|
||||
|
||||
// 靓号池只配置等级区间和生成规则,等级上下限在表单层先做顺序校验,后端仍会再次校验权限和状态。
|
||||
export const prettyDisplayIDPoolSchema = z
|
||||
.object({
|
||||
levelTrack: z.enum(["wealth", "game", "charm"], "请选择等级轨道"),
|
||||
maxLevel: z.coerce.number().int("最高等级必须是整数").min(0, "最高等级不能小于 0"),
|
||||
minLevel: z.coerce.number().int("最低等级必须是整数").min(0, "最低等级不能小于 0"),
|
||||
name: z.string().trim().min(1, "请输入靓号池名称").max(128, "靓号池名称不能超过 128 个字符"),
|
||||
ruleConfigJson: z.string().trim().max(4000, "规则配置不能超过 4000 个字符").optional().default(""),
|
||||
ruleType: z.enum(prettyRuleValues, "请选择生成规则"),
|
||||
sortOrder: z.coerce.number().int("排序必须是整数").min(0, "排序不能小于 0").optional().default(0),
|
||||
status: z.enum(["active", "disabled"], "请选择状态"),
|
||||
})
|
||||
.refine((value) => value.maxLevel >= value.minLevel, {
|
||||
message: "最高等级不能小于最低等级",
|
||||
path: ["maxLevel"],
|
||||
});
|
||||
|
||||
// 批量生成支持市面常见靓号模式,单次最多 1000 个,避免一次生成把候选空间和数据库锁时间放大。
|
||||
export const prettyDisplayIDGenerateSchema = z.object({
|
||||
count: z.coerce.number().int("生成数量必须是整数").min(1, "生成数量至少 1 个").max(1000, "单次最多生成 1000 个"),
|
||||
poolId: z.string().trim().min(1, "请选择靓号池"),
|
||||
ruleConfigJson: z.string().trim().max(4000, "规则配置不能超过 4000 个字符").optional().default(""),
|
||||
ruleType: z.enum(prettyRuleValues, "请选择生成规则"),
|
||||
});
|
||||
|
||||
// 后台发放允许管理员输入非数字靓号;durationDays 为空或 0 都会转成 duration_ms=0,表示长期持有。
|
||||
export const prettyDisplayIDGrantSchema = z.object({
|
||||
displayUserId: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "请输入靓号内容")
|
||||
.max(64, "靓号不能超过 64 个字符")
|
||||
.regex(prettyContentPattern, "靓号只支持字母、数字和组合标记"),
|
||||
durationDays: z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.default("")
|
||||
.refine((value) => {
|
||||
if (!value) {
|
||||
return true;
|
||||
}
|
||||
const days = Number(value);
|
||||
return Number.isInteger(days) && days >= 0 && days <= 36500;
|
||||
}, "有效天数必须是 0 到 36500 的整数"),
|
||||
reason: z.string().trim().max(64, "原因不能超过 64 个字符").optional().default(""),
|
||||
targetUserId: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^[1-9]\d*$/, "请输入用户 ID"),
|
||||
});
|
||||
|
||||
// 状态变更只暴露未占用号码的 available/disabled/reserved,assigned 号码必须通过释放或覆盖链路改变。
|
||||
export const prettyDisplayIDStatusSchema = z.object({
|
||||
prettyId: z.string().trim().min(1, "请选择靓号"),
|
||||
reason: z.string().trim().max(64, "原因不能超过 64 个字符").optional().default(""),
|
||||
status: z.enum(["available", "disabled", "reserved"], "请选择状态"),
|
||||
});
|
||||
|
||||
export type AppUserUpdateForm = z.infer<typeof appUserUpdateSchema>;
|
||||
export type AppUserCountryForm = z.infer<typeof appUserCountrySchema>;
|
||||
export type AppUserPasswordForm = z.infer<typeof appUserPasswordSchema>;
|
||||
export type PrettyDisplayIDPoolForm = z.infer<typeof prettyDisplayIDPoolSchema>;
|
||||
export type PrettyDisplayIDGenerateForm = z.infer<typeof prettyDisplayIDGenerateSchema>;
|
||||
export type PrettyDisplayIDGrantForm = z.infer<typeof prettyDisplayIDGrantSchema>;
|
||||
export type PrettyDisplayIDStatusForm = z.infer<typeof prettyDisplayIDStatusSchema>;
|
||||
|
||||
@ -8,6 +8,9 @@ export interface FirstRechargeRewardTierDto {
|
||||
tierName: string;
|
||||
minCoinAmount: number;
|
||||
maxCoinAmount: number;
|
||||
usdMinorAmount: number;
|
||||
googleProductId: string;
|
||||
discountPercent: number;
|
||||
resourceGroupId: number;
|
||||
status: string;
|
||||
sortOrder: number;
|
||||
@ -32,6 +35,9 @@ export interface FirstRechargeRewardConfigPayload {
|
||||
tier_name: string;
|
||||
min_coin_amount: number;
|
||||
max_coin_amount: number;
|
||||
usd_minor_amount: number;
|
||||
google_product_id: string;
|
||||
discount_percent: number;
|
||||
resource_group_id: number;
|
||||
status: string;
|
||||
sort_order: number;
|
||||
@ -58,6 +64,9 @@ export interface FirstRechargeRewardClaimDto {
|
||||
rechargeCoinAmount?: number;
|
||||
rechargeSequence?: number;
|
||||
rechargeType?: string;
|
||||
tierUsdMinorAmount?: number;
|
||||
googleProductId?: string;
|
||||
rechargeUsdMinor?: number;
|
||||
status?: string;
|
||||
walletCommandId?: string;
|
||||
walletGrantId?: string;
|
||||
@ -122,6 +131,9 @@ function normalizeTier(item: RawTier): FirstRechargeRewardTierDto {
|
||||
tierName: stringValue(item.tierName ?? item.tier_name),
|
||||
minCoinAmount: numberValue(item.minCoinAmount ?? item.min_coin_amount),
|
||||
maxCoinAmount: numberValue(item.maxCoinAmount ?? item.max_coin_amount),
|
||||
usdMinorAmount: numberValue(item.usdMinorAmount ?? item.usd_minor_amount),
|
||||
googleProductId: stringValue(item.googleProductId ?? item.google_product_id),
|
||||
discountPercent: numberValue(item.discountPercent ?? item.discount_percent),
|
||||
resourceGroupId: numberValue(item.resourceGroupId ?? item.resource_group_id),
|
||||
status: stringValue(item.status) || "active",
|
||||
sortOrder: numberValue(item.sortOrder ?? item.sort_order),
|
||||
@ -150,6 +162,9 @@ function normalizeClaim(item: RawClaim): FirstRechargeRewardClaimDto {
|
||||
rechargeCoinAmount: numberValue(item.rechargeCoinAmount ?? item.recharge_coin_amount),
|
||||
rechargeSequence: numberValue(item.rechargeSequence ?? item.recharge_sequence),
|
||||
rechargeType: stringValue(item.rechargeType ?? item.recharge_type),
|
||||
tierUsdMinorAmount: numberValue(item.tierUsdMinorAmount ?? item.tier_usd_minor_amount),
|
||||
googleProductId: stringValue(item.googleProductId ?? item.google_product_id),
|
||||
rechargeUsdMinor: numberValue(item.rechargeUsdMinor ?? item.recharge_usd_minor),
|
||||
status: stringValue(item.status),
|
||||
walletCommandId: stringValue(item.walletCommandId ?? item.wallet_command_id),
|
||||
walletGrantId: stringValue(item.walletGrantId ?? item.wallet_grant_id),
|
||||
|
||||
@ -1,20 +1,13 @@
|
||||
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||
import SaveOutlined from "@mui/icons-material/SaveOutlined";
|
||||
import Drawer from "@mui/material/Drawer";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { RewardTierEditorTable } from "@/shared/ui/RewardTierEditorTable.jsx";
|
||||
import { ResourceGroupSelectField } from "@/shared/ui/ResourceGroupSelectDrawer.jsx";
|
||||
import styles from "@/features/first-recharge-reward/first-recharge-reward.module.css";
|
||||
|
||||
const statusOptions = [
|
||||
["active", "启用"],
|
||||
["inactive", "停用"],
|
||||
];
|
||||
|
||||
export function FirstRechargeRewardConfigDrawer({
|
||||
abilities,
|
||||
configLoading,
|
||||
@ -35,10 +28,11 @@ export function FirstRechargeRewardConfigDrawer({
|
||||
...(current.tiers || []),
|
||||
{
|
||||
tierId: 0,
|
||||
tierCode: "",
|
||||
tierCode: `first_recharge_${Date.now()}_${(current.tiers || []).length + 1}`,
|
||||
tierName: "",
|
||||
minCoinAmount: "",
|
||||
maxCoinAmount: "",
|
||||
usdAmount: "",
|
||||
googleProductId: "",
|
||||
discountPercent: "70",
|
||||
resourceGroupId: "",
|
||||
status: "active",
|
||||
sortOrder: String((current.tiers || []).length),
|
||||
@ -63,110 +57,126 @@ export function FirstRechargeRewardConfigDrawer({
|
||||
|
||||
return (
|
||||
<Drawer anchor="right" open={open} onClose={configSaving ? undefined : onClose}>
|
||||
<form className="form-drawer" onSubmit={onSubmit}>
|
||||
<h2>首冲奖励配置</h2>
|
||||
<section className="form-drawer__section">
|
||||
<div className="form-drawer__section-title">发放状态</div>
|
||||
<AdminSwitch
|
||||
checked={form.enabled}
|
||||
checkedLabel="开启"
|
||||
disabled={disabled}
|
||||
label="首冲奖励状态"
|
||||
uncheckedLabel="关闭"
|
||||
onChange={(event) => setForm((current) => ({ ...current, enabled: event.target.checked }))}
|
||||
/>
|
||||
</section>
|
||||
<section className="form-drawer__section">
|
||||
<div className={styles.drawerSectionTitle}>
|
||||
<span>奖励档位</span>
|
||||
<Button
|
||||
<form className="form-drawer form-drawer--activity-config" onSubmit={onSubmit}>
|
||||
<h2>首充礼包配置</h2>
|
||||
<div className="form-drawer__content">
|
||||
<section className="form-drawer__section">
|
||||
<div className="form-drawer__section-title">发放状态</div>
|
||||
<AdminSwitch
|
||||
checked={form.enabled}
|
||||
checkedLabel="开启"
|
||||
disabled={disabled}
|
||||
startIcon={<AddOutlined fontSize="small" />}
|
||||
type="button"
|
||||
onClick={addTier}
|
||||
>
|
||||
添加档位
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.tierEditorList}>
|
||||
{(form.tiers || []).map((tier, index) => (
|
||||
<div className={styles.tierEditor} key={`${tier.tierId || "new"}-${index}`}>
|
||||
<div className={styles.tierEditorHeader}>
|
||||
<span>档位 {index + 1}</span>
|
||||
<IconButton
|
||||
aria-label="删除档位"
|
||||
disabled={disabled}
|
||||
size="small"
|
||||
onClick={() => removeTier(index)}
|
||||
>
|
||||
<DeleteOutlineOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="form-drawer__grid">
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="档位编码"
|
||||
value={tier.tierCode}
|
||||
onChange={(event) => updateTier(index, { tierCode: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="档位名称"
|
||||
value={tier.tierName}
|
||||
onChange={(event) => updateTier(index, { tierName: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
inputProps={{ min: 1 }}
|
||||
label="最小金币"
|
||||
type="number"
|
||||
value={tier.minCoinAmount}
|
||||
onChange={(event) => updateTier(index, { minCoinAmount: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
inputProps={{ min: 0 }}
|
||||
label="最大金币"
|
||||
placeholder="0 表示无上限"
|
||||
type="number"
|
||||
value={tier.maxCoinAmount}
|
||||
onChange={(event) => updateTier(index, { maxCoinAmount: event.target.value })}
|
||||
/>
|
||||
<ResourceGroupSelectField
|
||||
disabled={disabled}
|
||||
drawerTitle="选择首冲奖励资源组"
|
||||
groups={resourceGroups}
|
||||
label="奖励资源组"
|
||||
value={tier.resourceGroupId}
|
||||
onChange={(value) => updateTier(index, { resourceGroupId: value })}
|
||||
/>
|
||||
<TextField
|
||||
select
|
||||
disabled={disabled}
|
||||
label="状态"
|
||||
value={tier.status}
|
||||
onChange={(event) => updateTier(index, { status: event.target.value })}
|
||||
>
|
||||
{statusOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
inputProps={{ min: 0 }}
|
||||
label="排序"
|
||||
type="number"
|
||||
value={tier.sortOrder}
|
||||
onChange={(event) => updateTier(index, { sortOrder: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!form.tiers?.length ? <div className={styles.emptyState}>暂无档位</div> : null}
|
||||
</div>
|
||||
</section>
|
||||
label="首充礼包状态"
|
||||
uncheckedLabel="关闭"
|
||||
onChange={(event) => setForm((current) => ({ ...current, enabled: event.target.checked }))}
|
||||
/>
|
||||
</section>
|
||||
<section className="form-drawer__section">
|
||||
<div className={styles.drawerSectionTitle}>
|
||||
<span>奖励档位</span>
|
||||
<Button
|
||||
disabled={disabled}
|
||||
startIcon={<AddOutlined fontSize="small" />}
|
||||
type="button"
|
||||
onClick={addTier}
|
||||
>
|
||||
添加档位
|
||||
</Button>
|
||||
</div>
|
||||
<RewardTierEditorTable
|
||||
columns={[
|
||||
{
|
||||
key: "tier",
|
||||
label: "档位",
|
||||
render: (_tier, index) => (
|
||||
<span className="reward-tier-editor-table__index">档位 {index + 1}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "usdAmount",
|
||||
label: "USD",
|
||||
render: (tier, index) => (
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
inputProps={{ "aria-label": "美元档位", min: 0.01, step: 0.01 }}
|
||||
placeholder="0.99"
|
||||
type="number"
|
||||
value={tier.usdAmount}
|
||||
onChange={(event) => updateTier(index, { usdAmount: event.target.value })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "googleProductId",
|
||||
label: "Google 商品 ID",
|
||||
render: (tier, index) => (
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
inputProps={{ "aria-label": "Google 商品 ID" }}
|
||||
placeholder="first_recharge_google_099"
|
||||
value={tier.googleProductId}
|
||||
onChange={(event) =>
|
||||
updateTier(index, { googleProductId: event.target.value })
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "resourceGroupId",
|
||||
label: "奖励资源组",
|
||||
render: (tier, index) => (
|
||||
<ResourceGroupSelectField
|
||||
disabled={disabled}
|
||||
drawerTitle="选择首充礼包资源组"
|
||||
groups={resourceGroups}
|
||||
label="奖励资源组"
|
||||
value={tier.resourceGroupId}
|
||||
onChange={(value) => updateTier(index, { resourceGroupId: value })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "discountPercent",
|
||||
label: "优惠%",
|
||||
render: (tier, index) => (
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
inputProps={{ "aria-label": "优惠百分比", min: 0, max: 100, step: 1 }}
|
||||
type="number"
|
||||
value={tier.discountPercent}
|
||||
onChange={(event) =>
|
||||
updateTier(index, { discountPercent: event.target.value })
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
render: (tier, index) => (
|
||||
<AdminSwitch
|
||||
checked={tier.status !== "inactive"}
|
||||
checkedLabel="启用"
|
||||
disabled={disabled}
|
||||
label={`档位 ${index + 1} 状态`}
|
||||
uncheckedLabel="停用"
|
||||
onChange={(event) =>
|
||||
updateTier(index, {
|
||||
status: event.target.checked ? "active" : "inactive",
|
||||
})
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
disabled={disabled}
|
||||
emptyText="暂无档位"
|
||||
gridTemplateColumns="72px minmax(110px, 0.6fr) minmax(230px, 1.2fr) minmax(260px, 1.4fr) 92px 120px 52px"
|
||||
rows={form.tiers || []}
|
||||
onRemoveTier={removeTier}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
<div className="form-drawer__actions">
|
||||
<Button disabled={configSaving} type="button" onClick={onClose}>
|
||||
取消
|
||||
|
||||
@ -36,7 +36,7 @@ export function useFirstRechargeRewardPage() {
|
||||
loading: claimsLoading,
|
||||
reload: reloadClaims,
|
||||
} = usePaginatedQuery({
|
||||
errorMessage: "加载首冲奖励记录失败",
|
||||
errorMessage: "加载首充礼包记录失败",
|
||||
fetcher: listFirstRechargeRewardClaims,
|
||||
filters,
|
||||
page,
|
||||
@ -55,7 +55,7 @@ export function useFirstRechargeRewardPage() {
|
||||
setForm(formFromConfig(config));
|
||||
setResourceGroups(groups.items || []);
|
||||
} catch (err) {
|
||||
showToast(err.message || "加载首冲奖励配置失败", "error");
|
||||
showToast(err.message || "加载首充礼包配置失败", "error");
|
||||
} finally {
|
||||
setConfigLoading(false);
|
||||
}
|
||||
@ -96,10 +96,10 @@ export function useFirstRechargeRewardPage() {
|
||||
setConfig(saved);
|
||||
setForm(formFromConfig(saved));
|
||||
setConfigDrawerOpen(false);
|
||||
showToast("首冲奖励配置已保存", "success");
|
||||
showToast("首充礼包配置已保存", "success");
|
||||
await reloadClaims();
|
||||
} catch (err) {
|
||||
showToast(err.message || "保存首冲奖励配置失败", "error");
|
||||
showToast(err.message || "保存首充礼包配置失败", "error");
|
||||
} finally {
|
||||
setConfigSaving(false);
|
||||
}
|
||||
@ -138,8 +138,9 @@ function formFromConfig(config) {
|
||||
tierId: tier.tierId || 0,
|
||||
tierCode: tier.tierCode || "",
|
||||
tierName: tier.tierName || "",
|
||||
minCoinAmount: tier.minCoinAmount ? String(tier.minCoinAmount) : "",
|
||||
maxCoinAmount: tier.maxCoinAmount ? String(tier.maxCoinAmount) : "",
|
||||
usdAmount: tier.usdMinorAmount ? (tier.usdMinorAmount / 100).toFixed(2) : "",
|
||||
googleProductId: tier.googleProductId || "",
|
||||
discountPercent: String(tier.discountPercent || 0),
|
||||
resourceGroupId: tier.resourceGroupId ? String(tier.resourceGroupId) : "",
|
||||
status: tier.status || "active",
|
||||
sortOrder: String(tier.sortOrder || 0),
|
||||
@ -148,31 +149,40 @@ function formFromConfig(config) {
|
||||
}
|
||||
|
||||
function payloadFromForm(form) {
|
||||
const seenGoogleProductIds = new Set();
|
||||
const tiers = (form.tiers || []).map((tier, index) => {
|
||||
const minCoinAmount = Number(tier.minCoinAmount || 0);
|
||||
const maxCoinAmount = Number(tier.maxCoinAmount || 0);
|
||||
const usdMinorAmount = Math.round(Number(tier.usdAmount || 0) * 100);
|
||||
const googleProductId = String(tier.googleProductId || "").trim();
|
||||
const discountPercent = Number(tier.discountPercent || 0);
|
||||
const resourceGroupId = Number(tier.resourceGroupId || 0);
|
||||
if (!tier.tierCode.trim()) {
|
||||
throw new Error("档位编码不能为空");
|
||||
if (usdMinorAmount <= 0) {
|
||||
throw new Error("美元档位必须大于 0");
|
||||
}
|
||||
if (!tier.tierName.trim()) {
|
||||
throw new Error("档位名称不能为空");
|
||||
if (!googleProductId) {
|
||||
throw new Error("Google 商品 ID 不能为空");
|
||||
}
|
||||
if (minCoinAmount <= 0) {
|
||||
throw new Error("最小金币必须大于 0");
|
||||
const normalizedGoogleProductId = googleProductId.toLowerCase();
|
||||
if (seenGoogleProductIds.has(normalizedGoogleProductId)) {
|
||||
throw new Error("Google 商品 ID 不能重复");
|
||||
}
|
||||
if (maxCoinAmount > 0 && maxCoinAmount < minCoinAmount) {
|
||||
throw new Error("最大金币不能小于最小金币");
|
||||
seenGoogleProductIds.add(normalizedGoogleProductId);
|
||||
if (discountPercent < 0 || discountPercent > 100) {
|
||||
throw new Error("优惠百分比必须在 0 到 100 之间");
|
||||
}
|
||||
if (resourceGroupId <= 0) {
|
||||
throw new Error("请选择资源组");
|
||||
}
|
||||
const tierCode = String(tier.tierCode || "").trim() || `first_recharge_${index + 1}_${usdMinorAmount}`;
|
||||
const tierName = String(tier.tierName || "").trim() || `$${(usdMinorAmount / 100).toFixed(2)}`;
|
||||
return {
|
||||
tier_id: Number(tier.tierId || 0),
|
||||
tier_code: tier.tierCode.trim(),
|
||||
tier_name: tier.tierName.trim(),
|
||||
min_coin_amount: minCoinAmount,
|
||||
max_coin_amount: maxCoinAmount,
|
||||
tier_code: tierCode,
|
||||
tier_name: tierName,
|
||||
min_coin_amount: 0,
|
||||
max_coin_amount: 0,
|
||||
usd_minor_amount: usdMinorAmount,
|
||||
google_product_id: googleProductId,
|
||||
discount_percent: discountPercent,
|
||||
resource_group_id: resourceGroupId,
|
||||
status: tier.status === "inactive" ? "inactive" : "active",
|
||||
sort_order: Number(tier.sortOrder || index),
|
||||
|
||||
@ -24,12 +24,14 @@ const columnsBase = [
|
||||
},
|
||||
{
|
||||
key: "tier",
|
||||
label: "命中档位",
|
||||
width: "minmax(180px, 0.8fr)",
|
||||
label: "商品档位",
|
||||
width: "minmax(260px, 1fr)",
|
||||
render: (claim) => (
|
||||
<div className={styles.stack}>
|
||||
<span>{claim.tierCode || "-"}</span>
|
||||
<span className={styles.meta}>资源组 #{claim.resourceGroupId || "-"}</span>
|
||||
<span>{claim.googleProductId || claim.tierCode || "-"}</span>
|
||||
<span className={styles.meta}>
|
||||
{formatUsdMinor(claim.tierUsdMinorAmount || claim.rechargeUsdMinor)}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@ -40,7 +42,7 @@ const columnsBase = [
|
||||
render: (claim) => (
|
||||
<div className={styles.stack}>
|
||||
<span>{formatNumber(claim.rechargeCoinAmount)} 金币</span>
|
||||
<span className={styles.meta}>第 {claim.rechargeSequence || "-"} 笔</span>
|
||||
<span className={styles.meta}>资源组 #{claim.resourceGroupId || "-"}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@ -174,3 +176,11 @@ function claimStatusLabel(status) {
|
||||
function formatNumber(value) {
|
||||
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
|
||||
}
|
||||
|
||||
function formatUsdMinor(value) {
|
||||
const amount = Number(value || 0);
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
return "$-";
|
||||
}
|
||||
return `$${(amount / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
|
||||
|
||||
export const firstRechargeRewardRoutes = [
|
||||
{
|
||||
label: "首冲奖励",
|
||||
label: "首充礼包",
|
||||
loader: () => import("./pages/FirstRechargeRewardPage.jsx").then((module) => module.FirstRechargeRewardPage),
|
||||
menuCode: MENU_CODES.firstRechargeReward,
|
||||
pageKey: "first-recharge-reward",
|
||||
|
||||
@ -74,6 +74,65 @@ export interface GameSyncResult {
|
||||
items: GameCatalogDto[];
|
||||
}
|
||||
|
||||
export interface DiceStakeOptionDto {
|
||||
stakeCoin: number;
|
||||
enabled: boolean;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface DiceConfigDto {
|
||||
appCode?: string;
|
||||
gameId: string;
|
||||
status: string;
|
||||
stakeOptions: DiceStakeOptionDto[];
|
||||
feeBps: number;
|
||||
poolBps: number;
|
||||
minPlayers: number;
|
||||
maxPlayers: number;
|
||||
robotEnabled: boolean;
|
||||
robotMatchWaitMs: number;
|
||||
poolBalanceCoin: number;
|
||||
createdAtMs?: number;
|
||||
updatedAtMs?: number;
|
||||
}
|
||||
|
||||
export interface DiceRobotDto {
|
||||
appCode?: string;
|
||||
gameId: string;
|
||||
userId: string;
|
||||
userIdNumber?: number;
|
||||
shortId?: string;
|
||||
nickname?: string;
|
||||
avatar?: string;
|
||||
status: string;
|
||||
createdByAdminId?: number;
|
||||
createdAtMs?: number;
|
||||
updatedAtMs?: number;
|
||||
}
|
||||
|
||||
export interface DiceRobotPage {
|
||||
items: DiceRobotDto[];
|
||||
nextCursor?: string;
|
||||
pageSize?: number;
|
||||
serverTimeMs?: number;
|
||||
}
|
||||
|
||||
export type DiceConfigPayload = Omit<DiceConfigDto, "appCode" | "createdAtMs" | "updatedAtMs" | "poolBalanceCoin">;
|
||||
|
||||
export interface DicePoolAdjustPayload {
|
||||
amountCoin: number;
|
||||
direction: "in" | "out";
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface DiceGenerateRobotsPayload {
|
||||
count: number;
|
||||
nicknameLanguage?: "english" | "arabic";
|
||||
avatarUrls?: string[];
|
||||
country?: string;
|
||||
gender?: string;
|
||||
}
|
||||
|
||||
export type GamePlatformPayload = Omit<
|
||||
GamePlatformDto,
|
||||
"appCode" | "createdAtMs" | "updatedAtMs" | "callbackSecretSet"
|
||||
@ -166,6 +225,70 @@ export function syncGamePlatformCatalog(platformCode: string, payload: GameSyncP
|
||||
}));
|
||||
}
|
||||
|
||||
export function listSelfGames(): Promise<{ items: DiceConfigDto[]; serverTimeMs?: number }> {
|
||||
const endpoint = API_ENDPOINTS.listSelfGames;
|
||||
return apiRequest<{ items: DiceConfigDto[]; serverTimeMs?: number }>(apiEndpointPath(API_OPERATIONS.listSelfGames), {
|
||||
method: endpoint.method,
|
||||
}).then((page) => ({
|
||||
...page,
|
||||
items: (page.items || []).map(normalizeDiceConfig),
|
||||
}));
|
||||
}
|
||||
|
||||
export function updateDiceConfig(gameId: string, payload: DiceConfigPayload): Promise<DiceConfigDto> {
|
||||
const endpoint = API_ENDPOINTS.updateDiceConfig;
|
||||
return apiRequest<DiceConfigDto, DiceConfigPayload>(
|
||||
apiEndpointPath(API_OPERATIONS.updateDiceConfig, { game_id: gameId }),
|
||||
{ body: payload, method: endpoint.method },
|
||||
).then(normalizeDiceConfig);
|
||||
}
|
||||
|
||||
export function adjustDicePool(
|
||||
gameId: string,
|
||||
payload: DicePoolAdjustPayload,
|
||||
): Promise<{ config: DiceConfigDto; adjustment: unknown }> {
|
||||
const endpoint = API_ENDPOINTS.adjustDicePool;
|
||||
return apiRequest<{ config: DiceConfigDto; adjustment: unknown }, DicePoolAdjustPayload>(
|
||||
apiEndpointPath(API_OPERATIONS.adjustDicePool, { game_id: gameId }),
|
||||
{ body: payload, method: endpoint.method },
|
||||
).then((result) => ({ ...result, config: normalizeDiceConfig(result.config || {}) }));
|
||||
}
|
||||
|
||||
export function listDiceRobots(query: { gameId?: string; status?: string; pageSize?: number; cursor?: string } = {}) {
|
||||
const endpoint = API_ENDPOINTS.listDiceRobots;
|
||||
return apiRequest<DiceRobotPage>(apiEndpointPath(API_OPERATIONS.listDiceRobots), {
|
||||
method: endpoint.method,
|
||||
query: query as QueryParams,
|
||||
}).then((page) => ({
|
||||
...page,
|
||||
items: (page.items || []).map(normalizeDiceRobot),
|
||||
}));
|
||||
}
|
||||
|
||||
export function generateDiceRobots(payload: DiceGenerateRobotsPayload) {
|
||||
const endpoint = API_ENDPOINTS.generateDiceRobots;
|
||||
return apiRequest<{ created: number; items: DiceRobotDto[] }, DiceGenerateRobotsPayload>(
|
||||
apiEndpointPath(API_OPERATIONS.generateDiceRobots),
|
||||
{ body: payload, method: endpoint.method },
|
||||
).then((result) => ({ ...result, items: (result.items || []).map(normalizeDiceRobot) }));
|
||||
}
|
||||
|
||||
export function setDiceRobotStatus(userId: string, status: string, gameId = "dice") {
|
||||
const endpoint = API_ENDPOINTS.setDiceRobotStatus;
|
||||
return apiRequest<DiceRobotDto, { status: string }>(
|
||||
apiEndpointPath(API_OPERATIONS.setDiceRobotStatus, { user_id: userId }),
|
||||
{ body: { status }, method: endpoint.method, query: { gameId } },
|
||||
).then(normalizeDiceRobot);
|
||||
}
|
||||
|
||||
export function deleteDiceRobot(userId: string, gameId = "dice"): Promise<{ deleted: boolean }> {
|
||||
const endpoint = API_ENDPOINTS.deleteDiceRobot;
|
||||
return apiRequest<{ deleted: boolean }>(apiEndpointPath(API_OPERATIONS.deleteDiceRobot, { user_id: userId }), {
|
||||
method: endpoint.method,
|
||||
query: { gameId },
|
||||
});
|
||||
}
|
||||
|
||||
function normalizePlatform(platform: Partial<GamePlatformDto>): GamePlatformDto {
|
||||
return {
|
||||
appCode: stringValue(platform.appCode),
|
||||
@ -209,6 +332,46 @@ function normalizeCatalog(game: Partial<GameCatalogDto>): GameCatalogDto {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDiceConfig(config: Partial<DiceConfigDto>): DiceConfigDto {
|
||||
return {
|
||||
appCode: stringValue(config.appCode),
|
||||
gameId: stringValue(config.gameId || "dice"),
|
||||
status: stringValue(config.status || "active"),
|
||||
stakeOptions: Array.isArray(config.stakeOptions)
|
||||
? config.stakeOptions.map((item) => ({
|
||||
stakeCoin: numberValue(item.stakeCoin),
|
||||
enabled: item.enabled !== false,
|
||||
sortOrder: numberValue(item.sortOrder),
|
||||
}))
|
||||
: [],
|
||||
feeBps: numberValue(config.feeBps),
|
||||
poolBps: numberValue(config.poolBps),
|
||||
minPlayers: numberValue(config.minPlayers || 2),
|
||||
maxPlayers: numberValue(config.maxPlayers || 2),
|
||||
robotEnabled: config.robotEnabled !== false,
|
||||
robotMatchWaitMs: numberValue(config.robotMatchWaitMs || 3000),
|
||||
poolBalanceCoin: numberValue(config.poolBalanceCoin),
|
||||
createdAtMs: numberValue(config.createdAtMs),
|
||||
updatedAtMs: numberValue(config.updatedAtMs),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDiceRobot(robot: Partial<DiceRobotDto>): DiceRobotDto {
|
||||
return {
|
||||
appCode: stringValue(robot.appCode),
|
||||
gameId: stringValue(robot.gameId || "dice"),
|
||||
userId: stringValue(robot.userId),
|
||||
userIdNumber: numberValue(robot.userIdNumber),
|
||||
shortId: stringValue(robot.shortId),
|
||||
nickname: stringValue(robot.nickname),
|
||||
avatar: stringValue(robot.avatar),
|
||||
status: stringValue(robot.status || "active"),
|
||||
createdByAdminId: numberValue(robot.createdByAdminId),
|
||||
createdAtMs: numberValue(robot.createdAtMs),
|
||||
updatedAtMs: numberValue(robot.updatedAtMs),
|
||||
};
|
||||
}
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
|
||||
}
|
||||
|
||||
@ -15,6 +15,17 @@
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.robotAvatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--primary-soft);
|
||||
color: var(--primary);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.identityText,
|
||||
.stack {
|
||||
display: flex;
|
||||
|
||||
274
src/features/games/pages/GameRobotPage.jsx
Normal file
274
src/features/games/pages/GameRobotPage.jsx
Normal file
@ -0,0 +1,274 @@
|
||||
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 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,
|
||||
nicknameLanguage: "arabic",
|
||||
};
|
||||
|
||||
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 [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({ gameId: "dice", 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]);
|
||||
|
||||
const changeStatus = useCallback(
|
||||
async (robot, checked) => {
|
||||
setLoadingAction(`status-${robot.userId}`);
|
||||
try {
|
||||
await setDiceRobotStatus(robot.userId, checked ? "active" : "disabled", robot.gameId);
|
||||
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, robot.gameId || "dice");
|
||||
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 result = await generateDiceRobots({
|
||||
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}
|
||||
loading={loadingAction === "generate"}
|
||||
open={dialogOpen}
|
||||
setForm={setGenerateForm}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
onSubmit={submitGenerate}
|
||||
/>
|
||||
</AdminListPage>
|
||||
);
|
||||
}
|
||||
|
||||
function GenerateDialog({ form, loading, open, setForm, onClose, onSubmit }) {
|
||||
return (
|
||||
<AdminFormDialog loading={loading} open={open} submitLabel="创建" title="批量创建游戏机器人" onClose={onClose} onSubmit={onSubmit}>
|
||||
<AdminFormFieldGrid>
|
||||
<TextField
|
||||
helperText="最多 200 个"
|
||||
label="数量"
|
||||
type="number"
|
||||
value={form.count}
|
||||
slotProps={{ htmlInput: { max: 200, 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>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function robotAvatarText(robot) {
|
||||
const value = String(robot.nickname || robot.shortId || robot.userId || "?").trim();
|
||||
return value ? value.slice(0, 1).toUpperCase() : "?";
|
||||
}
|
||||
62
src/features/games/pages/GameRobotPage.test.jsx
Normal file
62
src/features/games/pages/GameRobotPage.test.jsx
Normal file
@ -0,0 +1,62 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import { AppProviders } from "@/app/providers.jsx";
|
||||
import { GameRobotPage } from "@/features/games/pages/GameRobotPage.jsx";
|
||||
|
||||
vi.mock("@/app/auth/AuthProvider.jsx", () => ({
|
||||
AuthProvider: ({ children }) => children,
|
||||
useAuth: () => ({ can: () => true }),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => jsonResponse({ items: [] })),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("renders game robot page without crashing", async () => {
|
||||
render(
|
||||
<AppProviders>
|
||||
<MemoryRouter>
|
||||
<GameRobotPage />
|
||||
</MemoryRouter>
|
||||
</AppProviders>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("机器人用户")).toBeInTheDocument());
|
||||
});
|
||||
|
||||
test("shows nickname language selector when generating robots", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AppProviders>
|
||||
<MemoryRouter>
|
||||
<GameRobotPage />
|
||||
</MemoryRouter>
|
||||
</AppProviders>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("机器人用户")).toBeInTheDocument());
|
||||
await user.click(screen.getByRole("button", { name: "批量创建" }));
|
||||
|
||||
expect(screen.getByLabelText("昵称语言")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("combobox", { name: "昵称语言" }));
|
||||
expect(screen.getByRole("option", { name: "阿拉伯语名称" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "英语名称" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
function jsonResponse(body) {
|
||||
return {
|
||||
headers: new Headers({ "content-type": "application/json" }),
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => JSON.stringify(body),
|
||||
};
|
||||
}
|
||||
354
src/features/games/pages/SelfGamePage.jsx
Normal file
354
src/features/games/pages/SelfGamePage.jsx
Normal file
@ -0,0 +1,354 @@
|
||||
import AddCircleOutlineOutlined from "@mui/icons-material/AddCircleOutlineOutlined";
|
||||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
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 { adjustDicePool, listSelfGames, updateDiceConfig } from "@/features/games/api";
|
||||
import styles from "@/features/games/games.module.css";
|
||||
import {
|
||||
AdminActionIconButton,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminListToolbar,
|
||||
AdminRowActions,
|
||||
} from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { AdminFormDialog, AdminFormFieldGrid, AdminFormSection, AdminFormSwitchField } from "@/shared/ui/AdminFormDialog.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 defaultConfigForm = {
|
||||
status: "active",
|
||||
stakeOptionsText: "100,500,1000,8000",
|
||||
feeBps: 500,
|
||||
poolBps: 100,
|
||||
minPlayers: 2,
|
||||
maxPlayers: 2,
|
||||
robotEnabled: true,
|
||||
robotMatchWaitMs: 3000,
|
||||
};
|
||||
|
||||
const defaultPoolForm = {
|
||||
amountCoin: "",
|
||||
direction: "in",
|
||||
reason: "admin_adjust",
|
||||
};
|
||||
|
||||
export function SelfGamePage() {
|
||||
const { can } = useAuth();
|
||||
const { showToast } = useToast();
|
||||
const canUpdate = can(PERMISSIONS.gameUpdate);
|
||||
const [items, setItems] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
const [activeAction, setActiveAction] = useState("");
|
||||
const [activeGame, setActiveGame] = useState(null);
|
||||
const [configForm, setConfigForm] = useState(defaultConfigForm);
|
||||
const [poolForm, setPoolForm] = useState(defaultPoolForm);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await listSelfGames();
|
||||
setItems(result.items || []);
|
||||
} catch (err) {
|
||||
showToast(err.message || "加载自研游戏失败", "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: "game",
|
||||
label: "游戏",
|
||||
width: "minmax(180px, 1.2fr)",
|
||||
render: (game) => (
|
||||
<div className={styles.identity}>
|
||||
<span className={styles.name}>{game.gameId === "dice" ? "骰子" : game.gameId}</span>
|
||||
<span className={styles.meta}>{game.gameId}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "stakes",
|
||||
label: "下注档位",
|
||||
width: "minmax(220px, 1.4fr)",
|
||||
render: (game) => game.stakeOptions.map((item) => formatNumber(item.stakeCoin)).join(" / "),
|
||||
},
|
||||
{
|
||||
key: "fee",
|
||||
label: "费率",
|
||||
width: "minmax(140px, .8fr)",
|
||||
render: (game) => `${bpsPercent(game.feeBps)} / 入池 ${bpsPercent(game.poolBps)}`,
|
||||
},
|
||||
{
|
||||
key: "pool",
|
||||
label: "奖池",
|
||||
width: "minmax(120px, .8fr)",
|
||||
render: (game) => formatNumber(game.poolBalanceCoin),
|
||||
},
|
||||
{
|
||||
key: "robot",
|
||||
label: "机器人",
|
||||
width: "minmax(150px, .8fr)",
|
||||
render: (game) => `${game.robotEnabled ? "启用" : "关闭"} · ${game.robotMatchWaitMs}ms`,
|
||||
},
|
||||
{
|
||||
key: "updated",
|
||||
label: "更新时间",
|
||||
width: "minmax(170px, 1fr)",
|
||||
render: (game) => (game.updatedAtMs ? formatMillis(game.updatedAtMs) : "-"),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
width: "128px",
|
||||
render: (game) => (
|
||||
<AdminRowActions>
|
||||
<AdminActionIconButton disabled={!canUpdate} label="配置骰子" onClick={() => openConfig(game)}>
|
||||
<SettingsOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
<AdminActionIconButton disabled={!canUpdate} label="调整奖池" onClick={() => openPool(game)}>
|
||||
<AddCircleOutlineOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
</AdminRowActions>
|
||||
),
|
||||
},
|
||||
],
|
||||
[canUpdate],
|
||||
);
|
||||
|
||||
const openConfig = (game) => {
|
||||
setActiveGame(game);
|
||||
setConfigForm(configToForm(game));
|
||||
setActiveAction("config");
|
||||
};
|
||||
|
||||
const openPool = (game) => {
|
||||
setActiveGame(game);
|
||||
setPoolForm(defaultPoolForm);
|
||||
setActiveAction("pool");
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
setActiveAction("");
|
||||
setActiveGame(null);
|
||||
};
|
||||
|
||||
const submitConfig = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!activeGame) return;
|
||||
setLoadingAction("config");
|
||||
try {
|
||||
await updateDiceConfig(activeGame.gameId, configPayload(configForm, activeGame.gameId));
|
||||
await load();
|
||||
showToast("骰子配置已保存", "success");
|
||||
closeDialog();
|
||||
} catch (err) {
|
||||
showToast(err.message || "保存骰子配置失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
const submitPool = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!activeGame) return;
|
||||
setLoadingAction("pool");
|
||||
try {
|
||||
await adjustDicePool(activeGame.gameId, {
|
||||
amountCoin: Number(poolForm.amountCoin || 0),
|
||||
direction: poolForm.direction,
|
||||
reason: poolForm.reason,
|
||||
});
|
||||
await load();
|
||||
showToast("奖池已调整", "success");
|
||||
closeDialog();
|
||||
} catch (err) {
|
||||
showToast(err.message || "调整奖池失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
actions={
|
||||
<AdminActionIconButton label="刷新" onClick={load}>
|
||||
<RefreshOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
}
|
||||
/>
|
||||
<AdminListBody>
|
||||
<DataTable columns={columns} items={items} minWidth="980px" rowKey={(game) => game.gameId} />
|
||||
</AdminListBody>
|
||||
<ConfigDialog
|
||||
form={configForm}
|
||||
loading={loadingAction === "config"}
|
||||
open={activeAction === "config"}
|
||||
setForm={setConfigForm}
|
||||
onClose={closeDialog}
|
||||
onSubmit={submitConfig}
|
||||
/>
|
||||
<PoolDialog
|
||||
form={poolForm}
|
||||
loading={loadingAction === "pool"}
|
||||
open={activeAction === "pool"}
|
||||
setForm={setPoolForm}
|
||||
onClose={closeDialog}
|
||||
onSubmit={submitPool}
|
||||
/>
|
||||
</AdminListPage>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigDialog({ form, loading, open, setForm, onClose, onSubmit }) {
|
||||
return (
|
||||
<AdminFormDialog loading={loading} open={open} submitLabel="保存配置" title="骰子配置" onClose={onClose} onSubmit={onSubmit}>
|
||||
<AdminFormSection title="下注与费率">
|
||||
<AdminFormFieldGrid>
|
||||
<TextField
|
||||
label="下注档位"
|
||||
value={form.stakeOptionsText}
|
||||
onChange={(event) => setForm({ ...form, stakeOptionsText: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="平台抽水 bps"
|
||||
type="number"
|
||||
value={form.feeBps}
|
||||
onChange={(event) => setForm({ ...form, feeBps: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="入池 bps"
|
||||
type="number"
|
||||
value={form.poolBps}
|
||||
onChange={(event) => setForm({ ...form, poolBps: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="状态"
|
||||
select
|
||||
value={form.status}
|
||||
onChange={(event) => setForm({ ...form, status: event.target.value })}
|
||||
>
|
||||
<MenuItem value="active">启用</MenuItem>
|
||||
<MenuItem value="disabled">停用</MenuItem>
|
||||
</TextField>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="匹配">
|
||||
<AdminFormFieldGrid>
|
||||
<TextField
|
||||
label="最少人数"
|
||||
type="number"
|
||||
value={form.minPlayers}
|
||||
onChange={(event) => setForm({ ...form, minPlayers: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="最多人数"
|
||||
type="number"
|
||||
value={form.maxPlayers}
|
||||
onChange={(event) => setForm({ ...form, maxPlayers: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="机器人等待 ms"
|
||||
type="number"
|
||||
value={form.robotMatchWaitMs}
|
||||
onChange={(event) => setForm({ ...form, robotMatchWaitMs: event.target.value })}
|
||||
/>
|
||||
<AdminFormSwitchField
|
||||
checked={form.robotEnabled}
|
||||
label="机器人补位"
|
||||
onChange={(event) => setForm({ ...form, robotEnabled: event.target.checked })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
</AdminFormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function PoolDialog({ form, loading, open, setForm, onClose, onSubmit }) {
|
||||
return (
|
||||
<AdminFormDialog loading={loading} open={open} submitLabel="确认调整" title="调整奖池" onClose={onClose} onSubmit={onSubmit}>
|
||||
<AdminFormFieldGrid>
|
||||
<TextField
|
||||
label="方向"
|
||||
select
|
||||
value={form.direction}
|
||||
onChange={(event) => setForm({ ...form, direction: event.target.value })}
|
||||
>
|
||||
<MenuItem value="in">增加</MenuItem>
|
||||
<MenuItem value="out">减少</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
label="金币"
|
||||
type="number"
|
||||
value={form.amountCoin}
|
||||
onChange={(event) => setForm({ ...form, amountCoin: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="原因"
|
||||
value={form.reason}
|
||||
onChange={(event) => setForm({ ...form, reason: event.target.value })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function configToForm(game) {
|
||||
return {
|
||||
status: game.status || "active",
|
||||
stakeOptionsText: (game.stakeOptions || []).map((item) => item.stakeCoin).join(","),
|
||||
feeBps: game.feeBps,
|
||||
poolBps: game.poolBps,
|
||||
minPlayers: game.minPlayers,
|
||||
maxPlayers: game.maxPlayers,
|
||||
robotEnabled: game.robotEnabled,
|
||||
robotMatchWaitMs: game.robotMatchWaitMs,
|
||||
};
|
||||
}
|
||||
|
||||
function configPayload(form, gameId) {
|
||||
return {
|
||||
gameId,
|
||||
status: form.status,
|
||||
stakeOptions: parseStakeOptions(form.stakeOptionsText),
|
||||
feeBps: Number(form.feeBps || 0),
|
||||
poolBps: Number(form.poolBps || 0),
|
||||
minPlayers: Number(form.minPlayers || 2),
|
||||
maxPlayers: Number(form.maxPlayers || 2),
|
||||
robotEnabled: Boolean(form.robotEnabled),
|
||||
robotMatchWaitMs: Number(form.robotMatchWaitMs || 3000),
|
||||
};
|
||||
}
|
||||
|
||||
function parseStakeOptions(text) {
|
||||
return String(text || "")
|
||||
.split(/[,,\s]+/)
|
||||
.map((value) => Number(value))
|
||||
.filter((value) => Number.isFinite(value) && value > 0)
|
||||
.map((stakeCoin, index) => ({ stakeCoin, enabled: true, sortOrder: (index + 1) * 10 }));
|
||||
}
|
||||
|
||||
function bpsPercent(value) {
|
||||
return `${(Number(value || 0) / 100).toFixed(2)}%`;
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return new Intl.NumberFormat("en-US").format(Number(value || 0));
|
||||
}
|
||||
@ -9,4 +9,20 @@ export const gameRoutes = [
|
||||
path: "/games",
|
||||
permission: PERMISSIONS.gameView,
|
||||
},
|
||||
{
|
||||
label: "自研游戏",
|
||||
loader: () => import("./pages/SelfGamePage.jsx").then((module) => module.SelfGamePage),
|
||||
menuCode: MENU_CODES.selfGames,
|
||||
pageKey: "self-games",
|
||||
path: "/games/self-games",
|
||||
permission: PERMISSIONS.gameView,
|
||||
},
|
||||
{
|
||||
label: "游戏机器人",
|
||||
loader: () => import("./pages/GameRobotPage.jsx").then((module) => module.GameRobotPage),
|
||||
menuCode: MENU_CODES.gameRobots,
|
||||
pageKey: "game-robots",
|
||||
path: "/games/robots",
|
||||
permission: PERMISSIONS.gameView,
|
||||
},
|
||||
];
|
||||
|
||||
@ -126,7 +126,7 @@ export function useHostAgenciesPage() {
|
||||
commandId: makeCommandId("agency-close"),
|
||||
reason: "close agency from list switch",
|
||||
});
|
||||
patchAgencyRow(agency.agencyId, data || { status: "closed", joinEnabled: false });
|
||||
patchAgencyRow(agency.agencyId, agencyActionPatch(data, { status: "closed", joinEnabled: false }));
|
||||
return data;
|
||||
});
|
||||
};
|
||||
@ -141,7 +141,7 @@ export function useHostAgenciesPage() {
|
||||
joinEnabled,
|
||||
reason: "set agency join enabled from list switch",
|
||||
});
|
||||
patchAgencyRow(agency.agencyId, data || { joinEnabled });
|
||||
patchAgencyRow(agency.agencyId, agencyActionPatch(data, { joinEnabled }));
|
||||
return data;
|
||||
});
|
||||
};
|
||||
@ -221,3 +221,13 @@ export function useHostAgenciesPage() {
|
||||
function makeCommandId(prefix) {
|
||||
return `${prefix}-${Date.now()}`;
|
||||
}
|
||||
|
||||
function agencyActionPatch(data, fallback = {}) {
|
||||
const patch = { ...fallback };
|
||||
for (const key of ["status", "joinEnabled", "updatedAtMs"]) {
|
||||
if (data?.[key] !== undefined) {
|
||||
patch[key] = data[key];
|
||||
}
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
|
||||
@ -2,11 +2,14 @@ import { apiRequest } from "@/shared/api/request";
|
||||
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||
import type {
|
||||
ApiPage,
|
||||
ApiList,
|
||||
EntityId,
|
||||
PageQuery,
|
||||
RechargeBillDto,
|
||||
RechargeProductDto,
|
||||
RechargeProductPayload,
|
||||
ThirdPartyPaymentChannelDto,
|
||||
ThirdPartyPaymentMethodDto,
|
||||
} from "@/shared/api/types";
|
||||
|
||||
export function listRechargeBills(query: PageQuery = {}): Promise<ApiPage<RechargeBillDto>> {
|
||||
@ -56,3 +59,41 @@ export function deleteRechargeProduct(productId: EntityId): Promise<unknown> {
|
||||
method: endpoint.method,
|
||||
});
|
||||
}
|
||||
|
||||
export function listThirdPartyPaymentChannels(): Promise<ApiList<ThirdPartyPaymentChannelDto>> {
|
||||
const endpoint = API_ENDPOINTS.listThirdPartyPaymentChannels;
|
||||
return apiRequest<ApiList<ThirdPartyPaymentChannelDto>>(
|
||||
apiEndpointPath(API_OPERATIONS.listThirdPartyPaymentChannels),
|
||||
{
|
||||
method: endpoint.method,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function setThirdPartyPaymentMethodStatus(
|
||||
methodId: EntityId,
|
||||
payload: { enabled: boolean },
|
||||
): Promise<ThirdPartyPaymentMethodDto> {
|
||||
const endpoint = API_ENDPOINTS.setThirdPartyPaymentMethodStatus;
|
||||
return apiRequest<ThirdPartyPaymentMethodDto, { enabled: boolean }>(
|
||||
apiEndpointPath(API_OPERATIONS.setThirdPartyPaymentMethodStatus, { method_id: methodId }),
|
||||
{
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function updateThirdPartyPaymentRate(
|
||||
methodId: EntityId,
|
||||
payload: { usdToCurrencyRate: string },
|
||||
): Promise<ThirdPartyPaymentMethodDto> {
|
||||
const endpoint = API_ENDPOINTS.updateThirdPartyPaymentRate;
|
||||
return apiRequest<ThirdPartyPaymentMethodDto, { usdToCurrencyRate: string }>(
|
||||
apiEndpointPath(API_OPERATIONS.updateThirdPartyPaymentRate, { method_id: methodId }),
|
||||
{
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -15,12 +15,14 @@ import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
|
||||
const pageSize = 50;
|
||||
|
||||
// 新建默认走 H5 web 档位和普通用户档位;币商档位必须由运营显式选择,避免误配商品受众。
|
||||
const emptyForm = () => ({
|
||||
amountUsdt: "",
|
||||
audienceType: "normal",
|
||||
coinAmount: "",
|
||||
description: "",
|
||||
enabled: true,
|
||||
platform: "android",
|
||||
platform: "web",
|
||||
productName: "",
|
||||
regionIds: [],
|
||||
});
|
||||
@ -33,6 +35,7 @@ export function useRechargeProductsPage() {
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [platform, setPlatform] = useState("");
|
||||
const [audienceType, setAudienceType] = useState("");
|
||||
const [regionId, setRegionId] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [activeAction, setActiveAction] = useState("");
|
||||
@ -40,14 +43,16 @@ export function useRechargeProductsPage() {
|
||||
const [form, setFormState] = useState(emptyForm);
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
|
||||
// audienceType 直接透传给 admin-server,后台再转 wallet-service 过滤 normal / coin_seller 档位。
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
audienceType,
|
||||
keyword,
|
||||
platform,
|
||||
regionId,
|
||||
status,
|
||||
}),
|
||||
[keyword, platform, regionId, status],
|
||||
[audienceType, keyword, platform, regionId, status],
|
||||
);
|
||||
const result = usePaginatedQuery({
|
||||
errorMessage: "加载内购配置失败",
|
||||
@ -88,6 +93,7 @@ export function useRechargeProductsPage() {
|
||||
setKeyword("");
|
||||
setStatus("");
|
||||
setPlatform("");
|
||||
setAudienceType("");
|
||||
setRegionId("");
|
||||
};
|
||||
|
||||
@ -107,12 +113,36 @@ export function useRechargeProductsPage() {
|
||||
if (editingItem) {
|
||||
await updateRechargeProduct(editingItem.productId, payload);
|
||||
showToast("内购配置已更新", "success");
|
||||
closeDialog();
|
||||
await result.reload();
|
||||
} else {
|
||||
await createRechargeProduct(payload);
|
||||
const createdProduct = await createRechargeProduct(payload);
|
||||
const nextFilters = visibleFiltersForCreatedProduct(createdProduct, payload);
|
||||
const canReloadCurrentQuery =
|
||||
page === 1 &&
|
||||
keyword === nextFilters.keyword &&
|
||||
status === nextFilters.status &&
|
||||
platform === nextFilters.platform &&
|
||||
audienceType === nextFilters.audienceType &&
|
||||
regionId === nextFilters.regionId;
|
||||
|
||||
showToast("内购配置已新增", "success");
|
||||
closeDialog();
|
||||
|
||||
// 新增币商档位时,当前列表可能还停在“普通用户”过滤条件;这里主动切到新商品所属的受众、
|
||||
// 平台和上下架状态,并清空搜索/区域过滤,保证运营提交后能立刻看到刚创建的记录。
|
||||
setKeyword(nextFilters.keyword);
|
||||
setStatus(nextFilters.status);
|
||||
setPlatform(nextFilters.platform);
|
||||
setAudienceType(nextFilters.audienceType);
|
||||
setRegionId(nextFilters.regionId);
|
||||
setPage(1);
|
||||
|
||||
// 如果当前查询条件已经正好能展示新商品,直接刷新当前页;否则交给上面的过滤条件变更触发新查询。
|
||||
if (canReloadCurrentQuery) {
|
||||
await result.reload();
|
||||
}
|
||||
}
|
||||
closeDialog();
|
||||
await result.reload();
|
||||
} catch (err) {
|
||||
showToast(err.message || "操作失败", "error");
|
||||
} finally {
|
||||
@ -158,6 +188,7 @@ export function useRechargeProductsPage() {
|
||||
return {
|
||||
abilities,
|
||||
activeAction,
|
||||
audienceType,
|
||||
closeDialog,
|
||||
data: result.data,
|
||||
editingItem,
|
||||
@ -178,6 +209,7 @@ export function useRechargeProductsPage() {
|
||||
removeProduct,
|
||||
resetFilters,
|
||||
setForm,
|
||||
setAudienceType,
|
||||
setKeyword,
|
||||
setPage,
|
||||
setPlatform,
|
||||
@ -193,35 +225,60 @@ function normalizeForm(form) {
|
||||
return {
|
||||
...form,
|
||||
amountUsdt: form.amountUsdt || "",
|
||||
audienceType: form.audienceType || "normal",
|
||||
coinAmount: form.coinAmount || "",
|
||||
regionIds: form.regionIds || [],
|
||||
};
|
||||
}
|
||||
|
||||
function formFromProduct(item) {
|
||||
// 后端老数据可能没有 audienceType;前端按普通用户兜底,保证旧档位不会被误认为币商档位。
|
||||
return {
|
||||
amountUsdt: item.amountUsdt || "",
|
||||
audienceType: item.audienceType === "coin_seller" ? "coin_seller" : "normal",
|
||||
coinAmount: item.coinAmount === 0 || item.coinAmount ? String(item.coinAmount) : "",
|
||||
description: item.description || "",
|
||||
enabled: item.enabled ?? item.status === "active",
|
||||
platform: item.platform || "android",
|
||||
platform: item.platform || "web",
|
||||
productName: item.productName || "",
|
||||
regionIds: (item.regionIds || []).map(String),
|
||||
};
|
||||
}
|
||||
|
||||
function productPayloadFromItem(item, enabled) {
|
||||
// 行内上下架只改变 enabled,其他字段必须按订单快照原样回传,避免一次快捷开关丢失受众或平台。
|
||||
return {
|
||||
amountUsdt: item.amountUsdt || formatMicroAmount(item.amountUsdtMicro),
|
||||
audienceType: item.audienceType === "coin_seller" ? "coin_seller" : "normal",
|
||||
coinAmount: item.coinAmount,
|
||||
description: item.description || "",
|
||||
enabled,
|
||||
platform: item.platform === "ios" ? "ios" : "android",
|
||||
platform: item.platform === "android" || item.platform === "ios" ? item.platform : "web",
|
||||
productName: item.productName || "",
|
||||
regionIds: item.regionIds || [],
|
||||
};
|
||||
}
|
||||
|
||||
function visibleFiltersForCreatedProduct(product, payload) {
|
||||
const enabled = product?.enabled ?? payload.enabled;
|
||||
const audienceType =
|
||||
product?.audienceType === "coin_seller" || payload.audienceType === "coin_seller" ? "coin_seller" : "normal";
|
||||
return {
|
||||
audienceType,
|
||||
keyword: "",
|
||||
platform: normalizeProductPlatform(product?.platform || payload.platform),
|
||||
regionId: "",
|
||||
status: product?.status === "disabled" || enabled === false ? "disabled" : "active",
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProductPlatform(value) {
|
||||
if (value === "android" || value === "ios" || value === "web") {
|
||||
return value;
|
||||
}
|
||||
return "web";
|
||||
}
|
||||
|
||||
function formatMicroAmount(value) {
|
||||
const numberValue = Number(value || 0);
|
||||
if (!Number.isFinite(numberValue) || numberValue <= 0) {
|
||||
|
||||
124
src/features/payment/hooks/useThirdPartyPaymentPage.js
Normal file
124
src/features/payment/hooks/useThirdPartyPaymentPage.js
Normal file
@ -0,0 +1,124 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
listThirdPartyPaymentChannels,
|
||||
setThirdPartyPaymentMethodStatus,
|
||||
updateThirdPartyPaymentRate,
|
||||
} from "@/features/payment/api";
|
||||
import { usePaymentAbilities } from "@/features/payment/permissions.js";
|
||||
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
|
||||
const emptyChannels = { items: [], total: 0 };
|
||||
|
||||
export function useThirdPartyPaymentPage() {
|
||||
const abilities = usePaymentAbilities();
|
||||
const { showToast } = useToast();
|
||||
const [expandedChannel, setExpandedChannel] = useState("");
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
const [rateDrafts, setRateDrafts] = useState({});
|
||||
|
||||
// 三方支付页是配置中心入口,列表必须包含已关闭方式,H5 只展示后端判定可用的方式。
|
||||
// 这里不在前端过滤关闭项,保证运营可以重新打开某个国家或某个支付方式。
|
||||
const result = useAdminQuery(listThirdPartyPaymentChannels, {
|
||||
errorMessage: "加载第三方支付失败",
|
||||
initialData: emptyChannels,
|
||||
queryKey: ["payment", "third-party-channels"],
|
||||
});
|
||||
|
||||
// 后端按 providerCode 返回渠道;展开态只保存 code,避免列表刷新后持有旧对象。
|
||||
const channels = useMemo(() => result.data?.items || [], [result.data]);
|
||||
const activeChannelCode = expandedChannel || channels[0]?.providerCode || "";
|
||||
const activeChannel = useMemo(
|
||||
() => channels.find((channel) => channel.providerCode === activeChannelCode) || channels[0] || null,
|
||||
[activeChannelCode, channels],
|
||||
);
|
||||
|
||||
// 默认打开第一条渠道,让运营进页面即可看到国家/方式明细;没有渠道时保持空态。
|
||||
useEffect(() => {
|
||||
if (!expandedChannel && channels[0]?.providerCode) {
|
||||
setExpandedChannel(channels[0].providerCode);
|
||||
}
|
||||
}, [channels, expandedChannel]);
|
||||
|
||||
// 汇率输入是可编辑草稿,不能直接绑定服务端对象,否则用户输入中途刷新会覆盖正在编辑的值。
|
||||
// 只在本地没有草稿时用接口值初始化,保存后再通过 reload 收敛到服务端事实。
|
||||
useEffect(() => {
|
||||
setRateDrafts((current) => {
|
||||
const next = { ...current };
|
||||
channels.forEach((channel) => {
|
||||
(channel.methods || []).forEach((method) => {
|
||||
const key = String(method.methodId);
|
||||
if (next[key] === undefined) {
|
||||
next[key] = method.usdToCurrencyRate || "";
|
||||
}
|
||||
});
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}, [channels]);
|
||||
|
||||
const toggleChannel = (channelCode) => {
|
||||
setExpandedChannel((current) => (current === channelCode ? "" : channelCode));
|
||||
};
|
||||
|
||||
const setRateDraft = (methodId, value) => {
|
||||
setRateDrafts((current) => ({ ...current, [String(methodId)]: value }));
|
||||
};
|
||||
|
||||
const toggleMethod = async (method, active) => {
|
||||
// 权限判断放在动作入口,避免只依赖按钮禁用;接口失败时保留原状态并提示。
|
||||
if (!abilities.canUpdateThirdParty) {
|
||||
return;
|
||||
}
|
||||
const actionKey = `status:${method.methodId}`;
|
||||
setLoadingAction(actionKey);
|
||||
try {
|
||||
await setThirdPartyPaymentMethodStatus(method.methodId, { enabled: active });
|
||||
await result.reload();
|
||||
showToast(active ? "支付方式已打开" : "支付方式已关闭", "success");
|
||||
} catch (err) {
|
||||
showToast(err.message || "状态更新失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
const saveRate = async (method) => {
|
||||
// 汇率决定 H5 展示和下单本币金额,前端先挡掉空值和非正数,最终仍以后端校验为准。
|
||||
if (!abilities.canUpdateThirdParty) {
|
||||
return;
|
||||
}
|
||||
const rateToUsd = String(rateDrafts[String(method.methodId)] || "").trim();
|
||||
if (!rateToUsd || Number(rateToUsd) <= 0) {
|
||||
showToast("请填写大于 0 的汇率", "error");
|
||||
return;
|
||||
}
|
||||
const actionKey = `rate:${method.methodId}`;
|
||||
setLoadingAction(actionKey);
|
||||
try {
|
||||
await updateThirdPartyPaymentRate(method.methodId, { usdToCurrencyRate: rateToUsd });
|
||||
await result.reload();
|
||||
showToast("汇率已保存", "success");
|
||||
} catch (err) {
|
||||
showToast(err.message || "汇率保存失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
activeChannel,
|
||||
channels,
|
||||
error: result.error,
|
||||
expandedChannel,
|
||||
loading: result.loading,
|
||||
loadingAction,
|
||||
rateDrafts,
|
||||
reload: result.reload,
|
||||
saveRate,
|
||||
setRateDraft,
|
||||
toggleChannel,
|
||||
toggleMethod,
|
||||
};
|
||||
}
|
||||
@ -36,10 +36,17 @@ const statusOptions = [
|
||||
|
||||
const platformOptions = [
|
||||
["", "全部平台"],
|
||||
["web", "Web"],
|
||||
["android", "Android"],
|
||||
["ios", "iOS"],
|
||||
];
|
||||
|
||||
const audienceOptions = [
|
||||
["", "全部用户类型"],
|
||||
["normal", "普通用户"],
|
||||
["coin_seller", "币商"],
|
||||
];
|
||||
|
||||
export function RechargeProductConfigPage() {
|
||||
const page = useRechargeProductsPage();
|
||||
const items = page.data?.items || [];
|
||||
@ -107,9 +114,21 @@ export function RechargeProductConfigPage() {
|
||||
value={page.form.platform}
|
||||
onChange={(event) => page.setForm({ platform: event.target.value })}
|
||||
>
|
||||
<MenuItem value="web">Web</MenuItem>
|
||||
<MenuItem value="android">Android</MenuItem>
|
||||
<MenuItem value="ios">iOS</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={!canSubmit}
|
||||
label="用户类型"
|
||||
required
|
||||
select
|
||||
value={page.form.audienceType}
|
||||
onChange={(event) => page.setForm({ audienceType: event.target.value })}
|
||||
>
|
||||
<MenuItem value="normal">普通用户</MenuItem>
|
||||
<MenuItem value="coin_seller">币商</MenuItem>
|
||||
</TextField>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="价格配置">
|
||||
@ -205,6 +224,18 @@ function productColumns(page, regionLabels) {
|
||||
}),
|
||||
width: "minmax(120px, 0.55fr)",
|
||||
},
|
||||
{
|
||||
key: "audienceType",
|
||||
label: "用户类型",
|
||||
render: (item) => audienceLabel(item.audienceType),
|
||||
filter: createOptionsColumnFilter({
|
||||
options: audienceOptions,
|
||||
placeholder: "搜索用户类型",
|
||||
value: page.audienceType,
|
||||
onChange: page.setAudienceType,
|
||||
}),
|
||||
width: "minmax(130px, 0.6fr)",
|
||||
},
|
||||
{
|
||||
key: "regionIds",
|
||||
label: "支持区域",
|
||||
@ -309,6 +340,9 @@ function Stack({ primary, secondary }) {
|
||||
}
|
||||
|
||||
function platformLabel(value) {
|
||||
if (value === "web") {
|
||||
return "Web";
|
||||
}
|
||||
if (value === "android") {
|
||||
return "Android";
|
||||
}
|
||||
@ -318,6 +352,16 @@ function platformLabel(value) {
|
||||
return value || "-";
|
||||
}
|
||||
|
||||
function audienceLabel(value) {
|
||||
if (value === "coin_seller") {
|
||||
return "币商";
|
||||
}
|
||||
if (value === "normal") {
|
||||
return "普通用户";
|
||||
}
|
||||
return value || "普通用户";
|
||||
}
|
||||
|
||||
function regionLabelMap(options) {
|
||||
return options.reduce((labels, option) => {
|
||||
labels[String(option.value)] = option.name || option.label;
|
||||
|
||||
174
src/features/payment/pages/ThirdPartyPaymentPage.jsx
Normal file
174
src/features/payment/pages/ThirdPartyPaymentPage.jsx
Normal file
@ -0,0 +1,174 @@
|
||||
import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined";
|
||||
import KeyboardArrowRightOutlined from "@mui/icons-material/KeyboardArrowRightOutlined";
|
||||
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useThirdPartyPaymentPage } from "@/features/payment/hooks/useThirdPartyPaymentPage.js";
|
||||
import { AdminListBody, AdminListPage, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import styles from "@/features/payment/payment.module.css";
|
||||
|
||||
export function ThirdPartyPaymentPage() {
|
||||
const page = useThirdPartyPaymentPage();
|
||||
const navigate = useNavigate();
|
||||
// 国家分组只影响展示密度,不改变后端返回的可开关粒度;每个 method 仍独立保存状态和汇率。
|
||||
const groupedMethods = useMemo(() => groupMethods(page.activeChannel?.methods || []), [page.activeChannel]);
|
||||
|
||||
return (
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
actions={
|
||||
<Button
|
||||
startIcon={<SettingsOutlined fontSize="small" />}
|
||||
variant="primary"
|
||||
onClick={() => navigate("/payment/recharge-products")}
|
||||
>
|
||||
三方支付内购配置
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<AdminListBody>
|
||||
<div className={styles.thirdPartyLayout}>
|
||||
<div className={styles.channelList}>
|
||||
{page.channels.map((channel) => (
|
||||
<button
|
||||
key={channel.providerCode}
|
||||
className={[
|
||||
styles.channelRow,
|
||||
page.activeChannel?.providerCode === channel.providerCode
|
||||
? styles.channelRowActive
|
||||
: "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
type="button"
|
||||
onClick={() => page.toggleChannel(channel.providerCode)}
|
||||
>
|
||||
<span className={styles.channelExpandIcon}>
|
||||
{page.expandedChannel === channel.providerCode ? (
|
||||
<KeyboardArrowDownOutlined fontSize="small" />
|
||||
) : (
|
||||
<KeyboardArrowRightOutlined fontSize="small" />
|
||||
)}
|
||||
</span>
|
||||
<span className={styles.channelText}>
|
||||
<span>{channel.providerName || channel.providerCode}</span>
|
||||
<span>{channel.providerCode}</span>
|
||||
</span>
|
||||
<StatusPill active={channel.status === "active"} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.methodPanel}>
|
||||
{page.activeChannel ? (
|
||||
groupedMethods.map((group) => (
|
||||
<section key={group.key} className={styles.methodGroup}>
|
||||
<div className={styles.methodGroupHeader}>
|
||||
<span>{group.title}</span>
|
||||
<span>{group.methods.length} 个支付方式</span>
|
||||
</div>
|
||||
<div className={styles.methodTable}>
|
||||
<div className={styles.methodHeader}>
|
||||
<span>支付方式</span>
|
||||
<span>类型</span>
|
||||
<span>币种</span>
|
||||
<span>USD 到本币汇率</span>
|
||||
<span>状态</span>
|
||||
</div>
|
||||
{group.methods.map((method) => (
|
||||
<div key={method.methodId} className={styles.methodRow}>
|
||||
<span className={styles.methodName}>
|
||||
<strong>{method.methodName || method.payWay || "-"}</strong>
|
||||
<small>{method.payWay || "-"}</small>
|
||||
</span>
|
||||
<span>{method.payType || "-"}</span>
|
||||
<span>{method.currencyCode || "-"}</span>
|
||||
<span className={styles.rateEditor}>
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdateThirdParty}
|
||||
size="small"
|
||||
slotProps={{ htmlInput: { inputMode: "decimal" } }}
|
||||
value={page.rateDrafts[String(method.methodId)] ?? ""}
|
||||
onChange={(event) =>
|
||||
page.setRateDraft(method.methodId, event.target.value)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
disabled={
|
||||
!page.abilities.canUpdateThirdParty ||
|
||||
page.loadingAction === `rate:${method.methodId}`
|
||||
}
|
||||
onClick={() => page.saveRate(method)}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</span>
|
||||
<span>
|
||||
<AdminSwitch
|
||||
checked={method.status === "active"}
|
||||
checkedLabel="开"
|
||||
disabled={
|
||||
!page.abilities.canUpdateThirdParty ||
|
||||
page.loadingAction === `status:${method.methodId}`
|
||||
}
|
||||
inputProps={{
|
||||
"aria-label": method.status === "active"
|
||||
? "关闭支付方式"
|
||||
: "打开支付方式",
|
||||
}}
|
||||
uncheckedLabel="关"
|
||||
onChange={(event) =>
|
||||
page.toggleMethod(method, event.target.checked)
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))
|
||||
) : (
|
||||
<div className={styles.emptyMethods}>当前无第三方支付渠道</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
</AdminListPage>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusPill({ active }) {
|
||||
return <span className={active ? styles.statusActive : styles.statusDisabled}>{active ? "已启用" : "已停用"}</span>;
|
||||
}
|
||||
|
||||
function groupMethods(methods) {
|
||||
// GLOBAL 是三方支付的跨国家方式,放在最前面,国家方式再按标题排序,方便运营先处理全局支付。
|
||||
const groups = new Map();
|
||||
methods.forEach((method) => {
|
||||
const countryCode = method.countryCode || "GLOBAL";
|
||||
const countryName = countryCode === "GLOBAL" ? "全球方式" : method.countryName || countryCode;
|
||||
if (!groups.has(countryCode)) {
|
||||
groups.set(countryCode, {
|
||||
key: countryCode,
|
||||
methods: [],
|
||||
title: `${countryName} / ${countryCode}`,
|
||||
});
|
||||
}
|
||||
groups.get(countryCode).methods.push(method);
|
||||
});
|
||||
return Array.from(groups.values()).sort((left, right) => {
|
||||
if (left.key === "GLOBAL") {
|
||||
return -1;
|
||||
}
|
||||
if (right.key === "GLOBAL") {
|
||||
return 1;
|
||||
}
|
||||
return left.title.localeCompare(right.title);
|
||||
});
|
||||
}
|
||||
@ -54,3 +54,220 @@
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--admin-font-size);
|
||||
}
|
||||
|
||||
.thirdPartyLayout {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: minmax(260px, 320px) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.channelList,
|
||||
.methodPanel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.channelList {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
overflow: auto;
|
||||
padding: var(--space-4) 0 var(--space-4) var(--space-4);
|
||||
}
|
||||
|
||||
.channelRow {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-height: 72px;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
gap: var(--space-2);
|
||||
grid-template-columns: 24px minmax(0, 1fr) auto;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.channelRow:hover,
|
||||
.channelRowActive {
|
||||
border-color: var(--primary-border);
|
||||
background: var(--active-surface);
|
||||
}
|
||||
|
||||
.channelExpandIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.channelText {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.channelText span:first-child {
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
font-weight: 720;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.channelText span:last-child {
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--admin-font-size);
|
||||
}
|
||||
|
||||
.methodPanel {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
overflow: auto;
|
||||
padding: var(--space-4) var(--space-4) var(--space-4) 0;
|
||||
}
|
||||
|
||||
.methodGroup {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.methodGroupHeader {
|
||||
display: flex;
|
||||
min-height: 48px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: 0 var(--space-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text-primary);
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
.methodGroupHeader span:last-child {
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--admin-font-size);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.methodTable {
|
||||
display: grid;
|
||||
min-width: 760px;
|
||||
}
|
||||
|
||||
.methodHeader,
|
||||
.methodRow {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
grid-template-columns: minmax(180px, 1.1fr) 120px 100px minmax(220px, 0.9fr) 104px;
|
||||
}
|
||||
|
||||
.methodHeader {
|
||||
min-height: 42px;
|
||||
padding: 0 var(--space-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--admin-font-size);
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
.methodRow {
|
||||
min-height: 64px;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.methodRow:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.methodName {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.methodName strong,
|
||||
.methodName small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.methodName small {
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--admin-font-size);
|
||||
}
|
||||
|
||||
.rateEditor {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
grid-template-columns: minmax(96px, 1fr) auto;
|
||||
}
|
||||
|
||||
.statusActive,
|
||||
.statusDisabled {
|
||||
display: inline-flex;
|
||||
min-width: 56px;
|
||||
height: 26px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 12px;
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
.statusActive {
|
||||
background: var(--success-surface);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.statusDisabled {
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.emptyMethods {
|
||||
display: flex;
|
||||
min-height: 280px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.thirdPartyLayout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.channelList {
|
||||
padding: var(--space-4) var(--space-4) 0;
|
||||
}
|
||||
|
||||
.methodPanel {
|
||||
padding: 0 var(--space-4) var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,5 +10,7 @@ export function usePaymentAbilities() {
|
||||
canUpdateProduct: can(PERMISSIONS.paymentProductUpdate),
|
||||
canViewBill: can(PERMISSIONS.paymentBillView),
|
||||
canViewProduct: can(PERMISSIONS.paymentProductView),
|
||||
canUpdateThirdParty: can(PERMISSIONS.paymentThirdPartyUpdate),
|
||||
canViewThirdParty: can(PERMISSIONS.paymentThirdPartyView),
|
||||
};
|
||||
}
|
||||
|
||||
@ -8,5 +8,22 @@ export const paymentRoutes = [
|
||||
pageKey: "payment-bill-list",
|
||||
path: "/payment/bills",
|
||||
permission: PERMISSIONS.paymentBillView,
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "第三方支付",
|
||||
loader: () => import("./pages/ThirdPartyPaymentPage.jsx").then((module) => module.ThirdPartyPaymentPage),
|
||||
menuCode: MENU_CODES.paymentThirdParty,
|
||||
pageKey: "payment-third-party",
|
||||
path: "/payment/third-party",
|
||||
permission: PERMISSIONS.paymentThirdPartyView,
|
||||
},
|
||||
{
|
||||
label: "三方支付内购配置",
|
||||
loader: () =>
|
||||
import("./pages/RechargeProductConfigPage.jsx").then((module) => module.RechargeProductConfigPage),
|
||||
menuCode: MENU_CODES.paymentRechargeProducts,
|
||||
pageKey: "payment-recharge-products",
|
||||
path: "/payment/recharge-products",
|
||||
permission: PERMISSIONS.paymentProductView,
|
||||
},
|
||||
];
|
||||
|
||||
@ -8,10 +8,11 @@ const usdtAmountSchema = z
|
||||
|
||||
export const rechargeProductSchema = z.object({
|
||||
amountUsdt: usdtAmountSchema,
|
||||
audienceType: z.enum(["normal", "coin_seller"]),
|
||||
coinAmount: z.coerce.number().int("获得金币数必须是整数").positive("获得金币数必须大于 0"),
|
||||
description: z.string().trim().max(512, "描述不能超过 512 个字符").default(""),
|
||||
enabled: z.boolean(),
|
||||
platform: z.enum(["android", "ios"]),
|
||||
platform: z.enum(["android", "ios", "web"]),
|
||||
productName: z.string().trim().min(1, "请填写产品名称").max(128, "产品名称不能超过 128 个字符"),
|
||||
regionIds: z.array(z.coerce.number().int().positive()).min(1, "请选择支持区域"),
|
||||
});
|
||||
|
||||
@ -4,6 +4,7 @@ import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
|
||||
import ChevronLeftOutlined from "@mui/icons-material/ChevronLeftOutlined";
|
||||
import ChevronRightOutlined from "@mui/icons-material/ChevronRightOutlined";
|
||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||
import PublicOutlined from "@mui/icons-material/PublicOutlined";
|
||||
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import InputAdornment from "@mui/material/InputAdornment";
|
||||
@ -97,7 +98,16 @@ const baseColumns = (giftTypeOptions) => [
|
||||
render: (gift) => (
|
||||
<div className={styles.stack}>
|
||||
{giftEffectDetails(gift).map((item, index) => (
|
||||
<span className={index > 0 ? styles.meta : undefined} key={item}>
|
||||
<span
|
||||
className={
|
||||
item === effectLabels.global_broadcast
|
||||
? styles.globalBroadcastEffect
|
||||
: index > 0
|
||||
? styles.meta
|
||||
: undefined
|
||||
}
|
||||
key={item}
|
||||
>
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
@ -641,10 +651,16 @@ function GiftIdentity({ gift }) {
|
||||
const preview = imageURL(gift.resource?.previewUrl || gift.resource?.assetUrl);
|
||||
const resourceCode = String(gift.resource?.resourceCode || "").trim();
|
||||
const title = `${gift.name || "-"}${resourceCode ? `(${resourceCode})` : ""}`;
|
||||
const globalBroadcast = hasGlobalBroadcastEffect(gift);
|
||||
return (
|
||||
<div className={styles.identity}>
|
||||
<span className={styles.thumb}>
|
||||
{preview ? <img src={preview} alt="" /> : <CardGiftcardOutlined fontSize="small" />}
|
||||
{globalBroadcast ? (
|
||||
<span className={styles.giftGlobalBroadcastBadge} title="全局广播">
|
||||
<PublicOutlined fontSize="inherit" />
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<div className={styles.identityText}>
|
||||
<span className={styles.name}>{title}</span>
|
||||
@ -689,6 +705,10 @@ function effectTypesLabel(values) {
|
||||
return list.length ? list.map(effectTypeLabel).join("、") : "无特效";
|
||||
}
|
||||
|
||||
function hasGlobalBroadcastEffect(gift) {
|
||||
return (gift.effectTypes || []).some((item) => String(item || "").trim() === "global_broadcast");
|
||||
}
|
||||
|
||||
function giftEffectDetails(gift) {
|
||||
const details = [];
|
||||
const effective = effectiveRangeLabel(gift);
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
}
|
||||
|
||||
.thumb {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
@ -19,6 +20,37 @@
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.giftGlobalBroadcastBadge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: inline-flex;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-bottom-right-radius: 6px;
|
||||
background: linear-gradient(135deg, #e85cff 0%, #7a3cff 100%);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
box-shadow: 0 2px 6px rgb(122 60 255 / 28%);
|
||||
}
|
||||
|
||||
.globalBroadcastEffect {
|
||||
display: inline-flex;
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
border: 1px solid rgb(122 60 255 / 26%);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 7px;
|
||||
background: rgb(122 60 255 / 10%);
|
||||
color: #5f2bd8;
|
||||
font-size: var(--admin-font-size);
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@ -34,7 +34,7 @@ const permissionGroupDefinitions = [
|
||||
prefixes: ["coin-ledger", "coin-seller-ledger", "coin-adjustment", "report", "gift-diamond"],
|
||||
title: "运营管理",
|
||||
},
|
||||
{ key: "payment", prefixes: ["payment-bill", "payment-product"], title: "支付管理" },
|
||||
{ key: "payment", prefixes: ["payment-bill", "payment-product", "payment-third-party"], title: "支付管理" },
|
||||
{
|
||||
key: "activities",
|
||||
prefixes: [
|
||||
|
||||
@ -13,6 +13,7 @@ export interface ApiEndpoint {
|
||||
}
|
||||
|
||||
export const API_OPERATIONS = {
|
||||
adjustDicePool: "adjustDicePool",
|
||||
appBanUser: "appBanUser",
|
||||
appGetUser: "appGetUser",
|
||||
appListBannedUsers: "appListBannedUsers",
|
||||
@ -44,6 +45,7 @@ export const API_OPERATIONS = {
|
||||
createMenu: "createMenu",
|
||||
createPermission: "createPermission",
|
||||
createPlatform: "createPlatform",
|
||||
createPrettyIdPool: "createPrettyIdPool",
|
||||
createRechargeProduct: "createRechargeProduct",
|
||||
createRegion: "createRegion",
|
||||
createResource: "createResource",
|
||||
@ -64,6 +66,7 @@ export const API_OPERATIONS = {
|
||||
deleteBDLeader: "deleteBDLeader",
|
||||
deleteCatalog: "deleteCatalog",
|
||||
deleteCountry: "deleteCountry",
|
||||
deleteDiceRobot: "deleteDiceRobot",
|
||||
deleteExploreTab: "deleteExploreTab",
|
||||
deleteH5Link: "deleteH5Link",
|
||||
deleteHostAgencyPolicy: "deleteHostAgencyPolicy",
|
||||
@ -89,6 +92,8 @@ export const API_OPERATIONS = {
|
||||
exportLoginLogs: "exportLoginLogs",
|
||||
exportOperationLogs: "exportOperationLogs",
|
||||
exportUsers: "exportUsers",
|
||||
generateDiceRobots: "generateDiceRobots",
|
||||
generatePrettyIds: "generatePrettyIds",
|
||||
getCoinSellerSalaryRates: "getCoinSellerSalaryRates",
|
||||
getCountry: "getCountry",
|
||||
getCumulativeRechargeRewardConfig: "getCumulativeRechargeRewardConfig",
|
||||
@ -108,6 +113,7 @@ export const API_OPERATIONS = {
|
||||
getSevenDayCheckInConfig: "getSevenDayCheckInConfig",
|
||||
getUser: "getUser",
|
||||
getWeeklyStarCycle: "getWeeklyStarCycle",
|
||||
grantPrettyId: "grantPrettyId",
|
||||
grantResource: "grantResource",
|
||||
grantResourceGroup: "grantResourceGroup",
|
||||
listAchievementDefinitions: "listAchievementDefinitions",
|
||||
@ -124,6 +130,7 @@ export const API_OPERATIONS = {
|
||||
listCoinSellers: "listCoinSellers",
|
||||
listCountries: "listCountries",
|
||||
listCumulativeRechargeRewardGrants: "listCumulativeRechargeRewardGrants",
|
||||
listDiceRobots: "listDiceRobots",
|
||||
listEmojiPackCategories: "listEmojiPackCategories",
|
||||
listEmojiPacks: "listEmojiPacks",
|
||||
listExploreTabs: "listExploreTabs",
|
||||
@ -141,6 +148,8 @@ export const API_OPERATIONS = {
|
||||
listOperationLogs: "listOperationLogs",
|
||||
listPermissions: "listPermissions",
|
||||
listPlatforms: "listPlatforms",
|
||||
listPrettyIdPools: "listPrettyIdPools",
|
||||
listPrettyIds: "listPrettyIds",
|
||||
listRechargeBills: "listRechargeBills",
|
||||
listRechargeProducts: "listRechargeProducts",
|
||||
listRedPackets: "listRedPackets",
|
||||
@ -156,11 +165,13 @@ export const API_OPERATIONS = {
|
||||
listRoomPins: "listRoomPins",
|
||||
listRooms: "listRooms",
|
||||
listRoomTurnoverRewardSettlements: "listRoomTurnoverRewardSettlements",
|
||||
listSelfGames: "listSelfGames",
|
||||
listSevenDayCheckInClaims: "listSevenDayCheckInClaims",
|
||||
listSplashScreens: "listSplashScreens",
|
||||
listSystemMenus: "listSystemMenus",
|
||||
listTaskDefinitions: "listTaskDefinitions",
|
||||
listTeamSalaryPolicies: "listTeamSalaryPolicies",
|
||||
listThirdPartyPaymentChannels: "listThirdPartyPaymentChannels",
|
||||
listUserLeaderboards: "listUserLeaderboards",
|
||||
listUsers: "listUsers",
|
||||
listWeeklyStarCycles: "listWeeklyStarCycles",
|
||||
@ -187,8 +198,11 @@ export const API_OPERATIONS = {
|
||||
setBDLeaderStatus: "setBDLeaderStatus",
|
||||
setBDStatus: "setBDStatus",
|
||||
setCoinSellerStatus: "setCoinSellerStatus",
|
||||
setDiceRobotStatus: "setDiceRobotStatus",
|
||||
setGameStatus: "setGameStatus",
|
||||
setPrettyIdStatus: "setPrettyIdStatus",
|
||||
setTaskDefinitionStatus: "setTaskDefinitionStatus",
|
||||
setThirdPartyPaymentMethodStatus: "setThirdPartyPaymentMethodStatus",
|
||||
setWeeklyStarCycleStatus: "setWeeklyStarCycleStatus",
|
||||
sortMenus: "sortMenus",
|
||||
syncPermissions: "syncPermissions",
|
||||
@ -198,6 +212,7 @@ export const API_OPERATIONS = {
|
||||
updateCatalog: "updateCatalog",
|
||||
updateCountry: "updateCountry",
|
||||
updateCumulativeRechargeRewardConfig: "updateCumulativeRechargeRewardConfig",
|
||||
updateDiceConfig: "updateDiceConfig",
|
||||
updateExploreTab: "updateExploreTab",
|
||||
updateFirstRechargeRewardConfig: "updateFirstRechargeRewardConfig",
|
||||
updateGift: "updateGift",
|
||||
@ -210,6 +225,7 @@ export const API_OPERATIONS = {
|
||||
updateMenuVisible: "updateMenuVisible",
|
||||
updatePermission: "updatePermission",
|
||||
updatePlatform: "updatePlatform",
|
||||
updatePrettyIdPool: "updatePrettyIdPool",
|
||||
updateRechargeProduct: "updateRechargeProduct",
|
||||
updateRedPacketConfig: "updateRedPacketConfig",
|
||||
updateRegion: "updateRegion",
|
||||
@ -226,6 +242,7 @@ export const API_OPERATIONS = {
|
||||
updateSplashScreen: "updateSplashScreen",
|
||||
updateTaskDefinition: "updateTaskDefinition",
|
||||
updateTeamSalaryPolicy: "updateTeamSalaryPolicy",
|
||||
updateThirdPartyPaymentRate: "updateThirdPartyPaymentRate",
|
||||
updateTier: "updateTier",
|
||||
updateTrack: "updateTrack",
|
||||
updateUser: "updateUser",
|
||||
@ -241,6 +258,13 @@ export const API_OPERATIONS = {
|
||||
export type ApiOperationId = (typeof API_OPERATIONS)[keyof typeof API_OPERATIONS];
|
||||
|
||||
export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
adjustDicePool: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.adjustDicePool,
|
||||
path: "/v1/admin/game/self-games/{game_id}/pool/adjust",
|
||||
permission: "game:update",
|
||||
permissions: ["game:update"]
|
||||
},
|
||||
appBanUser: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.appBanUser,
|
||||
@ -456,6 +480,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "game:update",
|
||||
permissions: ["game:update"]
|
||||
},
|
||||
createPrettyIdPool: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.createPrettyIdPool,
|
||||
path: "/v1/admin/users/pretty-id-pools",
|
||||
permission: "pretty-id:update",
|
||||
permissions: ["pretty-id:update"]
|
||||
},
|
||||
createRechargeProduct: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.createRechargeProduct,
|
||||
@ -596,6 +627,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "country:status",
|
||||
permissions: ["country:status"]
|
||||
},
|
||||
deleteDiceRobot: {
|
||||
method: "DELETE",
|
||||
operationId: API_OPERATIONS.deleteDiceRobot,
|
||||
path: "/v1/admin/game/robots/{user_id}",
|
||||
permission: "game:delete",
|
||||
permissions: ["game:delete"]
|
||||
},
|
||||
deleteExploreTab: {
|
||||
method: "DELETE",
|
||||
operationId: API_OPERATIONS.deleteExploreTab,
|
||||
@ -771,6 +809,20 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "user:export",
|
||||
permissions: ["user:export"]
|
||||
},
|
||||
generateDiceRobots: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.generateDiceRobots,
|
||||
path: "/v1/admin/game/robots/generate",
|
||||
permission: "game:create",
|
||||
permissions: ["game:create"]
|
||||
},
|
||||
generatePrettyIds: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.generatePrettyIds,
|
||||
path: "/v1/admin/users/pretty-id-pools/{pool_id}/generate",
|
||||
permission: "pretty-id:generate",
|
||||
permissions: ["pretty-id:generate"]
|
||||
},
|
||||
getCoinSellerSalaryRates: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.getCoinSellerSalaryRates,
|
||||
@ -904,6 +956,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "weekly-star:view",
|
||||
permissions: ["weekly-star:view"]
|
||||
},
|
||||
grantPrettyId: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.grantPrettyId,
|
||||
path: "/v1/admin/users/pretty-ids/grant",
|
||||
permission: "pretty-id:grant",
|
||||
permissions: ["pretty-id:grant"]
|
||||
},
|
||||
grantResource: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.grantResource,
|
||||
@ -1014,6 +1073,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "cumulative-recharge-reward:view",
|
||||
permissions: ["cumulative-recharge-reward:view"]
|
||||
},
|
||||
listDiceRobots: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listDiceRobots,
|
||||
path: "/v1/admin/game/robots",
|
||||
permission: "game:view",
|
||||
permissions: ["game:view"]
|
||||
},
|
||||
listEmojiPackCategories: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listEmojiPackCategories,
|
||||
@ -1133,6 +1199,20 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "game:view",
|
||||
permissions: ["game:view"]
|
||||
},
|
||||
listPrettyIdPools: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listPrettyIdPools,
|
||||
path: "/v1/admin/users/pretty-id-pools",
|
||||
permission: "pretty-id:view",
|
||||
permissions: ["pretty-id:view"]
|
||||
},
|
||||
listPrettyIds: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listPrettyIds,
|
||||
path: "/v1/admin/users/pretty-ids",
|
||||
permission: "pretty-id:view",
|
||||
permissions: ["pretty-id:view"]
|
||||
},
|
||||
listRechargeBills: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listRechargeBills,
|
||||
@ -1238,6 +1318,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "room-turnover-reward:view",
|
||||
permissions: ["room-turnover-reward:view"]
|
||||
},
|
||||
listSelfGames: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listSelfGames,
|
||||
path: "/v1/admin/game/self-games",
|
||||
permission: "game:view",
|
||||
permissions: ["game:view"]
|
||||
},
|
||||
listSevenDayCheckInClaims: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listSevenDayCheckInClaims,
|
||||
@ -1273,6 +1360,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "team-salary-policy:view",
|
||||
permissions: ["team-salary-policy:view"]
|
||||
},
|
||||
listThirdPartyPaymentChannels: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listThirdPartyPaymentChannels,
|
||||
path: "/v1/admin/payment/third-party-channels",
|
||||
permission: "payment-third-party:view",
|
||||
permissions: ["payment-third-party:view"]
|
||||
},
|
||||
listUserLeaderboards: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listUserLeaderboards,
|
||||
@ -1443,6 +1537,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "coin-seller:update",
|
||||
permissions: ["coin-seller:update"]
|
||||
},
|
||||
setDiceRobotStatus: {
|
||||
method: "PATCH",
|
||||
operationId: API_OPERATIONS.setDiceRobotStatus,
|
||||
path: "/v1/admin/game/robots/{user_id}/status",
|
||||
permission: "game:update",
|
||||
permissions: ["game:update"]
|
||||
},
|
||||
setGameStatus: {
|
||||
method: "PATCH",
|
||||
operationId: API_OPERATIONS.setGameStatus,
|
||||
@ -1450,6 +1551,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "game:status",
|
||||
permissions: ["game:status"]
|
||||
},
|
||||
setPrettyIdStatus: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.setPrettyIdStatus,
|
||||
path: "/v1/admin/users/pretty-ids/{pretty_id}/status",
|
||||
permission: "pretty-id:update",
|
||||
permissions: ["pretty-id:update"]
|
||||
},
|
||||
setTaskDefinitionStatus: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.setTaskDefinitionStatus,
|
||||
@ -1457,6 +1565,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "daily-task:status",
|
||||
permissions: ["daily-task:status"]
|
||||
},
|
||||
setThirdPartyPaymentMethodStatus: {
|
||||
method: "PATCH",
|
||||
operationId: API_OPERATIONS.setThirdPartyPaymentMethodStatus,
|
||||
path: "/v1/admin/payment/third-party-methods/{method_id}/status",
|
||||
permission: "payment-third-party:update",
|
||||
permissions: ["payment-third-party:update"]
|
||||
},
|
||||
setWeeklyStarCycleStatus: {
|
||||
method: "PATCH",
|
||||
operationId: API_OPERATIONS.setWeeklyStarCycleStatus,
|
||||
@ -1520,6 +1635,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "cumulative-recharge-reward:update",
|
||||
permissions: ["cumulative-recharge-reward:update"]
|
||||
},
|
||||
updateDiceConfig: {
|
||||
method: "PATCH",
|
||||
operationId: API_OPERATIONS.updateDiceConfig,
|
||||
path: "/v1/admin/game/self-games/{game_id}/config",
|
||||
permission: "game:update",
|
||||
permissions: ["game:update"]
|
||||
},
|
||||
updateExploreTab: {
|
||||
method: "PUT",
|
||||
operationId: API_OPERATIONS.updateExploreTab,
|
||||
@ -1604,6 +1726,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "game:update",
|
||||
permissions: ["game:update"]
|
||||
},
|
||||
updatePrettyIdPool: {
|
||||
method: "PUT",
|
||||
operationId: API_OPERATIONS.updatePrettyIdPool,
|
||||
path: "/v1/admin/users/pretty-id-pools/{pool_id}",
|
||||
permission: "pretty-id:update",
|
||||
permissions: ["pretty-id:update"]
|
||||
},
|
||||
updateRechargeProduct: {
|
||||
method: "PUT",
|
||||
operationId: API_OPERATIONS.updateRechargeProduct,
|
||||
@ -1716,6 +1845,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "team-salary-policy:update",
|
||||
permissions: ["team-salary-policy:update"]
|
||||
},
|
||||
updateThirdPartyPaymentRate: {
|
||||
method: "PATCH",
|
||||
operationId: API_OPERATIONS.updateThirdPartyPaymentRate,
|
||||
path: "/v1/admin/payment/third-party-rates/{method_id}",
|
||||
permission: "payment-third-party:update",
|
||||
permissions: ["payment-third-party:update"]
|
||||
},
|
||||
updateTier: {
|
||||
method: "PUT",
|
||||
operationId: API_OPERATIONS.updateTier,
|
||||
|
||||
478
src/shared/api/generated/schema.d.ts
vendored
478
src/shared/api/generated/schema.d.ts
vendored
@ -1092,6 +1092,118 @@ export interface paths {
|
||||
patch: operations["updatePlatform"];
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/game/self-games": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["listSelfGames"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/game/self-games/{game_id}/config": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch: operations["updateDiceConfig"];
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/game/self-games/{game_id}/pool/adjust": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["adjustDicePool"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/game/robots": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["listDiceRobots"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/game/robots/generate": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["generateDiceRobots"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/game/robots/{user_id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete: operations["deleteDiceRobot"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/game/robots/{user_id}/status": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch: operations["setDiceRobotStatus"];
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/gift-types": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -1428,6 +1540,54 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/payment/third-party-channels": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["listThirdPartyPaymentChannels"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/payment/third-party-methods/{method_id}/status": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch: operations["setThirdPartyPaymentMethodStatus"];
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/payment/third-party-rates/{method_id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch: operations["updateThirdPartyPaymentRate"];
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/regions": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -2516,6 +2676,102 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/users/pretty-id-pools": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["listPrettyIdPools"];
|
||||
put?: never;
|
||||
post: operations["createPrettyIdPool"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/users/pretty-id-pools/{pool_id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put: operations["updatePrettyIdPool"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/users/pretty-id-pools/{pool_id}/generate": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["generatePrettyIds"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/users/pretty-ids": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["listPrettyIds"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/users/pretty-ids/grant": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["grantPrettyId"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/users/pretty-ids/{pretty_id}/status": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["setPrettyIdStatus"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
@ -4279,6 +4535,98 @@ export interface operations {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
listSelfGames: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
updateDiceConfig: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
game_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
adjustDicePool: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
game_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
listDiceRobots: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
generateDiceRobots: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
deleteDiceRobot: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
user_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
setDiceRobotStatus: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
user_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
listGiftTypes: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -4668,6 +5016,46 @@ export interface operations {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
listThirdPartyPaymentChannels: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
setThirdPartyPaymentMethodStatus: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
method_id: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
updateThirdPartyPaymentRate: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
method_id: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
listRegions: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -5867,4 +6255,94 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
listPrettyIdPools: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
createPrettyIdPool: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
updatePrettyIdPool: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
pool_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
generatePrettyIds: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
pool_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
listPrettyIds: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
grantPrettyId: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
setPrettyIdStatus: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
pretty_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@ -346,6 +346,7 @@ export interface RechargeProductDto {
|
||||
amountUsdt: string;
|
||||
amountUsdtMicro: number;
|
||||
appCode?: string;
|
||||
audienceType?: "normal" | "coin_seller" | string;
|
||||
channel?: string;
|
||||
coinAmount: number;
|
||||
createdAtMs?: number;
|
||||
@ -353,7 +354,7 @@ export interface RechargeProductDto {
|
||||
currencyCode?: string;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
platform: "android" | "ios" | string;
|
||||
platform: "android" | "ios" | "web" | string;
|
||||
policyVersion?: string;
|
||||
productCode?: string;
|
||||
productId: number;
|
||||
@ -368,14 +369,40 @@ export interface RechargeProductDto {
|
||||
|
||||
export interface RechargeProductPayload {
|
||||
amountUsdt: string;
|
||||
audienceType?: "normal" | "coin_seller";
|
||||
coinAmount: number;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
platform: "android" | "ios";
|
||||
platform: "android" | "ios" | "web";
|
||||
productName: string;
|
||||
regionIds: number[];
|
||||
}
|
||||
|
||||
export interface ThirdPartyPaymentMethodDto {
|
||||
appCode?: string;
|
||||
countryCode?: string;
|
||||
countryName?: string;
|
||||
currencyCode?: string;
|
||||
methodId: number;
|
||||
methodName?: string;
|
||||
payType?: string;
|
||||
payWay?: string;
|
||||
providerCode?: string;
|
||||
providerName?: string;
|
||||
status?: string;
|
||||
usdToCurrencyRate?: string;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export interface ThirdPartyPaymentChannelDto {
|
||||
appCode?: string;
|
||||
methods?: ThirdPartyPaymentMethodDto[];
|
||||
providerCode: string;
|
||||
providerName: string;
|
||||
sortOrder?: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface AppVersionDto {
|
||||
appCode?: string;
|
||||
buildNumber: number;
|
||||
@ -616,6 +643,99 @@ export interface AppUserPasswordPayload {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface PrettyDisplayIDPoolDto {
|
||||
appCode?: string;
|
||||
createdAtMs?: number;
|
||||
createdByAdminId?: EntityId;
|
||||
levelTrack: string;
|
||||
maxLevel: number;
|
||||
minLevel: number;
|
||||
name: string;
|
||||
poolId: string;
|
||||
ruleConfigJson?: string;
|
||||
ruleType: string;
|
||||
sortOrder?: number;
|
||||
status: string;
|
||||
updatedAtMs?: number;
|
||||
updatedByAdminId?: EntityId;
|
||||
}
|
||||
|
||||
export interface PrettyDisplayIDDto {
|
||||
appCode?: string;
|
||||
assignedAtMs?: number;
|
||||
assignedLeaseId?: string;
|
||||
assignedUserId?: EntityId;
|
||||
createdAtMs?: number;
|
||||
createdByAdminId?: EntityId;
|
||||
displayUserId: string;
|
||||
generatedBatchId?: string;
|
||||
pool?: PrettyDisplayIDPoolDto | null;
|
||||
poolId?: string;
|
||||
prettyId: string;
|
||||
releaseReason?: string;
|
||||
releasedAtMs?: number;
|
||||
source: string;
|
||||
status: string;
|
||||
updatedAtMs?: number;
|
||||
}
|
||||
|
||||
export interface PrettyDisplayIDGenerationBatchDto {
|
||||
appCode?: string;
|
||||
batchId: string;
|
||||
createdAtMs?: number;
|
||||
generatedCount: number;
|
||||
operatorAdminId?: EntityId;
|
||||
poolId: string;
|
||||
requestId?: string;
|
||||
requestedCount: number;
|
||||
ruleConfigJson?: string;
|
||||
ruleType: string;
|
||||
skippedConflictCount: number;
|
||||
status: string;
|
||||
updatedAtMs?: number;
|
||||
}
|
||||
|
||||
export interface PrettyDisplayIDPoolPayload {
|
||||
level_track: string;
|
||||
max_level: number;
|
||||
min_level: number;
|
||||
name: string;
|
||||
rule_config_json?: string;
|
||||
rule_type: string;
|
||||
sort_order?: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface GeneratePrettyDisplayIDsPayload {
|
||||
count: number;
|
||||
rule_config_json?: string;
|
||||
rule_type: string;
|
||||
}
|
||||
|
||||
export interface GrantPrettyDisplayIDPayload {
|
||||
display_user_id: string;
|
||||
duration_ms?: number;
|
||||
reason?: string;
|
||||
target_user_id: EntityId;
|
||||
}
|
||||
|
||||
export interface SetPrettyDisplayIDStatusPayload {
|
||||
reason?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface AdminGrantPrettyDisplayIDResultDto {
|
||||
identity?: {
|
||||
defaultDisplayUserId?: string;
|
||||
displayUserId?: string;
|
||||
prettyDisplayUserId?: string;
|
||||
prettyId?: string;
|
||||
userId?: string;
|
||||
};
|
||||
leaseId?: string;
|
||||
prettyId: string;
|
||||
}
|
||||
|
||||
export interface RoomDto {
|
||||
closeReason?: string;
|
||||
closedAtMs?: number;
|
||||
@ -806,6 +926,7 @@ export interface AppSplashScreenDto {
|
||||
coverUrl: string;
|
||||
createdAtMs?: number;
|
||||
description?: string;
|
||||
displayDurationMs?: number;
|
||||
endsAtMs?: number;
|
||||
id: number;
|
||||
param?: string;
|
||||
@ -822,6 +943,7 @@ export interface AppSplashScreenPayload {
|
||||
countryCode?: string;
|
||||
coverUrl: string;
|
||||
description?: string;
|
||||
displayDurationMs?: number;
|
||||
endsAtMs?: number;
|
||||
param?: string;
|
||||
platform: "android" | "ios";
|
||||
|
||||
@ -97,6 +97,7 @@ const paginatedIdentityKeys = [
|
||||
"permissionId",
|
||||
"groupId",
|
||||
"resourceId",
|
||||
"productId",
|
||||
"roomId",
|
||||
"giftId",
|
||||
"taskId",
|
||||
|
||||
@ -37,3 +37,12 @@ test("mergePaginatedItems can still merge entity lists by user id", () => {
|
||||
|
||||
expect(merged.map((item) => item.userId)).toEqual(["1001", "1002"]);
|
||||
});
|
||||
|
||||
test("mergePaginatedItems keeps recharge products by product id", () => {
|
||||
const merged = mergePaginatedItems(
|
||||
[{ productId: 1, productName: "普通用户档位" }],
|
||||
[{ productId: 2, productName: "币商档位" }],
|
||||
);
|
||||
|
||||
expect(merged.map((item) => item.productId)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
1
var/pretty-id-dev.pid
Normal file
1
var/pretty-id-dev.pid
Normal file
@ -0,0 +1 @@
|
||||
9736
|
||||
Loading…
x
Reference in New Issue
Block a user