admin相关

This commit is contained in:
zhx 2026-06-11 01:01:53 +08:00
parent e2d8d03260
commit 7b094f2b6f
49 changed files with 4822 additions and 393 deletions

View File

@ -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。

View File

@ -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": {

View File

@ -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");

View File

@ -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",
]);
});

View File

@ -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];

View File

@ -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",

View File

@ -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 "全部区域";

View File

@ -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),

View File

@ -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, {

View File

@ -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"]),

View File

@ -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",

View File

@ -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 返回,生成类型和页面代码使用 camelCaseRaw 类型同时接收两种形态,便于后续切换生成客户端。
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 ?? "",
};
}

View File

@ -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;
}
}

View File

@ -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);

View 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 在输入阶段已 trimkeyword 保留原值用于模糊搜索。
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,
};
}

View 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;
}

View File

@ -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),
};
}

View File

@ -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,
},
];

View File

@ -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/reservedassigned 号码必须通过释放或覆盖链路改变。
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>;

View File

@ -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),

View File

@ -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}>
取消

View File

@ -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),

View File

@ -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)}`;
}

View File

@ -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",

View File

@ -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);
}

View File

@ -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;

View 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() : "?";
}

View 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),
};
}

View 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));
}

View File

@ -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,
},
];

View File

@ -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;
}

View File

@ -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,
},
);
}

View File

@ -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) {

View 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,
};
}

View File

@ -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;

View 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);
});
}

View File

@ -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);
}
}

View File

@ -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),
};
}

View File

@ -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,
},
];

View File

@ -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, "请选择支持区域"),
});

View File

@ -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);

View File

@ -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%;

View File

@ -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: [

View File

@ -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,

View File

@ -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"];
};
};
}

View File

@ -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";

View File

@ -97,6 +97,7 @@ const paginatedIdentityKeys = [
"permissionId",
"groupId",
"resourceId",
"productId",
"roomId",
"giftId",
"taskId",

View File

@ -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
View File

@ -0,0 +1 @@
9736