From 7b094f2b6fcda9011af88999785bb041616e2ff3 Mon Sep 17 00:00:00 2001 From: zhx Date: Thu, 11 Jun 2026 01:01:53 +0800 Subject: [PATCH] =?UTF-8?q?admin=E7=9B=B8=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 4 +- contracts/admin-openapi.json | 292 ++++++++ src/app/navigation/menu.js | 46 +- src/app/navigation/menu.test.jsx | 25 +- src/app/permissions.ts | 10 + .../hooks/useSplashScreenConfigPage.js | 3 + .../pages/SplashScreenConfigPage.jsx | 22 + src/features/app-config/routes.js | 9 - src/features/app-config/schema.test.ts | 11 + src/features/app-config/schema.ts | 1 + src/features/app-users/api.test.ts | 55 +- src/features/app-users/api.ts | 301 ++++++++ src/features/app-users/app-users.module.css | 473 ++++++++----- src/features/app-users/constants.js | 105 ++- .../app-users/hooks/usePrettyIdsPage.js | 346 ++++++++++ .../app-users/pages/AppUserPrettyIdsPage.jsx | 652 ++++++++++++++++++ src/features/app-users/permissions.js | 20 +- src/features/app-users/routes.js | 8 + src/features/app-users/schema.ts | 111 ++- src/features/first-recharge-reward/api.ts | 15 + .../FirstRechargeRewardConfigDrawer.jsx | 238 ++++--- .../hooks/useFirstRechargeRewardPage.js | 50 +- .../pages/FirstRechargeRewardPage.jsx | 20 +- src/features/first-recharge-reward/routes.js | 2 +- src/features/games/api.ts | 163 +++++ src/features/games/games.module.css | 11 + src/features/games/pages/GameRobotPage.jsx | 274 ++++++++ .../games/pages/GameRobotPage.test.jsx | 62 ++ src/features/games/pages/SelfGamePage.jsx | 354 ++++++++++ src/features/games/routes.js | 16 + .../host-org/hooks/useHostAgenciesPage.js | 14 +- src/features/payment/api.ts | 41 ++ .../payment/hooks/useRechargeProductsPage.js | 71 +- .../payment/hooks/useThirdPartyPaymentPage.js | 124 ++++ .../pages/RechargeProductConfigPage.jsx | 44 ++ .../payment/pages/ThirdPartyPaymentPage.jsx | 174 +++++ src/features/payment/payment.module.css | 217 ++++++ src/features/payment/permissions.js | 2 + src/features/payment/routes.js | 19 +- src/features/payment/schema.ts | 3 +- src/features/resources/pages/GiftListPage.jsx | 22 +- src/features/resources/resources.module.css | 32 + src/features/roles/hooks/useRolesPage.js | 2 +- src/shared/api/generated/endpoints.ts | 136 ++++ src/shared/api/generated/schema.d.ts | 478 +++++++++++++ src/shared/api/types.ts | 126 +++- src/shared/hooks/usePaginatedQuery.js | 1 + src/shared/hooks/usePaginatedQuery.test.js | 9 + var/pretty-id-dev.pid | 1 + 49 files changed, 4822 insertions(+), 393 deletions(-) create mode 100644 src/features/app-users/hooks/usePrettyIdsPage.js create mode 100644 src/features/app-users/pages/AppUserPrettyIdsPage.jsx create mode 100644 src/features/games/pages/GameRobotPage.jsx create mode 100644 src/features/games/pages/GameRobotPage.test.jsx create mode 100644 src/features/games/pages/SelfGamePage.jsx create mode 100644 src/features/payment/hooks/useThirdPartyPaymentPage.js create mode 100644 src/features/payment/pages/ThirdPartyPaymentPage.jsx create mode 100644 var/pretty-id-dev.pid diff --git a/AGENTS.md b/AGENTS.md index 1e01d2c..b67e500 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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。 diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index 63ff7fe..bcb997c 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -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": { diff --git a/src/app/navigation/menu.js b/src/app/navigation/menu.js index 2638869..22c1563 100644 --- a/src/app/navigation/menu.js +++ b/src/app/navigation/menu.js @@ -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"); diff --git a/src/app/navigation/menu.test.jsx b/src/app/navigation/menu.test.jsx index 39a1d6e..e6b279f 100644 --- a/src/app/navigation/menu.test.jsx +++ b/src/app/navigation/menu.test.jsx @@ -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", ]); }); diff --git a/src/app/permissions.ts b/src/app/permissions.ts index 2c4cfce..da4b660 100644 --- a/src/app/permissions.ts +++ b/src/app/permissions.ts @@ -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]; diff --git a/src/features/app-config/hooks/useSplashScreenConfigPage.js b/src/features/app-config/hooks/useSplashScreenConfigPage.js index d0e697a..3f1c7a4 100644 --- a/src/features/app-config/hooks/useSplashScreenConfigPage.js +++ b/src/features/app-config/hooks/useSplashScreenConfigPage.js @@ -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", diff --git a/src/features/app-config/pages/SplashScreenConfigPage.jsx b/src/features/app-config/pages/SplashScreenConfigPage.jsx index 495591f..a360d18 100644 --- a/src/features/app-config/pages/SplashScreenConfigPage.jsx +++ b/src/features/app-config/pages/SplashScreenConfigPage.jsx @@ -160,6 +160,14 @@ export function SplashScreenConfigPage() { value={page.form.sortOrder} onChange={(event) => page.setForm({ sortOrder: event.target.value })} /> + page.setForm({ displayDurationMs: event.target.value })} + /> 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 "全部区域"; diff --git a/src/features/app-config/routes.js b/src/features/app-config/routes.js index 1401aa9..7b54225 100644 --- a/src/features/app-config/routes.js +++ b/src/features/app-config/routes.js @@ -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), diff --git a/src/features/app-config/schema.test.ts b/src/features/app-config/schema.test.ts index 9ddafdf..059459d 100644 --- a/src/features/app-config/schema.test.ts +++ b/src/features/app-config/schema.test.ts @@ -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, { diff --git a/src/features/app-config/schema.ts b/src/features/app-config/schema.ts index 6f6280a..9f80625 100644 --- a/src/features/app-config/schema.ts +++ b/src/features/app-config/schema.ts @@ -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"]), diff --git a/src/features/app-users/api.test.ts b/src/features/app-users/api.test.ts index e8f92cd..3ffe3c7 100644 --- a/src/features/app-users/api.test.ts +++ b/src/features/app-users/api.test.ts @@ -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", diff --git a/src/features/app-users/api.ts b/src/features/app-users/api.ts index 2f2c4ce..c37b928 100644 --- a/src/features/app-users/api.ts +++ b/src/features/app-users/api.ts @@ -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> { @@ -70,3 +78,296 @@ export function setAppUserPassword( }, ); } + +// 靓号管理接口路径全部来自生成的 OpenAPI 端点,页面层只依赖这些函数,避免手写 URL 和契约漂移。 +export async function listPrettyDisplayIDPools(query: PageQuery = {}): Promise> { + const endpoint = API_ENDPOINTS.listPrettyIdPools; + const data = await apiRequest>(apiEndpointPath(API_OPERATIONS.listPrettyIdPools), { + method: endpoint.method, + query, + }); + return normalizePage(data, normalizePrettyDisplayIDPool); +} + +export async function createPrettyDisplayIDPool(payload: PrettyDisplayIDPoolPayload): Promise { + const endpoint = API_ENDPOINTS.createPrettyIdPool; + const data = await apiRequest( + apiEndpointPath(API_OPERATIONS.createPrettyIdPool), + { + body: payload, + method: endpoint.method, + }, + ); + return normalizePrettyDisplayIDPool(data); +} + +export async function updatePrettyDisplayIDPool( + poolId: EntityId, + payload: PrettyDisplayIDPoolPayload, +): Promise { + const endpoint = API_ENDPOINTS.updatePrettyIdPool; + const data = await apiRequest( + apiEndpointPath(API_OPERATIONS.updatePrettyIdPool, { pool_id: poolId }), + { + body: payload, + method: endpoint.method, + }, + ); + return normalizePrettyDisplayIDPool(data); +} + +export async function generatePrettyDisplayIDs( + poolId: EntityId, + payload: GeneratePrettyDisplayIDsPayload, +): Promise { + const endpoint = API_ENDPOINTS.generatePrettyIds; + const data = await apiRequest( + apiEndpointPath(API_OPERATIONS.generatePrettyIds, { pool_id: poolId }), + { + body: payload, + method: endpoint.method, + }, + ); + return normalizePrettyDisplayIDGenerationBatch(data); +} + +export async function listPrettyDisplayIDs(query: PageQuery = {}): Promise> { + const endpoint = API_ENDPOINTS.listPrettyIds; + const data = await apiRequest>(apiEndpointPath(API_OPERATIONS.listPrettyIds), { + method: endpoint.method, + query, + }); + return normalizePage(data, normalizePrettyDisplayID); +} + +export async function grantPrettyDisplayID( + payload: GrantPrettyDisplayIDPayload, +): Promise { + const endpoint = API_ENDPOINTS.grantPrettyId; + const data = await apiRequest( + apiEndpointPath(API_OPERATIONS.grantPrettyId), + { + body: payload, + method: endpoint.method, + }, + ); + return normalizeAdminGrantPrettyDisplayIDResult(data); +} + +export async function setPrettyDisplayIDStatus( + prettyId: EntityId, + payload: SetPrettyDisplayIDStatusPayload, +): Promise { + const endpoint = API_ENDPOINTS.setPrettyIdStatus; + const data = await apiRequest( + apiEndpointPath(API_OPERATIONS.setPrettyIdStatus, { pretty_id: prettyId }), + { + body: payload, + method: endpoint.method, + }, + ); + return normalizePrettyDisplayID(data); +} + +// Go 后台当前按 snake_case JSON 返回,生成类型和页面代码使用 camelCase;Raw 类型同时接收两种形态,便于后续切换生成客户端。 +interface RawPrettyDisplayIDPool { + app_code?: string; + appCode?: string; + created_at_ms?: number; + createdAtMs?: number; + created_by_admin_id?: EntityId; + createdByAdminId?: EntityId; + level_track?: string; + levelTrack?: string; + max_level?: number; + maxLevel?: number; + min_level?: number; + minLevel?: number; + name?: string; + pool_id?: string; + poolId?: string; + rule_config_json?: string; + ruleConfigJson?: string; + rule_type?: string; + ruleType?: string; + sort_order?: number; + sortOrder?: number; + status?: string; + updated_at_ms?: number; + updatedAtMs?: number; + updated_by_admin_id?: EntityId; + updatedByAdminId?: EntityId; +} + +interface RawPrettyDisplayID { + app_code?: string; + appCode?: string; + assigned_at_ms?: number; + assignedAtMs?: number; + assigned_lease_id?: string; + assignedLeaseId?: string; + assigned_user_id?: EntityId; + assignedUserId?: EntityId; + created_at_ms?: number; + createdAtMs?: number; + created_by_admin_id?: EntityId; + createdByAdminId?: EntityId; + display_user_id?: string; + displayUserId?: string; + generated_batch_id?: string; + generatedBatchId?: string; + pool?: RawPrettyDisplayIDPool | null; + pool_id?: string; + poolId?: string; + pretty_id?: string; + prettyId?: string; + release_reason?: string; + releaseReason?: string; + released_at_ms?: number; + releasedAtMs?: number; + source?: string; + status?: string; + updated_at_ms?: number; + updatedAtMs?: number; +} + +interface RawPrettyDisplayIDGenerationBatch { + app_code?: string; + appCode?: string; + batch_id?: string; + batchId?: string; + created_at_ms?: number; + createdAtMs?: number; + generated_count?: number; + generatedCount?: number; + operator_admin_id?: EntityId; + operatorAdminId?: EntityId; + pool_id?: string; + poolId?: string; + request_id?: string; + requestId?: string; + requested_count?: number; + requestedCount?: number; + rule_config_json?: string; + ruleConfigJson?: string; + rule_type?: string; + ruleType?: string; + skipped_conflict_count?: number; + skippedConflictCount?: number; + status?: string; + updated_at_ms?: number; + updatedAtMs?: number; +} + +interface RawAdminGrantPrettyDisplayIDResult { + identity?: { + default_display_user_id?: string; + defaultDisplayUserId?: string; + display_user_id?: string; + displayUserId?: string; + pretty_display_user_id?: string; + prettyDisplayUserId?: string; + pretty_id?: string; + prettyId?: string; + user_id?: EntityId; + userId?: EntityId; + }; + lease_id?: string; + leaseId?: string; + pretty_id?: string; + prettyId?: string; +} + +// 分页结构也做一次兼容,page_size 来自 Go 响应,pageSize 来自前端 ApiPage 约定。 +function normalizePage( + data: ApiPage & { page_size?: number }, + normalize: (item: TRaw) => TItem, +): ApiPage { + 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 ?? "", + }; +} diff --git a/src/features/app-users/app-users.module.css b/src/features/app-users/app-users.module.css index df43051..3acdfc2 100644 --- a/src/features/app-users/app-users.module.css +++ b/src/features/app-users/app-users.module.css @@ -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; + } } diff --git a/src/features/app-users/constants.js b/src/features/app-users/constants.js index 57945ca..bbb58e7 100644 --- a/src/features/app-users/constants.js +++ b/src/features/app-users/constants.js @@ -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); diff --git a/src/features/app-users/hooks/usePrettyIdsPage.js b/src/features/app-users/hooks/usePrettyIdsPage.js new file mode 100644 index 0000000..a6fa39c --- /dev/null +++ b/src/features/app-users/hooks/usePrettyIdsPage.js @@ -0,0 +1,346 @@ +import { useMemo, useState } from "react"; +import { parseForm } from "@/shared/forms/validation"; +import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js"; +import { useToast } from "@/shared/ui/ToastProvider.jsx"; +import { + createPrettyDisplayIDPool, + generatePrettyDisplayIDs, + grantPrettyDisplayID, + listPrettyDisplayIDPools, + listPrettyDisplayIDs, + setPrettyDisplayIDStatus, + updatePrettyDisplayIDPool, +} from "@/features/app-users/api"; +import { useAppUserAbilities } from "@/features/app-users/permissions.js"; +import { + prettyDisplayIDGenerateSchema, + prettyDisplayIDGrantSchema, + prettyDisplayIDPoolSchema, + prettyDisplayIDStatusSchema, +} from "@/features/app-users/schema"; + +const pageSize = 20; +const emptyPoolForm = () => ({ + levelTrack: "wealth", + maxLevel: 10, + minLevel: 1, + name: "", + ruleConfigJson: "", + ruleType: "aabbcc", + sortOrder: 0, + status: "active", +}); +const emptyGenerateForm = () => ({ count: 100, poolId: "", ruleConfigJson: "", ruleType: "aabbcc" }); +const emptyGrantForm = () => ({ displayUserId: "", durationDays: "", reason: "", targetUserId: "" }); +const emptyStatusForm = () => ({ prettyId: "", reason: "", status: "available" }); + +export function usePrettyIdsPage() { + const abilities = useAppUserAbilities(); + const { showToast } = useToast(); + const [poolPage, setPoolPage] = useState(1); + const [poolStatus, setPoolStatus] = useState(""); + const [poolTrack, setPoolTrack] = useState(""); + const [idPage, setIdPage] = useState(1); + const [idKeyword, setIdKeyword] = useState(""); + const [idStatus, setIdStatus] = useState(""); + const [idSource, setIdSource] = useState(""); + const [idPoolId, setIdPoolId] = useState(""); + const [assignedUserId, setAssignedUserId] = useState(""); + const [activeDialog, setActiveDialog] = useState(""); + const [activePool, setActivePool] = useState(null); + const [activePrettyID, setActivePrettyID] = useState(null); + const [poolForm, setPoolForm] = useState(emptyPoolForm); + const [generateForm, setGenerateForm] = useState(emptyGenerateForm); + const [grantForm, setGrantForm] = useState(emptyGrantForm); + const [statusForm, setStatusForm] = useState(emptyStatusForm); + const [loadingAction, setLoadingAction] = useState(""); + + // 靓号池和靓号明细是两个独立分页接口,筛选条件保持服务端字段名,避免在 hook 内再做一次字段映射。 + const poolFilters = useMemo( + () => ({ + level_track: poolTrack, + status: poolStatus, + }), + [poolStatus, poolTrack], + ); + // 靓号明细筛选会直接透传到后台列表接口,assigned_user_id 在输入阶段已 trim,keyword 保留原值用于模糊搜索。 + const idFilters = useMemo( + () => ({ + assigned_user_id: assignedUserId, + keyword: idKeyword, + pool_id: idPoolId, + source: idSource, + status: idStatus, + }), + [assignedUserId, idKeyword, idPoolId, idSource, idStatus], + ); + + const poolQuery = usePaginatedQuery({ + errorMessage: "加载靓号池失败", + fetcher: listPrettyDisplayIDPools, + filters: poolFilters, + page: poolPage, + pageSize, + queryKey: ["pretty-id-pools", poolFilters, poolPage], + }); + const idQuery = usePaginatedQuery({ + errorMessage: "加载靓号列表失败", + fetcher: listPrettyDisplayIDs, + filters: idFilters, + page: idPage, + pageSize, + queryKey: ["pretty-ids", idFilters, idPage], + }); + + // 生成弹窗和明细筛选只需要当前已加载池列表;没有额外拉全量,避免页面初始化时放大后台查询。 + const poolOptions = useMemo( + () => + (poolQuery.data.items || []).map((pool) => ({ + label: `${pool.name} · ${pool.minLevel}-${pool.maxLevel}`, + value: pool.poolId, + })), + [poolQuery.data.items], + ); + + const openCreatePool = () => { + setActivePool(null); + setPoolForm(emptyPoolForm()); + setActiveDialog("pool"); + }; + + const openEditPool = (pool) => { + setActivePool(pool); + // 编辑弹窗以服务端当前值为准回填,缺失字段只兜底到首版规则,避免把旧数据误清空。 + setPoolForm({ + levelTrack: pool.levelTrack || "wealth", + maxLevel: pool.maxLevel ?? 10, + minLevel: pool.minLevel ?? 1, + name: pool.name || "", + ruleConfigJson: pool.ruleConfigJson || "", + ruleType: pool.ruleType || "aabbcc", + sortOrder: pool.sortOrder || 0, + status: pool.status || "active", + }); + setActiveDialog("pool"); + }; + + const openGenerate = (pool) => { + setActivePool(pool || null); + // 从池行进入时优先使用该池,从工具栏进入时沿用列表筛选池,减少管理员重复选择。 + setGenerateForm({ + ...emptyGenerateForm(), + poolId: pool?.poolId || idPoolId || "", + ruleConfigJson: pool?.ruleConfigJson || "", + ruleType: pool?.ruleType || "aabbcc", + }); + setActiveDialog("generate"); + }; + + const openGrant = () => { + setGrantForm(emptyGrantForm()); + setActiveDialog("grant"); + }; + + const openStatus = (item) => { + setActivePrettyID(item); + // 状态弹窗默认给出最常用的反向操作:禁用中的号码恢复可用,其余未占用号码默认禁用。 + setStatusForm({ + prettyId: item?.prettyId || "", + reason: "", + status: item?.status === "disabled" ? "available" : "disabled", + }); + setActiveDialog("status"); + }; + + const closeDialog = () => { + setActiveDialog(""); + setActivePool(null); + setActivePrettyID(null); + }; + + const submitPool = async (event) => { + event.preventDefault(); + await runAction("pool", activePool ? "靓号池已更新" : "靓号池已创建", async () => { + // 表单字段使用前端 camelCase,提交边界统一转换为后端 JSON 的 snake_case。 + const form = parseForm(prettyDisplayIDPoolSchema, poolForm); + const payload = { + level_track: form.levelTrack, + max_level: form.maxLevel, + min_level: form.minLevel, + name: form.name, + rule_config_json: form.ruleConfigJson, + rule_type: form.ruleType, + sort_order: form.sortOrder, + status: form.status, + }; + if (activePool?.poolId) { + await updatePrettyDisplayIDPool(activePool.poolId, payload); + } else { + await createPrettyDisplayIDPool(payload); + } + closeDialog(); + await reloadAll(); + }); + }; + + const submitGenerate = async (event) => { + event.preventDefault(); + await runAction("generate", "靓号已生成", async () => { + // 批量生成只让后台接收池 ID、规则和数量,冲突过滤与批次审计由 user-service 事务完成。 + const form = parseForm(prettyDisplayIDGenerateSchema, generateForm); + await generatePrettyDisplayIDs(form.poolId, { + count: form.count, + rule_config_json: form.ruleConfigJson, + rule_type: form.ruleType, + }); + closeDialog(); + await reloadAll(); + }); + }; + + const submitGrant = async (event) => { + event.preventDefault(); + await runAction("grant", "靓号已发放", async () => { + const form = parseForm(prettyDisplayIDGrantSchema, grantForm); + // 后台发放用 0 表示长期,正数天数在前端换算成毫秒,服务端只处理 duration_ms 这个统一单位。 + const days = Number(form.durationDays || 0); + await grantPrettyDisplayID({ + display_user_id: form.displayUserId, + duration_ms: days > 0 ? days * 24 * 60 * 60 * 1000 : 0, + reason: form.reason, + target_user_id: form.targetUserId, + }); + closeDialog(); + await idQuery.reload(); + }); + }; + + const submitStatus = async (event) => { + event.preventDefault(); + await runAction("status", "靓号状态已更新", async () => { + // 状态变更只允许未分配号码,前端提交 pretty_id 和目标状态,真正可变状态由后端再次校验。 + const form = parseForm(prettyDisplayIDStatusSchema, statusForm); + await setPrettyDisplayIDStatus(form.prettyId, { + reason: form.reason, + status: form.status, + }); + closeDialog(); + await idQuery.reload(); + }); + }; + + const changePoolStatus = (value) => { + setPoolStatus(value); + // 任意筛选变化都回到第一页,避免旧页码在新条件下落到空页。 + setPoolPage(1); + }; + const changePoolTrack = (value) => { + setPoolTrack(value); + setPoolPage(1); + }; + const changeIDKeyword = (value) => { + setIdKeyword(value); + // 明细列表的每个筛选项都会改变总数,页码统一回到第一页。 + setIdPage(1); + }; + const changeIDStatus = (value) => { + setIdStatus(value); + setIdPage(1); + }; + const changeIDSource = (value) => { + setIdSource(value); + setIdPage(1); + }; + const changeIDPool = (value) => { + setIdPoolId(value); + setIdPage(1); + }; + const changeAssignedUserId = (value) => { + setAssignedUserId(value.trim()); + setIdPage(1); + }; + + const resetPoolFilters = () => { + // 重置筛选后立即回到第一页,保持 URL 之外的页面状态和查询条件同步。 + setPoolStatus(""); + setPoolTrack(""); + setPoolPage(1); + }; + const resetIDFilters = () => { + // 靓号列表筛选项彼此独立,重置时一次性清空,避免残留 assigned_user_id 影响排查。 + setAssignedUserId(""); + setIdKeyword(""); + setIdPoolId(""); + setIdSource(""); + setIdStatus(""); + setIdPage(1); + }; + + const reloadAll = async () => { + // 池配置会影响生成入口和号码列表展示,池相关操作完成后两个分页一起刷新。 + await Promise.all([poolQuery.reload(), idQuery.reload()]); + }; + + const runAction = async (action, successMessage, submitter) => { + // 所有写操作共享 loading、toast 和异常展示;弹窗只在 submitter 内确认上游成功后关闭。 + setLoadingAction(action); + try { + await submitter(); + showToast(successMessage, "success"); + } catch (err) { + showToast(err.message || "操作失败", "error"); + } finally { + setLoadingAction(""); + } + }; + + return { + abilities, + activeDialog, + activePool, + activePrettyID, + assignedUserId, + changeAssignedUserId, + changeIDKeyword, + changeIDPool, + changeIDSource, + changeIDStatus, + changePoolStatus, + changePoolTrack, + closeDialog, + generateForm, + grantForm, + idKeyword, + idPage, + idPoolId, + idQuery, + idSource, + idStatus, + loadingAction, + openCreatePool, + openEditPool, + openGenerate, + openGrant, + openStatus, + poolForm, + poolOptions, + poolPage, + poolQuery, + poolStatus, + poolTrack, + reloadAll, + resetIDFilters, + resetPoolFilters, + setGenerateForm, + setGrantForm, + setIdPage, + setPoolForm, + setPoolPage, + setStatusForm, + statusForm, + submitGenerate, + submitGrant, + submitPool, + submitStatus, + }; +} diff --git a/src/features/app-users/pages/AppUserPrettyIdsPage.jsx b/src/features/app-users/pages/AppUserPrettyIdsPage.jsx new file mode 100644 index 0000000..abcc667 --- /dev/null +++ b/src/features/app-users/pages/AppUserPrettyIdsPage.jsx @@ -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 ( +
+
+
+
+ + {page.abilities.canPrettyGenerate ? ( + + ) : null} + {page.abilities.canPrettyUpdate ? ( + + ) : null} + + } + count={page.poolQuery.data.total} + title="靓号池" + /> +
+ page.changePoolTrack(event.target.value)} + > + 全部等级轨道 + {prettyLevelTrackOptions.map(([value, label]) => ( + + {label} + + ))} + + page.changePoolStatus(event.target.value)} + > + {prettyPoolStatusOptions.map(([value, label]) => ( + + {label} + + ))} + + +
+ + 0 + ? { + page: page.poolPage, + pageSize: page.poolQuery.data.pageSize || 20, + total: page.poolQuery.data.total, + onPageChange: page.setPoolPage, + } + : undefined + } + rowKey={(pool) => pool.poolId} + /> + +
+ +
+ } + variant="primary" + onClick={page.openGrant} + > + 后台发放 + + ) : null + } + count={page.idQuery.data.total} + title="靓号列表" + /> +
+ page.changeIDKeyword(event.target.value)} + /> + page.changeIDPool(event.target.value)} + > + 全部靓号池 + {page.poolOptions.map((option) => ( + + {option.label} + + ))} + + page.changeIDSource(event.target.value)} + > + {prettyIdSourceOptions.map(([value, label]) => ( + + {label} + + ))} + + page.changeIDStatus(event.target.value)} + > + {prettyIdStatusOptions.map(([value, label]) => ( + + {label} + + ))} + + page.changeAssignedUserId(event.target.value)} + /> + +
+ + 0 + ? { + page: page.idPage, + pageSize: page.idQuery.data.pageSize || 20, + total: page.idQuery.data.total, + onPageChange: page.setIdPage, + } + : undefined + } + rowKey={(item) => item.prettyId} + /> + +
+
+
+ + + + + +
+ ); +} + +function PaneHeader({ actions, count, title }) { + return ( +
+
+

{title}

+ 共 {Number(count || 0)} 条 +
+
{actions}
+
+ ); +} + +function poolColumns(page) { + return [ + { + key: "pool", + label: "靓号池", + width: "minmax(220px, 1.2fr)", + render: (pool) => ( +
+ {pool.name || "-"} + {pool.poolId} + 更新 {formatMillis(pool.updatedAtMs || pool.createdAtMs)} +
+ ), + }, + { + key: "rule", + label: "规则 / 等级", + width: "minmax(160px, 0.9fr)", + render: (pool) => ( +
+ {ruleLabel(pool.ruleType)} + + {trackLabel(pool.levelTrack)} {pool.minLevel}-{pool.maxLevel} + +
+ ), + }, + { + key: "status", + label: "状态", + width: "minmax(110px, 0.6fr)", + render: (pool) => , + }, + { + key: "actions", + label: "操作", + width: "104px", + render: (pool) => ( +
+ {page.abilities.canPrettyUpdate ? ( + page.openEditPool(pool)}> + + + ) : null} + {page.abilities.canPrettyGenerate ? ( + page.openGenerate(pool)}> + + + ) : null} +
+ ), + }, + ]; +} + +function idColumns(page) { + return [ + { + key: "display", + label: "靓号", + width: "minmax(190px, 1fr)", + render: (item) => ( +
+
+ {item.displayUserId || "-"} +
+
{item.prettyId}
+
+ ), + }, + { + key: "pool", + label: "来源 / 池", + width: "minmax(180px, 0.9fr)", + render: (item) => ( +
+ {prettyIdSourceLabels[item.source] || item.source || "-"} + {item.pool?.name || item.poolId || "-"} +
+ ), + }, + { + key: "status", + label: "状态", + width: "minmax(110px, 0.6fr)", + render: (item) => , + }, + { + key: "assigned", + label: "占用 / 时间", + width: "minmax(190px, 0.9fr)", + render: (item) => ( +
+ {formatEntityID(item.assignedUserId)} + + {item.assignedAtMs ? formatMillis(item.assignedAtMs) : formatMillis(item.createdAtMs)} + + + {item.releasedAtMs ? `释放 ${formatMillis(item.releasedAtMs)}` : "-"} + +
+ ), + }, + { + key: "actions", + label: "操作", + width: "minmax(96px, 0.5fr)", + render: (item) => + page.abilities.canPrettyUpdate && canChangePrettyStatus(item) ? ( +
+ page.openStatus(item)}> + + +
+ ) : ( + "-" + ), + }, + ]; +} + +function PoolDialog({ page }) { + const editing = Boolean(page.activePool); + return ( + + + page.setPoolForm({ ...page.poolForm, name: event.target.value })} + /> + page.setPoolForm({ ...page.poolForm, levelTrack: event.target.value })} + > + {prettyLevelTrackOptions.map(([value, label]) => ( + + {label} + + ))} + +
+ page.setPoolForm({ ...page.poolForm, minLevel: event.target.value })} + /> + page.setPoolForm({ ...page.poolForm, maxLevel: event.target.value })} + /> +
+
+ page.setPoolForm({ ...page.poolForm, ruleType: event.target.value })} + > + {prettyRuleOptions.map(([value, label]) => ( + + {label} + + ))} + + page.setPoolForm({ ...page.poolForm, status: event.target.value })} + > + {prettyPoolStatusOptions + .filter(([value]) => value) + .map(([value, label]) => ( + + {label} + + ))} + +
+ page.setPoolForm({ ...page.poolForm, sortOrder: event.target.value })} + /> + page.setPoolForm({ ...page.poolForm, ruleConfigJson: event.target.value })} + /> +
+
+ ); +} + +function GenerateDialog({ page }) { + return ( + + + page.setGenerateForm({ ...page.generateForm, poolId: event.target.value })} + > + {page.poolOptions.map((option) => ( + + {option.label} + + ))} + + page.setGenerateForm({ ...page.generateForm, ruleType: event.target.value })} + > + {prettyRuleOptions.map(([value, label]) => ( + + {label} + + ))} + + page.setGenerateForm({ ...page.generateForm, count: event.target.value })} + /> + + page.setGenerateForm({ ...page.generateForm, ruleConfigJson: event.target.value }) + } + /> + + + ); +} + +function GrantDialog({ page }) { + return ( + + + page.setGrantForm({ ...page.grantForm, targetUserId: event.target.value })} + /> + page.setGrantForm({ ...page.grantForm, displayUserId: event.target.value })} + /> + page.setGrantForm({ ...page.grantForm, durationDays: event.target.value })} + /> + page.setGrantForm({ ...page.grantForm, reason: event.target.value })} + /> + + + ); +} + +function StatusDialog({ page }) { + return ( + + + + page.setStatusForm({ ...page.statusForm, status: event.target.value })} + > + {prettyIdStatusOptions + .filter(([value]) => ["available", "disabled", "reserved"].includes(value)) + .map(([value, label]) => ( + + {label} + + ))} + + page.setStatusForm({ ...page.statusForm, reason: event.target.value })} + /> + + + ); +} + +function ActionIcon({ children, label, onClick }) { + return ( + + + + {children} + + + + ); +} + +function StatusBadge({ labels, status }) { + return ( + + + {labels[status] || status || "-"} + + ); +} + +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; +} diff --git a/src/features/app-users/permissions.js b/src/features/app-users/permissions.js index eea5498..504073a 100644 --- a/src/features/app-users/permissions.js +++ b/src/features/app-users/permissions.js @@ -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), + }; } diff --git a/src/features/app-users/routes.js b/src/features/app-users/routes.js index e750f33..aa915f9 100644 --- a/src/features/app-users/routes.js +++ b/src/features/app-users/routes.js @@ -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, + }, ]; diff --git a/src/features/app-users/schema.ts b/src/features/app-users/schema.ts index 9da6adb..22eb697 100644 --- a/src/features/app-users/schema.ts +++ b/src/features/app-users/schema.ts @@ -1,19 +1,120 @@ import { z } from "zod"; export const appUserUpdateSchema = z.object({ - avatar: z.string().trim().max(512, "头像不能超过 512 个字符").optional().default(""), - gender: z.string().trim().max(32, "性别不能超过 32 个字符").optional().default(""), - username: z.string().trim().max(64, "用户名称不能超过 64 个字符").optional().default("") + avatar: z.string().trim().max(512, "头像不能超过 512 个字符").optional().default(""), + gender: z.string().trim().max(32, "性别不能超过 32 个字符").optional().default(""), + username: z.string().trim().max(64, "用户名称不能超过 64 个字符").optional().default(""), }); export const appUserCountrySchema = z.object({ - country: z.string().trim().min(2, "请选择国家").max(16, "国家码不能超过 16 个字符") + country: z.string().trim().min(2, "请选择国家").max(16, "国家码不能超过 16 个字符"), }); export const appUserPasswordSchema = z.object({ - password: z.string().trim().min(6, "密码至少 6 位").max(128, "密码不能超过 128 个字符") + password: z.string().trim().min(6, "密码至少 6 位").max(128, "密码不能超过 128 个字符"), +}); + +// 与服务端后台发放规则保持一致:只允许 Unicode 字母、数字和组合标记,空格、标点、emoji 都在前端先拦截。 +const prettyContentPattern = /^[\p{L}\p{N}\p{M}]+$/u; +const prettyRuleValues = [ + "aa", + "aaa", + "aaaa", + "aaaaa", + "aaaaaa", + "aaaaaaa", + "aabb", + "aabbcc", + "aabbccdd", + "abab", + "ababab", + "abba", + "abcba", + "abccba", + "aaab", + "aaaab", + "aaaaab", + "aaabbb", + "aaaabbbb", + "abc", + "abcd", + "abcde", + "abcdef", + "cba", + "dcba", + "edcba", + "fedcba", + "abcabc", + "date_yyyymmdd", + "date_yymmdd", + "love", + "custom_values", + "custom_pattern", +] as const; + +// 靓号池只配置等级区间和生成规则,等级上下限在表单层先做顺序校验,后端仍会再次校验权限和状态。 +export const prettyDisplayIDPoolSchema = z + .object({ + levelTrack: z.enum(["wealth", "game", "charm"], "请选择等级轨道"), + maxLevel: z.coerce.number().int("最高等级必须是整数").min(0, "最高等级不能小于 0"), + minLevel: z.coerce.number().int("最低等级必须是整数").min(0, "最低等级不能小于 0"), + name: z.string().trim().min(1, "请输入靓号池名称").max(128, "靓号池名称不能超过 128 个字符"), + ruleConfigJson: z.string().trim().max(4000, "规则配置不能超过 4000 个字符").optional().default(""), + ruleType: z.enum(prettyRuleValues, "请选择生成规则"), + sortOrder: z.coerce.number().int("排序必须是整数").min(0, "排序不能小于 0").optional().default(0), + status: z.enum(["active", "disabled"], "请选择状态"), + }) + .refine((value) => value.maxLevel >= value.minLevel, { + message: "最高等级不能小于最低等级", + path: ["maxLevel"], + }); + +// 批量生成支持市面常见靓号模式,单次最多 1000 个,避免一次生成把候选空间和数据库锁时间放大。 +export const prettyDisplayIDGenerateSchema = z.object({ + count: z.coerce.number().int("生成数量必须是整数").min(1, "生成数量至少 1 个").max(1000, "单次最多生成 1000 个"), + poolId: z.string().trim().min(1, "请选择靓号池"), + ruleConfigJson: z.string().trim().max(4000, "规则配置不能超过 4000 个字符").optional().default(""), + ruleType: z.enum(prettyRuleValues, "请选择生成规则"), +}); + +// 后台发放允许管理员输入非数字靓号;durationDays 为空或 0 都会转成 duration_ms=0,表示长期持有。 +export const prettyDisplayIDGrantSchema = z.object({ + displayUserId: z + .string() + .trim() + .min(1, "请输入靓号内容") + .max(64, "靓号不能超过 64 个字符") + .regex(prettyContentPattern, "靓号只支持字母、数字和组合标记"), + durationDays: z + .string() + .trim() + .optional() + .default("") + .refine((value) => { + if (!value) { + return true; + } + const days = Number(value); + return Number.isInteger(days) && days >= 0 && days <= 36500; + }, "有效天数必须是 0 到 36500 的整数"), + reason: z.string().trim().max(64, "原因不能超过 64 个字符").optional().default(""), + targetUserId: z + .string() + .trim() + .regex(/^[1-9]\d*$/, "请输入用户 ID"), +}); + +// 状态变更只暴露未占用号码的 available/disabled/reserved,assigned 号码必须通过释放或覆盖链路改变。 +export const prettyDisplayIDStatusSchema = z.object({ + prettyId: z.string().trim().min(1, "请选择靓号"), + reason: z.string().trim().max(64, "原因不能超过 64 个字符").optional().default(""), + status: z.enum(["available", "disabled", "reserved"], "请选择状态"), }); export type AppUserUpdateForm = z.infer; export type AppUserCountryForm = z.infer; export type AppUserPasswordForm = z.infer; +export type PrettyDisplayIDPoolForm = z.infer; +export type PrettyDisplayIDGenerateForm = z.infer; +export type PrettyDisplayIDGrantForm = z.infer; +export type PrettyDisplayIDStatusForm = z.infer; diff --git a/src/features/first-recharge-reward/api.ts b/src/features/first-recharge-reward/api.ts index 7be6640..84eda8a 100644 --- a/src/features/first-recharge-reward/api.ts +++ b/src/features/first-recharge-reward/api.ts @@ -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), diff --git a/src/features/first-recharge-reward/components/FirstRechargeRewardConfigDrawer.jsx b/src/features/first-recharge-reward/components/FirstRechargeRewardConfigDrawer.jsx index 0d525e0..6b15526 100644 --- a/src/features/first-recharge-reward/components/FirstRechargeRewardConfigDrawer.jsx +++ b/src/features/first-recharge-reward/components/FirstRechargeRewardConfigDrawer.jsx @@ -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 ( -
-

首冲奖励配置

-
-
发放状态
- setForm((current) => ({ ...current, enabled: event.target.checked }))} - /> -
-
-
- 奖励档位 - -
-
- {(form.tiers || []).map((tier, index) => ( -
-
- 档位 {index + 1} - removeTier(index)} - > - - -
-
- updateTier(index, { tierCode: event.target.value })} - /> - updateTier(index, { tierName: event.target.value })} - /> - updateTier(index, { minCoinAmount: event.target.value })} - /> - updateTier(index, { maxCoinAmount: event.target.value })} - /> - updateTier(index, { resourceGroupId: value })} - /> - updateTier(index, { status: event.target.value })} - > - {statusOptions.map(([value, label]) => ( - - {label} - - ))} - - updateTier(index, { sortOrder: event.target.value })} - /> -
-
- ))} - {!form.tiers?.length ?
暂无档位
: null} -
-
+ label="首充礼包状态" + uncheckedLabel="关闭" + onChange={(event) => setForm((current) => ({ ...current, enabled: event.target.checked }))} + /> + +
+
+ 奖励档位 + +
+ ( + 档位 {index + 1} + ), + }, + { + key: "usdAmount", + label: "USD", + render: (tier, index) => ( + updateTier(index, { usdAmount: event.target.value })} + /> + ), + }, + { + key: "googleProductId", + label: "Google 商品 ID", + render: (tier, index) => ( + + updateTier(index, { googleProductId: event.target.value }) + } + /> + ), + }, + { + key: "resourceGroupId", + label: "奖励资源组", + render: (tier, index) => ( + updateTier(index, { resourceGroupId: value })} + /> + ), + }, + { + key: "discountPercent", + label: "优惠%", + render: (tier, index) => ( + + updateTier(index, { discountPercent: event.target.value }) + } + /> + ), + }, + { + key: "status", + label: "状态", + render: (tier, index) => ( + + 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} + /> +
+
+ } + /> + + +
+
+ {page.channels.map((channel) => ( + + ))} +
+ +
+ {page.activeChannel ? ( + groupedMethods.map((group) => ( +
+
+ {group.title} + {group.methods.length} 个支付方式 +
+
+
+ 支付方式 + 类型 + 币种 + USD 到本币汇率 + 状态 +
+ {group.methods.map((method) => ( +
+ + {method.methodName || method.payWay || "-"} + {method.payWay || "-"} + + {method.payType || "-"} + {method.currencyCode || "-"} + + + page.setRateDraft(method.methodId, event.target.value) + } + /> + + + + + page.toggleMethod(method, event.target.checked) + } + /> + +
+ ))} +
+
+ )) + ) : ( +
当前无第三方支付渠道
+ )} +
+
+
+
+ + ); +} + +function StatusPill({ active }) { + return {active ? "已启用" : "已停用"}; +} + +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); + }); +} diff --git a/src/features/payment/payment.module.css b/src/features/payment/payment.module.css index a1702fd..39c7a0b 100644 --- a/src/features/payment/payment.module.css +++ b/src/features/payment/payment.module.css @@ -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); + } +} diff --git a/src/features/payment/permissions.js b/src/features/payment/permissions.js index 1c8344d..f2f7646 100644 --- a/src/features/payment/permissions.js +++ b/src/features/payment/permissions.js @@ -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), }; } diff --git a/src/features/payment/routes.js b/src/features/payment/routes.js index 5e61de8..9f90c87 100644 --- a/src/features/payment/routes.js +++ b/src/features/payment/routes.js @@ -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, + }, ]; diff --git a/src/features/payment/schema.ts b/src/features/payment/schema.ts index 35b714b..758c57f 100644 --- a/src/features/payment/schema.ts +++ b/src/features/payment/schema.ts @@ -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, "请选择支持区域"), }); diff --git a/src/features/resources/pages/GiftListPage.jsx b/src/features/resources/pages/GiftListPage.jsx index e04582f..7e08d28 100644 --- a/src/features/resources/pages/GiftListPage.jsx +++ b/src/features/resources/pages/GiftListPage.jsx @@ -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) => (
{giftEffectDetails(gift).map((item, index) => ( - 0 ? styles.meta : undefined} key={item}> + 0 + ? styles.meta + : undefined + } + key={item} + > {item} ))} @@ -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 (
{preview ? : } + {globalBroadcast ? ( + + + + ) : null}
{title} @@ -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); diff --git a/src/features/resources/resources.module.css b/src/features/resources/resources.module.css index ae72989..662ddcb 100644 --- a/src/features/resources/resources.module.css +++ b/src/features/resources/resources.module.css @@ -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%; diff --git a/src/features/roles/hooks/useRolesPage.js b/src/features/roles/hooks/useRolesPage.js index 055fece..d9e06a8 100644 --- a/src/features/roles/hooks/useRolesPage.js +++ b/src/features/roles/hooks/useRolesPage.js @@ -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: [ diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index 91f9810..1dcb329 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -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 = { + 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index dff9a0d..3c30c69 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -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; 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"]; + }; + }; } diff --git a/src/shared/api/types.ts b/src/shared/api/types.ts index e324c8e..dace89d 100644 --- a/src/shared/api/types.ts +++ b/src/shared/api/types.ts @@ -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"; diff --git a/src/shared/hooks/usePaginatedQuery.js b/src/shared/hooks/usePaginatedQuery.js index bc78880..f6e3819 100644 --- a/src/shared/hooks/usePaginatedQuery.js +++ b/src/shared/hooks/usePaginatedQuery.js @@ -97,6 +97,7 @@ const paginatedIdentityKeys = [ "permissionId", "groupId", "resourceId", + "productId", "roomId", "giftId", "taskId", diff --git a/src/shared/hooks/usePaginatedQuery.test.js b/src/shared/hooks/usePaginatedQuery.test.js index a006548..6410036 100644 --- a/src/shared/hooks/usePaginatedQuery.test.js +++ b/src/shared/hooks/usePaginatedQuery.test.js @@ -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]); +}); diff --git a/var/pretty-id-dev.pid b/var/pretty-id-dev.pid new file mode 100644 index 0000000..e95ec61 --- /dev/null +++ b/var/pretty-id-dev.pid @@ -0,0 +1 @@ +9736