Compare commits

...

14 Commits

Author SHA1 Message Date
zhx
103b70f5e5 任务 2026-06-11 18:40:23 +08:00
zhx
7a6f0db859 邀请 2026-06-11 18:17:16 +08:00
zhx
53b74f9dc2 feat room rocket reward quantity 2026-06-11 18:07:57 +08:00
zhx
ebb4545ad6 礼物选择 2026-06-11 17:20:54 +08:00
zhx
7d6418e43c 新增功能 2026-06-11 17:11:04 +08:00
zhx
54fdc867fe 任务,用户头像显示相关 2026-06-11 13:33:30 +08:00
zhx
7b094f2b6f admin相关 2026-06-11 01:01:53 +08:00
zhx
e2d8d03260 修复游戏桥接bug 2026-06-10 10:57:22 +08:00
zhx
0b3589a701 开屏配置 2026-06-09 22:08:57 +08:00
zhx
e423adc738 修复mp4解析问题 2026-06-09 15:58:55 +08:00
zhx
7740d611b6 去掉游戏sha256 2026-06-09 15:13:03 +08:00
zhx
4e56597adb 批量重算 2026-06-09 13:53:21 +08:00
zhx
fcd44f8c13 批量重算 2026-06-09 13:41:26 +08:00
zhx
8a6e9dc18d 礼物布局 2026-06-09 13:14:11 +08:00
135 changed files with 16153 additions and 3278 deletions

View File

@ -17,6 +17,8 @@
13. 左侧菜单 active 样式优先级必须高于 hover 样式。
14. 侧边栏展开/收起按钮必须跟随状态切换图标。
15. 展开态 Logo 区域和侧边栏宽度保持 248px。
16. 所有逻辑功能配置默认打开;只有密钥、地址、三方账号等必须由环境提供的敏感值使用占位符或环境配置,不在默认配置里伪造可用凭证。
17. 所有业务逻辑、状态流转、权限判断、支付链路、异步流程都保持高密度逻辑注释,注释解释为什么这样处理和失败分支,不复述语法。
## 当前实现方式
@ -97,7 +99,7 @@ import { Refresh } from "@mui/icons-material";
- 区域编辑弹窗必须同时包含区域基础字段和国家码字段;国家码使用可搜索多选下拉框,不再拆成独立“区域国家”弹窗。
- 头像、图片、封面等图片资源字段必须使用 `src/shared/ui/UploadField.jsx` 上传表单组件,不使用普通文本输入框承载 URL。
- 图片上传表单必须提供统一预览;`webp` 使用浏览器原生动效预览,`svga` 使用 SVGA 播放器预览,`pag` 使用 libpag + wasm 预览。
- BD Leader 创建弹窗必须使用区域下拉选择 `regionId`;目标用户原区域可以不同,创建成功后后端会把目标用户当前区域同步到所选区域
- BD Leader 创建弹窗不选择区域、不提交 `regionId`;创建结果按目标用户当前区域生成,若区域不对,先修复用户国家/区域映射,再创建 BD Leader
- 区域创建和区域启用/禁用调用后台管理后端接口,不要求 user-service 新增对应 RPC。
- 国家创建和国家启用/禁用调用后台管理后端接口,不要求 user-service 新增对应 RPC。

View File

@ -90,7 +90,7 @@
"name": "task_id",
"required": true,
"schema": {
"type": "integer"
"type": "string"
}
}
],
@ -112,7 +112,7 @@
"name": "task_id",
"required": true,
"schema": {
"type": "integer"
"type": "string"
}
}
],
@ -270,6 +270,18 @@
"x-permissions": ["first-recharge-reward:view"]
}
},
"/admin/activity/invite-reward/claims": {
"get": {
"operationId": "listInviteActivityRewardClaims",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "invite-activity-reward:view",
"x-permissions": ["invite-activity-reward:view"]
}
},
"/admin/activity/cumulative-recharge-reward/grants": {
"get": {
"operationId": "listCumulativeRechargeRewardGrants",
@ -488,6 +500,28 @@
"x-permissions": ["cumulative-recharge-reward:update"]
}
},
"/admin/activity/invite-reward/config": {
"get": {
"operationId": "getInviteActivityRewardConfig",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "invite-activity-reward:view",
"x-permissions": ["invite-activity-reward:view"]
},
"put": {
"operationId": "updateInviteActivityRewardConfig",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "invite-activity-reward:update",
"x-permissions": ["invite-activity-reward:update"]
}
},
"/admin/activity/room-turnover-reward/config": {
"get": {
"operationId": "getRoomTurnoverRewardConfig",
@ -760,6 +794,134 @@
"x-permissions": ["app-config:update"]
}
},
"/admin/app-config/splash-screens": {
"get": {
"operationId": "listSplashScreens",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "app-config:view",
"x-permissions": ["app-config:view"]
},
"post": {
"operationId": "createSplashScreen",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "app-config:update",
"x-permissions": ["app-config:update"]
}
},
"/admin/app-config/splash-screens/{splash_id}": {
"put": {
"operationId": "updateSplashScreen",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "splash_id",
"required": true,
"schema": {
"type": "integer"
}
}
],
"x-permission": "app-config:update",
"x-permissions": ["app-config:update"]
},
"delete": {
"operationId": "deleteSplashScreen",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "splash_id",
"required": true,
"schema": {
"type": "integer"
}
}
],
"x-permission": "app-config:update",
"x-permissions": ["app-config:update"]
}
},
"/admin/app-config/popups": {
"get": {
"operationId": "listPopups",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "app-config:view",
"x-permissions": ["app-config:view"]
},
"post": {
"operationId": "createPopup",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "app-config:update",
"x-permissions": ["app-config:update"]
}
},
"/admin/app-config/popups/{popup_id}": {
"put": {
"operationId": "updatePopup",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "popup_id",
"required": true,
"schema": {
"type": "integer"
}
}
],
"x-permission": "app-config:update",
"x-permissions": ["app-config:update"]
},
"delete": {
"operationId": "deletePopup",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "popup_id",
"required": true,
"schema": {
"type": "integer"
}
}
],
"x-permission": "app-config:update",
"x-permissions": ["app-config:update"]
}
},
"/admin/app-config/h5-links": {
"get": {
"operationId": "listH5Links",
@ -1543,6 +1705,230 @@
"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/game/room-rps/config": {
"get": {
"operationId": "getRoomRpsConfig",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "game:view",
"x-permissions": ["game:view"]
},
"patch": {
"operationId": "updateRoomRpsConfig",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "game:update",
"x-permissions": ["game:update"]
}
},
"/admin/game/room-rps/challenges": {
"get": {
"operationId": "listRoomRpsChallenges",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "game:view",
"x-permissions": ["game:view"]
}
},
"/admin/game/room-rps/challenges/{challenge_id}": {
"get": {
"operationId": "getRoomRpsChallenge",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "challenge_id",
"required": true,
"schema": {
"type": "string"
}
}
],
"x-permission": "game:view",
"x-permissions": ["game:view"]
}
},
"/admin/game/room-rps/challenges/{challenge_id}/retry-settlement": {
"post": {
"operationId": "retryRoomRpsSettlement",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "challenge_id",
"required": true,
"schema": {
"type": "string"
}
}
],
"x-permission": "game:update",
"x-permissions": ["game:update"]
}
},
"/admin/game/room-rps/challenges/{challenge_id}/expire": {
"post": {
"operationId": "expireRoomRpsChallenge",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "challenge_id",
"required": true,
"schema": {
"type": "string"
}
}
],
"x-permission": "game:update",
"x-permissions": ["game:update"]
}
},
"/admin/gift-types": {
"get": {
"operationId": "listGiftTypes",
@ -1629,6 +2015,26 @@
],
"x-permission": "gift:update",
"x-permissions": ["gift:update"]
},
"delete": {
"operationId": "deleteGift",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "gift_id",
"required": true,
"schema": {
"type": "string"
}
}
],
"x-permission": "gift:delete",
"x-permissions": ["gift:delete"]
}
},
"/admin/gifts/{gift_id}/disable": {
@ -2015,6 +2421,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",
@ -3457,6 +3919,118 @@
},
"x-permissions": ["user:export"]
}
},
"/admin/users/pretty-id-pools": {
"get": {
"operationId": "listPrettyIdPools",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "pretty-id:view",
"x-permissions": ["pretty-id:view"]
},
"post": {
"operationId": "createPrettyIdPool",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "pretty-id:update",
"x-permissions": ["pretty-id:update"]
}
},
"/admin/users/pretty-id-pools/{pool_id}": {
"put": {
"operationId": "updatePrettyIdPool",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"name": "pool_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"x-permission": "pretty-id:update",
"x-permissions": ["pretty-id:update"]
}
},
"/admin/users/pretty-id-pools/{pool_id}/generate": {
"post": {
"operationId": "generatePrettyIds",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"name": "pool_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"x-permission": "pretty-id:generate",
"x-permissions": ["pretty-id:generate"]
}
},
"/admin/users/pretty-ids": {
"get": {
"operationId": "listPrettyIds",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "pretty-id:view",
"x-permissions": ["pretty-id:view"]
}
},
"/admin/users/pretty-ids/grant": {
"post": {
"operationId": "grantPrettyId",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "pretty-id:grant",
"x-permissions": ["pretty-id:grant"]
}
},
"/admin/users/pretty-ids/{pretty_id}/status": {
"post": {
"operationId": "setPrettyIdStatus",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"name": "pretty_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"x-permission": "pretty-id:update",
"x-permissions": ["pretty-id:update"]
}
}
},
"components": {

View File

@ -15,6 +15,7 @@ import HubOutlined from "@mui/icons-material/HubOutlined";
import ImageOutlined from "@mui/icons-material/ImageOutlined";
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
import LeaderboardOutlined from "@mui/icons-material/LeaderboardOutlined";
import LocalOfferOutlined from "@mui/icons-material/LocalOfferOutlined";
import LoginOutlined from "@mui/icons-material/LoginOutlined";
import ManageAccountsOutlined from "@mui/icons-material/ManageAccountsOutlined";
import MapOutlined from "@mui/icons-material/MapOutlined";
@ -22,6 +23,7 @@ import MenuOpenOutlined from "@mui/icons-material/MenuOpenOutlined";
import PublicOutlined from "@mui/icons-material/PublicOutlined";
import PushPinOutlined from "@mui/icons-material/PushPinOutlined";
import PaidOutlined from "@mui/icons-material/PaidOutlined";
import PersonAddAltOutlined from "@mui/icons-material/PersonAddAltOutlined";
import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined";
import RedeemOutlined from "@mui/icons-material/RedeemOutlined";
import SendOutlined from "@mui/icons-material/SendOutlined";
@ -63,6 +65,7 @@ const iconMap = {
menu: MenuOpenOutlined,
operations: ReceiptLongOutlined,
paid: PaidOutlined,
person_add: PersonAddAltOutlined,
public: PublicOutlined,
push_pin: PushPinOutlined,
receipt: ReceiptLongOutlined,
@ -74,6 +77,7 @@ const iconMap = {
sports_esports: SportsEsportsOutlined,
star: StarBorderOutlined,
storefront: StorefrontOutlined,
tag: LocalOfferOutlined,
task: TaskAltOutlined,
users: ManageAccountsOutlined,
wallet: WalletOutlined,
@ -108,6 +112,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 }),
],
},
@ -146,8 +151,9 @@ export const fallbackNavigation = [
children: [
routeNavItem("app-config-h5", { icon: SettingsApplicationsOutlined }),
routeNavItem("app-config-banners", { icon: ImageOutlined }),
routeNavItem("app-config-splash-screens", { icon: ImageOutlined }),
routeNavItem("app-config-popups", { icon: ImageOutlined }),
routeNavItem("app-config-explore", { icon: MapOutlined }),
routeNavItem("payment-recharge-products", { icon: WalletOutlined }),
routeNavItem("app-config-versions", { icon: SettingsApplicationsOutlined }),
],
},
@ -184,7 +190,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",
@ -196,6 +206,7 @@ export const fallbackNavigation = [
routeNavItem("registration-reward", { icon: CardGiftcardOutlined }),
routeNavItem("first-recharge-reward", { icon: CardGiftcardOutlined }),
routeNavItem("cumulative-recharge-reward", { icon: WorkspacePremiumOutlined }),
routeNavItem("invite-activity-reward", { icon: PersonAddAltOutlined }),
routeNavItem("seven-day-checkin", { icon: EventAvailableOutlined }),
routeNavItem("room-rocket", { icon: RedeemOutlined }),
routeNavItem("room-turnover-reward", { icon: PaidOutlined }),
@ -211,7 +222,13 @@ 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 }),
routeNavItem("room-rps-config", { icon: SettingsApplicationsOutlined }),
routeNavItem("room-rps-challenges", { icon: HistoryOutlined }),
],
},
{
code: "geo",
@ -262,7 +279,7 @@ export function mapBackendMenus(menus = []) {
}
function relocateBackendMenus(menus = []) {
return relocateRechargeProductsToAppConfig(relocateLogMenusToSystem(menus));
return relocateLogMenusToSystem(menus);
}
function orderTopLevelMenus(menus = []) {
@ -276,34 +293,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");
@ -357,7 +346,19 @@ export function mergeNavigationItems(primaryItems = [], fallbackItems = []) {
}
});
return merged;
return orderNavigationItemsByFallback(merged, fallbackItems);
}
function orderNavigationItemsByFallback(items = [], fallbackItems = []) {
const fallbackOrder = new Map(fallbackItems.map((item, index) => [item.code, index]));
return items
.map((item, index) => ({ index, item }))
.sort((left, right) => {
const leftOrder = fallbackOrder.get(left.item.code) ?? Number.MAX_SAFE_INTEGER;
const rightOrder = fallbackOrder.get(right.item.code) ?? Number.MAX_SAFE_INTEGER;
return leftOrder === rightOrder ? left.index - right.index : leftOrder - rightOrder;
})
.map(({ item }) => item);
}
export function findNavItemByPath(pathname, items = fallbackNavigation) {

View File

@ -84,7 +84,7 @@ describe("navigation menu helpers", () => {
expect(flattenCodes(merged)).toContain("host-org-managers");
});
test("places recharge products under app config even when backend menu is stale", () => {
test("keeps payment children under payment menu when backend menu is current", () => {
const mapped = mapBackendMenus([
{ children: [], code: "app-config", icon: "settings", id: "app-config", label: "APP配置" },
{
@ -97,6 +97,14 @@ describe("navigation menu helpers", () => {
path: "/payment/recharge-products",
permissionCode: "payment-product:view",
},
{
code: "payment-third-party",
icon: "wallet",
id: "payment-third-party",
label: "第三方支付",
path: "/payment/third-party",
permissionCode: "payment-third-party:view",
},
],
code: "payment",
icon: "wallet",
@ -107,11 +115,61 @@ 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", () => {
const backendMenus = [
{
children: [
{ code: "app-config-h5", id: "app-config-h5", label: "H5配置", path: "/app-config/h5" },
{
code: "app-config-banners",
id: "app-config-banners",
label: "BANNER配置",
path: "/app-config/banners",
},
{
code: "app-config-explore",
id: "app-config-explore",
label: "Explore配置",
path: "/app-config/explore",
},
{
code: "app-config-popups",
id: "app-config-popups",
label: "弹窗配置",
path: "/app-config/popups",
},
{
code: "app-config-versions",
id: "app-config-versions",
label: "版本管理",
path: "/app-config/versions",
},
],
code: "app-config",
icon: "settings",
id: "app-config",
label: "APP配置",
},
];
const fallbackMenus = filterNavigationByPermission(fallbackNavigation, (code) => code === "app-config:view");
const merged = mergeNavigationItems(mapBackendMenus(backendMenus), fallbackMenus);
const appConfig = merged.find((item) => item.code === "app-config");
expect(appConfig.children.map((item) => item.code)).toEqual([
"app-config-h5",
"app-config-banners",
"app-config-splash-screens",
"app-config-popups",
"app-config-explore",
"app-config-versions",
]);
});
});

View File

@ -50,6 +50,8 @@ export const PERMISSIONS = {
paymentProductCreate: "payment-product:create",
paymentProductUpdate: "payment-product:update",
paymentProductDelete: "payment-product:delete",
paymentThirdPartyView: "payment-third-party:view",
paymentThirdPartyUpdate: "payment-third-party:update",
gameView: "game:view",
gameCreate: "game:create",
gameUpdate: "game:update",
@ -79,6 +81,10 @@ export const PERMISSIONS = {
appUserUpdate: "app-user:update",
appUserStatus: "app-user:status",
appUserPassword: "app-user:password",
prettyIdView: "pretty-id:view",
prettyIdUpdate: "pretty-id:update",
prettyIdGenerate: "pretty-id:generate",
prettyIdGrant: "pretty-id:grant",
levelConfigView: "level-config:view",
levelConfigUpdate: "level-config:update",
regionBlockView: "region-block:view",
@ -111,6 +117,7 @@ export const PERMISSIONS = {
giftCreate: "gift:create",
giftUpdate: "gift:update",
giftStatus: "gift:status",
giftDelete: "gift:delete",
emojiPackView: "emoji-pack:view",
emojiPackCreate: "emoji-pack:create",
dailyTaskView: "daily-task:view",
@ -126,6 +133,8 @@ export const PERMISSIONS = {
firstRechargeRewardUpdate: "first-recharge-reward:update",
cumulativeRechargeRewardView: "cumulative-recharge-reward:view",
cumulativeRechargeRewardUpdate: "cumulative-recharge-reward:update",
inviteActivityRewardView: "invite-activity-reward:view",
inviteActivityRewardUpdate: "invite-activity-reward:update",
sevenDayCheckInView: "seven-day-checkin:view",
sevenDayCheckInUpdate: "seven-day-checkin:update",
luckyGiftView: "lucky-gift:view",
@ -169,6 +178,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",
@ -176,6 +186,8 @@ export const MENU_CODES = {
appConfig: "app-config",
appConfigH5: "app-config-h5",
appConfigBanners: "app-config-banners",
appConfigSplashScreens: "app-config-splash-screens",
appConfigPopups: "app-config-popups",
appConfigExplore: "app-config-explore",
appConfigVersions: "app-config-versions",
resources: "resources",
@ -197,6 +209,7 @@ export const MENU_CODES = {
registrationReward: "registration-reward",
firstRechargeReward: "first-recharge-reward",
cumulativeRechargeReward: "cumulative-recharge-reward",
inviteActivityReward: "invite-activity-reward",
achievementConfig: "achievement-config",
sevenDayCheckIn: "seven-day-checkin",
roomRocket: "room-rocket",
@ -221,8 +234,13 @@ 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",
roomRpsConfig: "room-rps-config",
roomRpsChallenges: "room-rps-challenges",
} as const;
export type MenuCode = (typeof MENU_CODES)[keyof typeof MENU_CODES];

View File

@ -6,6 +6,7 @@ import { ConfirmProvider } from "@/shared/ui/ConfirmProvider.jsx";
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
import { QueryProvider } from "@/shared/query/QueryProvider.jsx";
import { RefreshProvider } from "@/shared/hooks/useRefreshSignal.js";
import { AppUserDetailProvider } from "@/features/app-users/components/AppUserDetailProvider.jsx";
import { theme } from "@/theme.js";
export function AppProviders({ children }) {
@ -17,7 +18,9 @@ export function AppProviders({ children }) {
<RefreshProvider>
<AuthProvider>
<AppScopeProvider>
<TimeZoneProvider>{children}</TimeZoneProvider>
<TimeZoneProvider>
<AppUserDetailProvider>{children}</AppUserDetailProvider>
</TimeZoneProvider>
</AppScopeProvider>
</AuthProvider>
</RefreshProvider>

View File

@ -5,36 +5,48 @@ import { PageLoadBoundary } from "@/shared/ui/PageLoadBoundary.jsx";
const pageCache = new Map();
export function DeferredPage({ route }) {
const [Page, setPage] = useState(() => pageCache.get(route.pageKey) || null);
const [Page, setPage] = useState(() => pageCache.get(route.pageKey) || null);
useEffect(() => {
let mounted = true;
useEffect(() => {
let mounted = true;
if (pageCache.has(route.pageKey)) {
setPage(() => pageCache.get(route.pageKey));
return undefined;
if (pageCache.has(route.pageKey)) {
setPage(() => pageCache.get(route.pageKey));
return undefined;
}
setPage(null);
route
.loader()
.then((component) => {
pageCache.set(route.pageKey, component);
if (mounted) {
setPage(() => component);
}
})
.catch((error) => {
if (mounted) {
setPage(
() =>
function DeferredPageLoaderError() {
throw error;
},
);
}
});
return () => {
mounted = false;
};
}, [route]);
if (!Page) {
return <PageSkeleton />;
}
setPage(null);
route.loader().then((component) => {
pageCache.set(route.pageKey, component);
if (mounted) {
setPage(() => component);
}
});
return () => {
mounted = false;
};
}, [route]);
if (!Page) {
return <PageSkeleton />;
}
return (
<PageLoadBoundary pageKey={route.pageKey}>
<Page {...(route.props || {})} />
</PageLoadBoundary>
);
return (
<PageLoadBoundary pageKey={route.pageKey}>
<Page {...(route.props || {})} />
</PageLoadBoundary>
);
}

View File

@ -10,6 +10,7 @@ import { firstRechargeRewardRoutes } from "@/features/first-recharge-reward/rout
import { gameRoutes } from "@/features/games/routes.js";
import { hostAgencyPolicyRoutes } from "@/features/host-agency-policy/routes.js";
import { hostOrgRoutes } from "@/features/host-org/routes.js";
import { inviteActivityRewardRoutes } from "@/features/invite-activity-reward/routes.js";
import { levelConfigRoutes } from "@/features/level-config/routes.js";
import { logsRoutes } from "@/features/logs/routes.js";
import { luckyGiftRoutes } from "@/features/lucky-gift/routes.js";
@ -46,6 +47,7 @@ export const adminRoutes: AdminRoute[] = [
...registrationRewardRoutes,
...firstRechargeRewardRoutes,
...cumulativeRechargeRewardRoutes,
...inviteActivityRewardRoutes,
...sevenDayCheckInRoutes,
...roomRocketRoutes,
...roomTurnoverRewardRoutes,

View File

@ -4,6 +4,10 @@ import type {
ApiList,
AppBannerDto,
AppBannerPayload,
AppPopupDto,
AppPopupPayload,
AppSplashScreenDto,
AppSplashScreenPayload,
AppVersionDto,
AppVersionPayload,
EntityId,
@ -18,23 +22,26 @@ import type {
export function listH5Links(): Promise<ApiList<H5LinkConfigDto>> {
const endpoint = API_ENDPOINTS.listH5Links;
return apiRequest<ApiList<H5LinkConfigDto>>(apiEndpointPath(API_OPERATIONS.listH5Links), {
method: endpoint.method
method: endpoint.method,
});
}
export function updateH5Links(payload: H5LinkConfigUpdatePayload): Promise<ApiList<H5LinkConfigDto>> {
const endpoint = API_ENDPOINTS.updateH5Links;
return apiRequest<ApiList<H5LinkConfigDto>, H5LinkConfigUpdatePayload>(apiEndpointPath(API_OPERATIONS.updateH5Links), {
body: payload,
method: endpoint.method
});
return apiRequest<ApiList<H5LinkConfigDto>, H5LinkConfigUpdatePayload>(
apiEndpointPath(API_OPERATIONS.updateH5Links),
{
body: payload,
method: endpoint.method,
},
);
}
export function createH5Link(payload: H5LinkConfigPayload): Promise<H5LinkConfigDto> {
const endpoint = API_ENDPOINTS.createH5Link;
return apiRequest<H5LinkConfigDto, H5LinkConfigPayload>(apiEndpointPath(API_OPERATIONS.createH5Link), {
body: payload,
method: endpoint.method
method: endpoint.method,
});
}
@ -42,14 +49,14 @@ export function updateH5Link(key: EntityId, payload: H5LinkConfigPayload): Promi
const endpoint = API_ENDPOINTS.updateH5Link;
return apiRequest<H5LinkConfigDto, H5LinkConfigPayload>(apiEndpointPath(API_OPERATIONS.updateH5Link, { key }), {
body: payload,
method: endpoint.method
method: endpoint.method,
});
}
export function deleteH5Link(key: EntityId): Promise<{ deleted: boolean }> {
const endpoint = API_ENDPOINTS.deleteH5Link;
return apiRequest<{ deleted: boolean }>(apiEndpointPath(API_OPERATIONS.deleteH5Link, { key }), {
method: endpoint.method
method: endpoint.method,
});
}
@ -57,7 +64,7 @@ export function listExploreTabs(query: PageQuery = {}): Promise<ApiList<ExploreT
const endpoint = API_ENDPOINTS.listExploreTabs;
return apiRequest<ApiList<ExploreTabDto>>(apiEndpointPath(API_OPERATIONS.listExploreTabs), {
method: endpoint.method,
query
query,
});
}
@ -65,22 +72,25 @@ export function createExploreTab(payload: ExploreTabPayload): Promise<ExploreTab
const endpoint = API_ENDPOINTS.createExploreTab;
return apiRequest<ExploreTabDto, ExploreTabPayload>(apiEndpointPath(API_OPERATIONS.createExploreTab), {
body: payload,
method: endpoint.method
method: endpoint.method,
});
}
export function updateExploreTab(tabId: EntityId, payload: ExploreTabPayload): Promise<ExploreTabDto> {
const endpoint = API_ENDPOINTS.updateExploreTab;
return apiRequest<ExploreTabDto, ExploreTabPayload>(apiEndpointPath(API_OPERATIONS.updateExploreTab, { tab_id: tabId }), {
body: payload,
method: endpoint.method
});
return apiRequest<ExploreTabDto, ExploreTabPayload>(
apiEndpointPath(API_OPERATIONS.updateExploreTab, { tab_id: tabId }),
{
body: payload,
method: endpoint.method,
},
);
}
export function deleteExploreTab(tabId: EntityId): Promise<{ deleted: boolean }> {
const endpoint = API_ENDPOINTS.deleteExploreTab;
return apiRequest<{ deleted: boolean }>(apiEndpointPath(API_OPERATIONS.deleteExploreTab, { tab_id: tabId }), {
method: endpoint.method
method: endpoint.method,
});
}
@ -88,7 +98,7 @@ export function listBanners(query: PageQuery = {}): Promise<ApiList<AppBannerDto
const endpoint = API_ENDPOINTS.listBanners;
return apiRequest<ApiList<AppBannerDto>>(apiEndpointPath(API_OPERATIONS.listBanners), {
method: endpoint.method,
query
query,
});
}
@ -96,22 +106,96 @@ export function createBanner(payload: AppBannerPayload): Promise<AppBannerDto> {
const endpoint = API_ENDPOINTS.createBanner;
return apiRequest<AppBannerDto, AppBannerPayload>(apiEndpointPath(API_OPERATIONS.createBanner), {
body: payload,
method: endpoint.method
method: endpoint.method,
});
}
export function updateBanner(bannerId: EntityId, payload: AppBannerPayload): Promise<AppBannerDto> {
const endpoint = API_ENDPOINTS.updateBanner;
return apiRequest<AppBannerDto, AppBannerPayload>(apiEndpointPath(API_OPERATIONS.updateBanner, { banner_id: bannerId }), {
body: payload,
method: endpoint.method
});
return apiRequest<AppBannerDto, AppBannerPayload>(
apiEndpointPath(API_OPERATIONS.updateBanner, { banner_id: bannerId }),
{
body: payload,
method: endpoint.method,
},
);
}
export function deleteBanner(bannerId: EntityId): Promise<{ deleted: boolean }> {
const endpoint = API_ENDPOINTS.deleteBanner;
return apiRequest<{ deleted: boolean }>(apiEndpointPath(API_OPERATIONS.deleteBanner, { banner_id: bannerId }), {
method: endpoint.method
method: endpoint.method,
});
}
export function listSplashScreens(query: PageQuery = {}): Promise<ApiList<AppSplashScreenDto>> {
const endpoint = API_ENDPOINTS.listSplashScreens;
return apiRequest<ApiList<AppSplashScreenDto>>(apiEndpointPath(API_OPERATIONS.listSplashScreens), {
method: endpoint.method,
query,
});
}
export function createSplashScreen(payload: AppSplashScreenPayload): Promise<AppSplashScreenDto> {
const endpoint = API_ENDPOINTS.createSplashScreen;
return apiRequest<AppSplashScreenDto, AppSplashScreenPayload>(apiEndpointPath(API_OPERATIONS.createSplashScreen), {
body: payload,
method: endpoint.method,
});
}
export function updateSplashScreen(splashId: EntityId, payload: AppSplashScreenPayload): Promise<AppSplashScreenDto> {
const endpoint = API_ENDPOINTS.updateSplashScreen;
return apiRequest<AppSplashScreenDto, AppSplashScreenPayload>(
apiEndpointPath(API_OPERATIONS.updateSplashScreen, { splash_id: splashId }),
{
body: payload,
method: endpoint.method,
},
);
}
export function deleteSplashScreen(splashId: EntityId): Promise<{ deleted: boolean }> {
const endpoint = API_ENDPOINTS.deleteSplashScreen;
return apiRequest<{ deleted: boolean }>(
apiEndpointPath(API_OPERATIONS.deleteSplashScreen, { splash_id: splashId }),
{
method: endpoint.method,
},
);
}
export function listPopups(query: PageQuery = {}): Promise<ApiList<AppPopupDto>> {
const endpoint = API_ENDPOINTS.listPopups;
return apiRequest<ApiList<AppPopupDto>>(apiEndpointPath(API_OPERATIONS.listPopups), {
method: endpoint.method,
query,
});
}
export function createPopup(payload: AppPopupPayload): Promise<AppPopupDto> {
const endpoint = API_ENDPOINTS.createPopup;
return apiRequest<AppPopupDto, AppPopupPayload>(apiEndpointPath(API_OPERATIONS.createPopup), {
body: payload,
method: endpoint.method,
});
}
export function updatePopup(popupId: EntityId, payload: AppPopupPayload): Promise<AppPopupDto> {
const endpoint = API_ENDPOINTS.updatePopup;
return apiRequest<AppPopupDto, AppPopupPayload>(
apiEndpointPath(API_OPERATIONS.updatePopup, { popup_id: popupId }),
{
body: payload,
method: endpoint.method,
},
);
}
export function deletePopup(popupId: EntityId): Promise<{ deleted: boolean }> {
const endpoint = API_ENDPOINTS.deletePopup;
return apiRequest<{ deleted: boolean }>(apiEndpointPath(API_OPERATIONS.deletePopup, { popup_id: popupId }), {
method: endpoint.method,
});
}
@ -119,7 +203,7 @@ export function listAppVersions(query: PageQuery = {}): Promise<ApiList<AppVersi
const endpoint = API_ENDPOINTS.listAppVersions;
return apiRequest<ApiList<AppVersionDto>>(apiEndpointPath(API_OPERATIONS.listAppVersions), {
method: endpoint.method,
query
query,
});
}
@ -127,7 +211,7 @@ export function createAppVersion(payload: AppVersionPayload): Promise<AppVersion
const endpoint = API_ENDPOINTS.createAppVersion;
return apiRequest<AppVersionDto, AppVersionPayload>(apiEndpointPath(API_OPERATIONS.createAppVersion), {
body: payload,
method: endpoint.method
method: endpoint.method,
});
}
@ -137,14 +221,17 @@ export function updateAppVersion(versionId: EntityId, payload: AppVersionPayload
apiEndpointPath(API_OPERATIONS.updateAppVersion, { version_id: versionId }),
{
body: payload,
method: endpoint.method
}
method: endpoint.method,
},
);
}
export function deleteAppVersion(versionId: EntityId): Promise<{ deleted: boolean }> {
const endpoint = API_ENDPOINTS.deleteAppVersion;
return apiRequest<{ deleted: boolean }>(apiEndpointPath(API_OPERATIONS.deleteAppVersion, { version_id: versionId }), {
method: endpoint.method
});
return apiRequest<{ deleted: boolean }>(
apiEndpointPath(API_OPERATIONS.deleteAppVersion, { version_id: versionId }),
{
method: endpoint.method,
},
);
}

View File

@ -24,11 +24,153 @@
object-fit: cover;
}
.popupCover,
.popupCoverEmpty {
display: inline-flex;
width: 72px;
height: 72px;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-card-strong);
color: var(--text-tertiary);
}
.popupCover {
object-fit: cover;
}
.splashCover,
.splashCoverEmpty {
display: inline-flex;
width: 54px;
height: 96px;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-card-strong);
color: var(--text-tertiary);
}
.splashCover {
object-fit: cover;
}
.formWideField {
grid-column: 1 / -1;
}
.splashFormLayout {
display: grid;
width: 100%;
grid-template-columns: minmax(220px, 260px) minmax(0, 1fr);
align-items: start;
gap: var(--space-5);
}
.splashAssetPane,
.splashFieldPane {
display: grid;
min-width: 0;
gap: var(--space-4);
}
.splashAssetPane {
padding-right: var(--space-1);
}
.splashFieldPane {
padding-left: var(--space-1);
}
.splashUploadField {
width: min(100%, 260px);
justify-self: center;
}
.splashUploadField .splashUploadPreview {
height: auto;
aspect-ratio: 9 / 16;
max-height: min(52vh, 420px);
}
.splashStatusField {
align-self: center;
}
.popupFormLayout {
display: grid;
width: 100%;
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
align-items: start;
gap: var(--space-5);
}
.popupAssetPane,
.popupFieldPane {
display: grid;
min-width: 0;
gap: var(--space-4);
}
.popupAssetPane {
padding-right: var(--space-1);
}
.popupFieldPane {
padding-left: var(--space-1);
}
.popupUploadField {
width: min(100%, 280px);
justify-self: center;
}
.popupUploadField .popupUploadPreview {
aspect-ratio: 1 / 1;
height: auto;
max-height: min(42vh, 300px);
}
.statusCell {
display: inline-flex;
align-items: center;
}
@media (max-width: 760px) {
.splashFormLayout {
grid-template-columns: 1fr;
gap: var(--space-4);
}
.splashAssetPane,
.splashFieldPane {
padding-right: 0;
padding-left: 0;
}
.splashUploadField {
justify-self: stretch;
width: min(100%, 260px);
}
.popupFormLayout {
grid-template-columns: 1fr;
gap: var(--space-4);
}
.popupAssetPane,
.popupFieldPane {
padding-right: 0;
padding-left: 0;
}
.popupUploadField {
justify-self: stretch;
width: min(100%, 280px);
}
}

View File

@ -0,0 +1,174 @@
import { useCallback, useMemo, useState } from "react";
import { createPopup, deletePopup, listPopups, updatePopup } from "@/features/app-config/api";
import { useAppConfigAbilities } from "@/features/app-config/permissions.js";
import { appPopupSchema } from "@/features/app-config/schema";
import { parseForm } from "@/shared/forms/validation";
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
const emptyData = { items: [], total: 0 };
const emptyForm = () => ({
code: "",
displayPeriodDays: "0",
endsAtMs: "",
imageUrl: "",
jumpType: "h5",
jumpUrl: "",
name: "",
sortOrder: "0",
startsAtMs: "",
status: "active",
});
export function usePopupConfigPage() {
const abilities = useAppConfigAbilities();
const confirm = useConfirm();
const { showToast } = useToast();
const [keyword, setKeyword] = useState("");
const [status, setStatus] = useState("");
const [activeAction, setActiveAction] = useState("");
const [editingItem, setEditingItem] = useState(null);
const [form, setFormState] = useState(emptyForm);
const [loadingAction, setLoadingAction] = useState("");
const filters = useMemo(
() => ({
keyword,
status,
}),
[keyword, status],
);
const queryFn = useCallback(() => listPopups(filters), [filters]);
const {
data = emptyData,
error,
loading,
reload,
} = useAdminQuery(queryFn, {
errorMessage: "加载弹窗配置失败",
initialData: emptyData,
queryKey: ["app-config", "popups", filters],
});
const setForm = (patch) => {
setFormState((current) => ({ ...current, ...patch }));
};
const openCreate = () => {
setEditingItem(null);
setFormState(emptyForm());
setActiveAction("create");
};
const openEdit = (item) => {
setEditingItem(item);
setFormState(formFromPopup(item));
setActiveAction("edit");
};
const closeDialog = () => {
setActiveAction("");
setEditingItem(null);
setFormState(emptyForm());
};
const resetFilters = () => {
setKeyword("");
setStatus("");
};
const submitPopup = async (event) => {
event.preventDefault();
const payload = parseForm(appPopupSchema, normalizeForm(form));
const action = editingItem ? "edit" : "create";
setLoadingAction(action);
try {
if (editingItem) {
await updatePopup(editingItem.id, payload);
showToast("弹窗配置已更新", "success");
} else {
await createPopup(payload);
showToast("弹窗配置已新增", "success");
}
closeDialog();
await reload();
} catch (err) {
showToast(err.message || "操作失败", "error");
} finally {
setLoadingAction("");
}
};
const removePopup = async (item) => {
const ok = await confirm({
confirmText: "删除",
message: item.name || item.jumpUrl || `弹窗配置 ${item.id}`,
title: "删除弹窗配置",
tone: "danger",
});
if (!ok) {
return;
}
setLoadingAction(`delete:${item.id}`);
try {
await deletePopup(item.id);
await reload();
showToast("弹窗配置已删除", "success");
} catch (err) {
showToast(err.message || "删除失败", "error");
} finally {
setLoadingAction("");
}
};
return {
abilities,
activeAction,
closeDialog,
data,
editingItem,
error,
form,
keyword,
loading,
loadingAction,
openCreate,
openEdit,
reload,
removePopup,
resetFilters,
setForm,
setKeyword,
setStatus,
status,
submitPopup,
};
}
function normalizeForm(form) {
return {
...form,
displayPeriodDays: form.displayPeriodDays || 0,
endsAtMs: form.endsAtMs || 0,
imageUrl: form.imageUrl || "",
sortOrder: form.sortOrder || 0,
startsAtMs: form.startsAtMs || 0,
};
}
function formFromPopup(item) {
return {
code: item.code || "",
displayPeriodDays: String(item.displayPeriodDays ?? 0),
endsAtMs: item.endsAtMs || "",
imageUrl: item.imageUrl || "",
jumpType: item.jumpType || "h5",
jumpUrl: item.jumpUrl || "",
name: item.name || "",
sortOrder: String(item.sortOrder || 0),
startsAtMs: item.startsAtMs || "",
status: item.status || "active",
};
}

View File

@ -0,0 +1,207 @@
import { useCallback, useMemo, useState } from "react";
import {
createSplashScreen,
deleteSplashScreen,
listSplashScreens,
updateSplashScreen,
} from "@/features/app-config/api";
import { useAppConfigAbilities } from "@/features/app-config/permissions.js";
import { appSplashScreenSchema } from "@/features/app-config/schema";
import { useCountryOptions } from "@/features/host-org/hooks/useCountryOptions.js";
import { parseForm } from "@/shared/forms/validation";
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
const emptyData = { items: [], total: 0 };
const emptyForm = () => ({
countryCode: "",
coverUrl: "",
description: "",
displayDurationMs: "3000",
endsAtMs: "",
param: "",
platform: "android",
regionId: "",
sortOrder: "0",
splashType: "h5",
startsAtMs: "",
status: "active",
});
export function useSplashScreenConfigPage() {
const abilities = useAppConfigAbilities();
const confirm = useConfirm();
const { showToast } = useToast();
const { countryOptions, loadingCountries } = useCountryOptions();
const { loadingRegions, regionOptions } = useRegionOptions();
const [keyword, setKeyword] = useState("");
const [status, setStatus] = useState("");
const [platform, setPlatform] = useState("");
const [regionId, setRegionId] = useState("");
const [countryCode, setCountryCode] = useState("");
const [activeAction, setActiveAction] = useState("");
const [editingItem, setEditingItem] = useState(null);
const [form, setFormState] = useState(emptyForm);
const [loadingAction, setLoadingAction] = useState("");
const filters = useMemo(
() => ({
countryCode,
keyword,
platform,
regionId,
status,
}),
[countryCode, keyword, platform, regionId, status],
);
const queryFn = useCallback(() => listSplashScreens(filters), [filters]);
const {
data = emptyData,
error,
loading,
reload,
} = useAdminQuery(queryFn, {
errorMessage: "加载开屏配置失败",
initialData: emptyData,
queryKey: ["app-config", "splash-screens", filters],
});
const setForm = (patch) => {
setFormState((current) => ({ ...current, ...patch }));
};
const openCreate = () => {
setEditingItem(null);
setFormState(emptyForm());
setActiveAction("create");
};
const openEdit = (item) => {
setEditingItem(item);
setFormState(formFromSplashScreen(item));
setActiveAction("edit");
};
const closeDialog = () => {
setActiveAction("");
setEditingItem(null);
setFormState(emptyForm());
};
const resetFilters = () => {
setKeyword("");
setStatus("");
setPlatform("");
setRegionId("");
setCountryCode("");
};
const submitSplashScreen = async (event) => {
event.preventDefault();
const payload = parseForm(appSplashScreenSchema, normalizeForm(form));
const action = editingItem ? "edit" : "create";
setLoadingAction(action);
try {
if (editingItem) {
await updateSplashScreen(editingItem.id, payload);
showToast("开屏配置已更新", "success");
} else {
await createSplashScreen(payload);
showToast("开屏配置已新增", "success");
}
closeDialog();
await reload();
} catch (err) {
showToast(err.message || "操作失败", "error");
} finally {
setLoadingAction("");
}
};
const removeSplashScreen = async (item) => {
const ok = await confirm({
confirmText: "删除",
message: item.description || item.param || `开屏配置 ${item.id}`,
title: "删除开屏配置",
tone: "danger",
});
if (!ok) {
return;
}
setLoadingAction(`delete:${item.id}`);
try {
await deleteSplashScreen(item.id);
await reload();
showToast("开屏配置已删除", "success");
} catch (err) {
showToast(err.message || "删除失败", "error");
} finally {
setLoadingAction("");
}
};
return {
abilities,
activeAction,
closeDialog,
countryCode,
countryOptions,
data,
editingItem,
error,
form,
keyword,
loading,
loadingAction,
loadingCountries,
loadingRegions,
openCreate,
openEdit,
platform,
regionId,
regionOptions,
reload,
removeSplashScreen,
resetFilters,
setCountryCode,
setForm,
setKeyword,
setPlatform,
setRegionId,
setStatus,
status,
submitSplashScreen,
};
}
function normalizeForm(form) {
return {
...form,
countryCode: form.countryCode || "",
displayDurationMs: form.displayDurationMs || 3000,
endsAtMs: form.endsAtMs || 0,
regionId: form.regionId || 0,
sortOrder: form.sortOrder || 0,
startsAtMs: form.startsAtMs || 0,
};
}
function formFromSplashScreen(item) {
return {
countryCode: item.countryCode || "",
coverUrl: item.coverUrl || "",
description: item.description || "",
displayDurationMs: String(item.displayDurationMs || 3000),
endsAtMs: item.endsAtMs || "",
param: item.param || "",
platform: item.platform || "android",
regionId: item.regionId ? String(item.regionId) : "",
sortOrder: String(item.sortOrder || 0),
splashType: item.splashType || "h5",
startsAtMs: item.startsAtMs || "",
status: item.status || "active",
};
}

View File

@ -27,6 +27,7 @@ import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
import { RegionSelect } from "@/shared/ui/RegionSelect.jsx";
import { AppJumpConfigField } from "@/shared/ui/AppJumpConfigField.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
import { UploadField } from "@/shared/ui/UploadField.jsx";
@ -116,17 +117,6 @@ export function BannerConfigPage() {
</AdminFormSection>
<AdminFormSection title="跳转信息">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<TextField
disabled={!page.abilities.canUpdate}
label="类型"
required
select
value={page.form.bannerType}
onChange={(event) => page.setForm({ bannerType: event.target.value })}
>
<MenuItem value="h5">H5</MenuItem>
<MenuItem value="app">APP</MenuItem>
</TextField>
<TextField
disabled={!page.abilities.canUpdate}
label="平台"
@ -138,13 +128,16 @@ export function BannerConfigPage() {
<MenuItem value="android">安卓</MenuItem>
<MenuItem value="ios">iOS</MenuItem>
</TextField>
<TextField
<AppJumpConfigField
appParamLabel="参数APP公共跳转JSON"
className={styles.formWideField}
disabled={!page.abilities.canUpdate}
label={page.form.bannerType === "h5" ? "参数H5链接" : "参数"}
required={page.form.bannerType === "h5"}
value={page.form.param}
onChange={(event) => page.setForm({ param: event.target.value })}
h5Label="参数H5链接"
paramValue={page.form.param}
required
typeLabel="类型"
typeValue={page.form.bannerType}
onChange={({ param, type }) => page.setForm({ bannerType: type, param })}
/>
</AdminFormFieldGrid>
</AdminFormSection>

View File

@ -0,0 +1,326 @@
import AddOutlined from "@mui/icons-material/AddOutlined";
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import EditOutlined from "@mui/icons-material/EditOutlined";
import ImageOutlined from "@mui/icons-material/ImageOutlined";
import TextField from "@mui/material/TextField";
import { useMemo } from "react";
import styles from "@/features/app-config/app-config.module.css";
import { usePopupConfigPage } from "@/features/app-config/hooks/usePopupConfigPage.js";
import {
AdminFormDialog,
AdminFormFieldGrid,
AdminFormSection,
AdminFormSwitchField,
} from "@/shared/ui/AdminFormDialog.jsx";
import {
AdminActionIconButton,
AdminListBody,
AdminListPage,
AdminListToolbar,
AdminRowActions,
} from "@/shared/ui/AdminListLayout.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { AppJumpConfigField } from "@/shared/ui/AppJumpConfigField.jsx";
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
import { UploadField } from "@/shared/ui/UploadField.jsx";
import { formatMillis } from "@/shared/utils/time.js";
const statusOptions = [
["", "全部状态"],
["active", "启用"],
["disabled", "关闭"],
["expired", "过期"],
];
export function PopupConfigPage() {
const page = usePopupConfigPage();
const items = page.data.items || [];
const columns = useMemo(() => popupColumns(page), [page]);
return (
<AdminListPage>
<AdminListToolbar
actions={
page.abilities.canUpdate ? (
<AdminActionIconButton label="新增弹窗" primary onClick={page.openCreate}>
<AddOutlined fontSize="small" />
</AdminActionIconButton>
) : null
}
/>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<AdminListBody>
<DataTable
columns={columns}
items={items}
minWidth="1180px"
pagination={{
itemCount: items.length,
page: 1,
pageSize: items.length || 1,
total: page.data.total ?? items.length,
}}
rowKey={(item) => item.id}
/>
</AdminListBody>
</DataState>
<AdminFormDialog
loading={page.loadingAction === "create" || page.loadingAction === "edit"}
open={Boolean(page.activeAction)}
size="large"
submitDisabled={
!page.abilities.canUpdate || page.loadingAction === "create" || page.loadingAction === "edit"
}
title={page.editingItem ? "编辑弹窗配置" : "新增弹窗配置"}
onClose={page.closeDialog}
onSubmit={page.submitPopup}
>
<div className={styles.popupFormLayout}>
<div className={styles.popupAssetPane}>
<AdminFormSection title="图片">
<UploadField
className={styles.popupUploadField}
disabled={!page.abilities.canUpdate || !page.abilities.canUpload}
label="弹窗图片"
previewClassName={styles.popupUploadPreview}
value={page.form.imageUrl}
onChange={(imageUrl) => page.setForm({ imageUrl })}
/>
</AdminFormSection>
</div>
<div className={styles.popupFieldPane}>
<AdminFormSection title="基础信息">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<TextField
className={styles.formWideField}
disabled={!page.abilities.canUpdate}
label="弹窗code"
required
value={page.form.code}
onChange={(event) => page.setForm({ code: event.target.value })}
/>
<TextField
className={styles.formWideField}
disabled={!page.abilities.canUpdate}
label="弹窗名字"
required
value={page.form.name}
onChange={(event) => page.setForm({ name: event.target.value })}
/>
<TextField
disabled={!page.abilities.canUpdate}
label="展示周期(天)"
slotProps={{ htmlInput: { min: 0, step: 1 } }}
type="number"
value={page.form.displayPeriodDays}
onChange={(event) => page.setForm({ displayPeriodDays: event.target.value })}
/>
<AppJumpConfigField
appParamLabel="跳转地址APP公共跳转JSON"
className={styles.formWideField}
disabled={!page.abilities.canUpdate}
h5Label="跳转地址H5链接"
paramValue={page.form.jumpUrl}
required
typeLabel="跳转类型"
typeValue={page.form.jumpType}
onChange={({ param, type }) => page.setForm({ jumpType: type, jumpUrl: param })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="生效设置">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<TextField
disabled={!page.abilities.canUpdate}
label="排序"
slotProps={{ htmlInput: { step: 1 } }}
type="number"
value={page.form.sortOrder}
onChange={(event) => page.setForm({ sortOrder: event.target.value })}
/>
<AdminFormSwitchField
checked={page.form.status === "active"}
checkedLabel="启用"
disabled={!page.abilities.canUpdate}
label="弹窗状态"
switchProps={{ inputProps: { "aria-label": "弹窗启用状态" } }}
uncheckedLabel={page.form.status === "expired" ? "过期" : "关闭"}
onChange={(checked) => page.setForm({ status: checked ? "active" : "disabled" })}
/>
<TimeRangeFilter
className={styles.formWideField}
disabled={!page.abilities.canUpdate}
label="生效时间区间"
value={{ endMs: page.form.endsAtMs, startMs: page.form.startsAtMs }}
onChange={(range) =>
page.setForm({ endsAtMs: range.endMs, startsAtMs: range.startMs })
}
/>
</AdminFormFieldGrid>
</AdminFormSection>
</div>
</div>
</AdminFormDialog>
</AdminListPage>
);
}
function popupColumns(page) {
const columns = [
{
key: "image",
label: "图片",
render: (item) => <PopupImage src={item.imageUrl} />,
width: "minmax(96px, 0.45fr)",
},
{
key: "name",
label: "弹窗名字",
render: (item) => <Stack primary={item.name || "-"} secondary={item.code || "-"} />,
filter: createTextColumnFilter({
placeholder: "搜索code、名字、跳转地址",
value: page.keyword,
onChange: page.setKeyword,
}),
width: "minmax(200px, 0.9fr)",
},
{
key: "jump",
label: "跳转",
render: (item) => <Stack primary={typeLabel(item.jumpType)} secondary={item.jumpUrl || "-"} />,
width: "minmax(260px, 1.25fr)",
},
{
key: "displayPeriodDays",
label: "展示周期",
render: (item) => periodLabel(item.displayPeriodDays),
width: "minmax(110px, 0.55fr)",
},
{
key: "status",
label: "状态",
render: (item) => <StatusBadge status={item.status} />,
filter: createOptionsColumnFilter({
options: statusOptions,
placeholder: "搜索状态",
value: page.status,
onChange: page.setStatus,
}),
width: "minmax(100px, 0.5fr)",
},
{
key: "sortOrder",
label: "排序",
width: "minmax(80px, 0.4fr)",
},
{
key: "effectiveTime",
label: "生效时间",
render: (item) => effectiveTimeLabel(item.startsAtMs, item.endsAtMs),
width: "minmax(220px, 1fr)",
},
{
key: "updatedAtMs",
label: "更新时间",
render: (item) => formatMillis(item.updatedAtMs),
width: "minmax(160px, 0.75fr)",
},
];
if (!page.abilities.canUpdate) {
return columns;
}
return [
...columns,
{
key: "actions",
label: "操作",
render: (item) => <PopupActions item={item} page={page} />,
width: "minmax(96px, 0.45fr)",
},
];
}
function PopupImage({ src }) {
if (!src) {
return (
<span className={styles.popupCoverEmpty}>
<ImageOutlined fontSize="small" />
</span>
);
}
return <img alt="" className={styles.popupCover} src={src} />;
}
function PopupActions({ item, page }) {
const deleting = page.loadingAction === `delete:${item.id}`;
return (
<AdminRowActions>
<AdminActionIconButton
disabled={Boolean(page.loadingAction)}
label="编辑"
onClick={() => page.openEdit(item)}
>
<EditOutlined fontSize="small" />
</AdminActionIconButton>
<AdminActionIconButton
disabled={deleting || Boolean(page.loadingAction)}
label="删除"
onClick={() => page.removePopup(item)}
>
<DeleteOutlineOutlined fontSize="small" />
</AdminActionIconButton>
</AdminRowActions>
);
}
function Stack({ primary, secondary }) {
return (
<div className="cell-stack">
<span>{primary || "-"}</span>
<span className="muted">{secondary || "-"}</span>
</div>
);
}
function StatusBadge({ status }) {
const tone = status === "active" ? "succeeded" : status === "expired" ? "warning" : "stopped";
return (
<span className={`status-badge status-badge--${tone}`}>
<span className="status-point" />
{statusLabel(status)}
</span>
);
}
function typeLabel(value) {
return value === "app" ? "APP" : "H5";
}
function periodLabel(value) {
const days = Number(value || 0);
if (!Number.isFinite(days) || days <= 0) {
return "每次打开";
}
return `${days} 天一次`;
}
function effectiveTimeLabel(startsAtMs, endsAtMs) {
const start = startsAtMs ? formatMillis(startsAtMs) : "不限";
const end = endsAtMs ? formatMillis(endsAtMs) : "不限";
return `${start} - ${end}`;
}
function statusLabel(status) {
if (status === "active") {
return "启用";
}
if (status === "expired") {
return "过期";
}
return "关闭";
}

View File

@ -0,0 +1,440 @@
import AddOutlined from "@mui/icons-material/AddOutlined";
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import EditOutlined from "@mui/icons-material/EditOutlined";
import ImageOutlined from "@mui/icons-material/ImageOutlined";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { useMemo } from "react";
import { useSplashScreenConfigPage } from "@/features/app-config/hooks/useSplashScreenConfigPage.js";
import styles from "@/features/app-config/app-config.module.css";
import {
AdminFormDialog,
AdminFormFieldGrid,
AdminFormSection,
AdminFormSwitchField,
} from "@/shared/ui/AdminFormDialog.jsx";
import {
AdminActionIconButton,
AdminListBody,
AdminListPage,
AdminListToolbar,
AdminRowActions,
} from "@/shared/ui/AdminListLayout.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
import { RegionSelect } from "@/shared/ui/RegionSelect.jsx";
import { AppJumpConfigField } from "@/shared/ui/AppJumpConfigField.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
import { UploadField } from "@/shared/ui/UploadField.jsx";
import { formatMillis } from "@/shared/utils/time.js";
const statusOptions = [
["", "全部状态"],
["active", "启用"],
["disabled", "关闭"],
["expired", "过期"],
];
const platformOptions = [
["", "全部平台"],
["android", "安卓"],
["ios", "iOS"],
];
export function SplashScreenConfigPage() {
const page = useSplashScreenConfigPage();
const items = page.data.items || [];
const columns = useMemo(() => splashScreenColumns(page), [page]);
return (
<AdminListPage>
<AdminListToolbar
actions={
page.abilities.canUpdate ? (
<AdminActionIconButton label="新增开屏" primary onClick={page.openCreate}>
<AddOutlined fontSize="small" />
</AdminActionIconButton>
) : null
}
/>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<AdminListBody>
<DataTable
columns={columns}
items={items}
minWidth="1320px"
pagination={{
itemCount: items.length,
page: 1,
pageSize: items.length || 1,
total: page.data.total ?? items.length,
}}
rowKey={(item) => item.id}
/>
</AdminListBody>
</DataState>
<AdminFormDialog
loading={page.loadingAction === "create" || page.loadingAction === "edit"}
open={Boolean(page.activeAction)}
size="large"
submitDisabled={
!page.abilities.canUpdate || page.loadingAction === "create" || page.loadingAction === "edit"
}
title={page.editingItem ? "编辑开屏配置" : "新增开屏配置"}
onClose={page.closeDialog}
onSubmit={page.submitSplashScreen}
>
<div className={styles.splashFormLayout}>
<div className={styles.splashAssetPane}>
<AdminFormSection title="素材">
<UploadField
className={styles.splashUploadField}
disabled={!page.abilities.canUpdate || !page.abilities.canUpload}
label="封面图"
previewClassName={styles.splashUploadPreview}
value={page.form.coverUrl}
onChange={(coverUrl) => page.setForm({ coverUrl })}
/>
</AdminFormSection>
</div>
<div className={styles.splashFieldPane}>
<AdminFormSection title="跳转信息">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<TextField
disabled={!page.abilities.canUpdate}
label="平台"
required
select
value={page.form.platform}
onChange={(event) => page.setForm({ platform: event.target.value })}
>
<MenuItem value="android">安卓</MenuItem>
<MenuItem value="ios">iOS</MenuItem>
</TextField>
<AppJumpConfigField
appParamLabel="参数APP公共跳转JSON"
className={styles.formWideField}
disabled={!page.abilities.canUpdate}
h5Label="参数H5链接"
paramValue={page.form.param}
required
typeLabel="类型"
typeValue={page.form.splashType}
onChange={({ param, type }) => page.setForm({ param, splashType: type })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="投放设置">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<RegionSelect
disabled={!page.abilities.canUpdate}
emptyLabel="全部区域"
loading={page.loadingRegions}
options={page.regionOptions}
value={page.form.regionId}
onChange={(regionId) => page.setForm({ regionId })}
/>
<CountrySelect
disabled={!page.abilities.canUpdate || page.loadingCountries}
emptyLabel="全部国家"
label="国家"
options={page.countryOptions}
value={page.form.countryCode}
onChange={(countryCode) => page.setForm({ countryCode })}
/>
<TextField
disabled={!page.abilities.canUpdate}
label="排序"
slotProps={{ htmlInput: { step: 1 } }}
type="number"
value={page.form.sortOrder}
onChange={(event) => page.setForm({ sortOrder: event.target.value })}
/>
<TextField
disabled={!page.abilities.canUpdate}
label="展示时长(ms)"
slotProps={{ htmlInput: { min: 0, step: 100 } }}
type="number"
value={page.form.displayDurationMs}
onChange={(event) => page.setForm({ displayDurationMs: event.target.value })}
/>
<AdminFormSwitchField
checked={page.form.status === "active"}
checkedLabel="启用"
className={styles.splashStatusField}
disabled={!page.abilities.canUpdate}
label="开屏状态"
switchProps={{ inputProps: { "aria-label": "开屏启用状态" } }}
uncheckedLabel={page.form.status === "expired" ? "过期" : "关闭"}
onChange={(checked) => page.setForm({ status: checked ? "active" : "disabled" })}
/>
<TimeRangeFilter
className={styles.formWideField}
disabled={!page.abilities.canUpdate}
label="投放时间区间"
value={{ endMs: page.form.endsAtMs, startMs: page.form.startsAtMs }}
onChange={(range) =>
page.setForm({ endsAtMs: range.endMs, startsAtMs: range.startMs })
}
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="描述">
<TextField
disabled={!page.abilities.canUpdate}
label="描述"
multiline
minRows={2}
value={page.form.description}
onChange={(event) => page.setForm({ description: event.target.value })}
/>
</AdminFormSection>
</div>
</div>
</AdminFormDialog>
</AdminListPage>
);
}
function splashScreenColumns(page) {
const columns = [
{
key: "cover",
label: "封面图",
render: (item) => <SplashCover src={item.coverUrl} />,
width: "minmax(96px, 0.45fr)",
},
{
key: "target",
label: "类型 / 参数",
render: (item) => <Stack primary={typeLabel(item.splashType)} secondary={item.param || "-"} />,
filter: createTextColumnFilter({
placeholder: "搜索参数、国家、描述",
value: page.keyword,
onChange: page.setKeyword,
}),
width: "minmax(260px, 1.3fr)",
},
{
key: "platform",
label: "平台",
render: (item) => platformLabel(item.platform),
filter: createOptionsColumnFilter({
options: platformOptions,
placeholder: "搜索平台",
value: page.platform,
onChange: page.setPlatform,
}),
width: "minmax(110px, 0.6fr)",
},
{
key: "status",
label: "状态",
render: (item) => <StatusBadge status={item.status} />,
filter: createOptionsColumnFilter({
options: statusOptions,
placeholder: "搜索状态",
value: page.status,
onChange: page.setStatus,
}),
width: "minmax(100px, 0.55fr)",
},
{
key: "region",
label: "区域",
render: (item) => regionLabel(page.regionOptions, item.regionId),
filter: createRegionColumnFilter({
loading: page.loadingRegions,
options: page.regionOptions,
value: page.regionId,
onChange: page.setRegionId,
}),
width: "minmax(150px, 0.8fr)",
},
{
key: "country",
label: "国家",
render: (item) => countryLabel(page.countryOptions, item.countryCode),
filter: createOptionsColumnFilter({
emptyLabel: "全部国家",
loading: page.loadingCountries,
options: page.countryOptions,
placeholder: "搜索国家",
value: page.countryCode,
onChange: page.setCountryCode,
}),
width: "minmax(150px, 0.8fr)",
},
{
key: "sortOrder",
label: "排序",
width: "minmax(80px, 0.4fr)",
},
{
key: "displayDurationMs",
label: "展示时长",
render: (item) => durationLabel(item.displayDurationMs),
width: "minmax(110px, 0.55fr)",
},
{
key: "deliveryTime",
label: "投放时间",
render: (item) => deliveryTimeLabel(item.startsAtMs, item.endsAtMs),
width: "minmax(220px, 1fr)",
},
{
key: "description",
label: "描述",
render: (item) => item.description || "-",
width: "minmax(180px, 0.9fr)",
},
{
key: "updatedAtMs",
label: "更新时间",
render: (item) => formatMillis(item.updatedAtMs),
width: "minmax(160px, 0.8fr)",
},
];
if (!page.abilities.canUpdate) {
return columns;
}
return [
...columns,
{
key: "actions",
label: "操作",
render: (item) => <SplashActions item={item} page={page} />,
width: "minmax(96px, 0.45fr)",
},
];
}
function SplashCover({ src }) {
if (!src) {
return (
<span className={styles.splashCoverEmpty}>
<ImageOutlined fontSize="small" />
</span>
);
}
return <img alt="" className={styles.splashCover} src={src} />;
}
function SplashActions({ item, page }) {
const deleting = page.loadingAction === `delete:${item.id}`;
return (
<AdminRowActions>
<AdminActionIconButton
disabled={Boolean(page.loadingAction)}
label="编辑"
onClick={() => page.openEdit(item)}
>
<EditOutlined fontSize="small" />
</AdminActionIconButton>
<AdminActionIconButton
disabled={deleting || Boolean(page.loadingAction)}
label="删除"
onClick={() => page.removeSplashScreen(item)}
>
<DeleteOutlineOutlined fontSize="small" />
</AdminActionIconButton>
</AdminRowActions>
);
}
function CountrySelect({ className, disabled, emptyLabel, label = "国家", onChange, options, value }) {
return (
<TextField
className={className}
disabled={disabled}
label={label}
select
size="small"
value={value || ""}
onChange={(event) => onChange(event.target.value)}
>
<MenuItem value="">{emptyLabel}</MenuItem>
{options.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</TextField>
);
}
function Stack({ primary, secondary }) {
return (
<div className="cell-stack">
<span>{primary || "-"}</span>
<span className="muted">{secondary || "-"}</span>
</div>
);
}
function StatusBadge({ status }) {
const tone = status === "active" ? "succeeded" : status === "expired" ? "warning" : "stopped";
return (
<span className={`status-badge status-badge--${tone}`}>
<span className="status-point" />
{statusLabel(status)}
</span>
);
}
function typeLabel(value) {
return value === "app" ? "APP" : "H5";
}
function platformLabel(value) {
if (value === "android") {
return "安卓";
}
if (value === "ios") {
return "iOS";
}
return value || "-";
}
function deliveryTimeLabel(startsAtMs, endsAtMs) {
const start = startsAtMs ? formatMillis(startsAtMs) : "不限";
const end = endsAtMs ? formatMillis(endsAtMs) : "不限";
return `${start} - ${end}`;
}
function statusLabel(status) {
if (status === "active") {
return "启用";
}
if (status === "expired") {
return "过期";
}
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 "全部区域";
}
return options.find((option) => option.value === String(value))?.label || `区域 ${value}`;
}
function countryLabel(options, value) {
if (!value) {
return "全部国家";
}
return options.find((option) => option.value === value)?.label || value;
}

View File

@ -1,45 +1,52 @@
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
export const appConfigRoutes = [
{
label: "H5配置",
loader: () => import("./pages/H5ConfigPage.jsx").then((module) => module.H5ConfigPage),
menuCode: MENU_CODES.appConfigH5,
pageKey: "app-config-h5",
path: "/app-config/h5",
permission: PERMISSIONS.appConfigView
},
{
label: "BANNER配置",
loader: () => import("./pages/BannerConfigPage.jsx").then((module) => module.BannerConfigPage),
menuCode: MENU_CODES.appConfigBanners,
pageKey: "app-config-banners",
path: "/app-config/banners",
permission: PERMISSIONS.appConfigView
},
{
label: "Explore配置",
loader: () => import("./pages/ExploreConfigPage.jsx").then((module) => module.ExploreConfigPage),
menuCode: MENU_CODES.appConfigExplore,
pageKey: "app-config-explore",
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),
menuCode: MENU_CODES.appConfigVersions,
pageKey: "app-config-versions",
path: "/app-config/versions",
permission: PERMISSIONS.appVersionView
}
{
label: "H5配置",
loader: () => import("./pages/H5ConfigPage.jsx").then((module) => module.H5ConfigPage),
menuCode: MENU_CODES.appConfigH5,
pageKey: "app-config-h5",
path: "/app-config/h5",
permission: PERMISSIONS.appConfigView,
},
{
label: "BANNER配置",
loader: () => import("./pages/BannerConfigPage.jsx").then((module) => module.BannerConfigPage),
menuCode: MENU_CODES.appConfigBanners,
pageKey: "app-config-banners",
path: "/app-config/banners",
permission: PERMISSIONS.appConfigView,
},
{
label: "开屏配置",
loader: () => import("./pages/SplashScreenConfigPage.jsx").then((module) => module.SplashScreenConfigPage),
menuCode: MENU_CODES.appConfigSplashScreens,
pageKey: "app-config-splash-screens",
path: "/app-config/splash-screens",
permission: PERMISSIONS.appConfigView,
},
{
label: "弹窗配置",
loader: () => import("./pages/PopupConfigPage.jsx").then((module) => module.PopupConfigPage),
menuCode: MENU_CODES.appConfigPopups,
pageKey: "app-config-popups",
path: "/app-config/popups",
permission: PERMISSIONS.appConfigView,
},
{
label: "Explore配置",
loader: () => import("./pages/ExploreConfigPage.jsx").then((module) => module.ExploreConfigPage),
menuCode: MENU_CODES.appConfigExplore,
pageKey: "app-config-explore",
path: "/app-config/explore",
permission: PERMISSIONS.appConfigView,
},
{
label: "版本管理",
loader: () => import("./pages/AppVersionManagementPage.jsx").then((module) => module.AppVersionManagementPage),
menuCode: MENU_CODES.appConfigVersions,
pageKey: "app-config-versions",
path: "/app-config/versions",
permission: PERMISSIONS.appVersionView,
},
];

View File

@ -1,5 +1,11 @@
import { describe, expect, test } from "vitest";
import { appBannerSchema, exploreTabSchema, h5LinkUpdateSchema } from "@/features/app-config/schema";
import {
appBannerSchema,
appPopupSchema,
appSplashScreenSchema,
exploreTabSchema,
h5LinkUpdateSchema,
} from "@/features/app-config/schema";
import { FormValidationError, parseForm } from "@/shared/forms/validation";
const validBannerForm = {
@ -19,6 +25,34 @@ const validBannerForm = {
status: "active",
};
const validSplashScreenForm = {
countryCode: "",
coverUrl: "https://media.haiyihy.com/splash/launch.png",
description: "",
displayDurationMs: "4500",
endsAtMs: "1800000000000",
param: "https://h5.example.com/splash",
platform: "ios",
regionId: "",
sortOrder: "0",
splashType: "h5",
startsAtMs: "1700000000000",
status: "active",
};
const validPopupForm = {
code: "new-user-popup",
displayPeriodDays: "2",
endsAtMs: "1800000000000",
imageUrl: "",
jumpType: "h5",
jumpUrl: "https://h5.example.com/popup",
name: "新人活动",
sortOrder: "0",
startsAtMs: "1700000000000",
status: "active",
};
describe("app config form schema", () => {
test("validates dynamic h5 config item", () => {
const payload = parseForm(h5LinkUpdateSchema, {
@ -49,6 +83,27 @@ describe("app config form schema", () => {
expect(payload.endsAtMs).toBe(1800000000000);
});
test("accepts public app jump json for banner", () => {
const payload = parseForm(appBannerSchema, {
...validBannerForm,
bannerType: "app",
param: '{"type":"room_random_game","game_id":"dice"}',
});
expect(payload.bannerType).toBe("app");
expect(payload.param).toBe('{"type":"room_random_game","game_id":"dice"}');
});
test("rejects incomplete public app jump json for banner", () => {
expect(() =>
parseForm(appBannerSchema, {
...validBannerForm,
bannerType: "app",
param: '{"type":"room_game","room_id":"room_1"}',
}),
).toThrow(FormValidationError);
});
test("accepts multiple banner display scopes", () => {
const payload = parseForm(appBannerSchema, {
...validBannerForm,
@ -79,6 +134,106 @@ describe("app config form schema", () => {
).toThrow(FormValidationError);
});
test("keeps splash screen delivery and placement fields without display scope", () => {
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);
});
test("accepts public app jump json for splash screen", () => {
const payload = parseForm(appSplashScreenSchema, {
...validSplashScreenForm,
param: '{"type":"wallet"}',
splashType: "app",
});
expect(payload.splashType).toBe("app");
expect(payload.param).toBe('{"type":"wallet"}');
});
test("requires h5 link for splash screen h5 type", () => {
expect(() =>
parseForm(appSplashScreenSchema, {
...validSplashScreenForm,
param: "",
}),
).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, {
...validSplashScreenForm,
endsAtMs: "1700000000000",
startsAtMs: "1800000000000",
}),
).toThrow(FormValidationError);
});
test("keeps popup optional image and display period", () => {
const payload = parseForm(appPopupSchema, validPopupForm);
expect(payload.code).toBe("new-user-popup");
expect(payload.imageUrl).toBe("");
expect(payload.displayPeriodDays).toBe(2);
expect(payload.startsAtMs).toBe(1700000000000);
expect(payload.endsAtMs).toBe(1800000000000);
});
test("accepts public app jump json for popup", () => {
const payload = parseForm(appPopupSchema, {
...validPopupForm,
jumpType: "app",
jumpUrl: '{"type":"room_window","room_id":"room_1","window":"gift_panel","gift_id":"gift_1"}',
});
expect(payload.jumpType).toBe("app");
expect(payload.jumpUrl).toBe(
'{"type":"room_window","room_id":"room_1","window":"gift_panel","gift_id":"gift_1"}',
);
});
test("rejects popup code with spaces", () => {
expect(() =>
parseForm(appPopupSchema, {
...validPopupForm,
code: "new user popup",
}),
).toThrow(FormValidationError);
});
test("requires popup jump address", () => {
expect(() =>
parseForm(appPopupSchema, {
...validPopupForm,
jumpUrl: "",
}),
).toThrow(FormValidationError);
});
test("rejects inverted popup effective time range", () => {
expect(() =>
parseForm(appPopupSchema, {
...validPopupForm,
endsAtMs: "1700000000000",
startsAtMs: "1800000000000",
}),
).toThrow(FormValidationError);
});
test("validates explore tab h5 url and sort order", () => {
const payload = parseForm(exploreTabSchema, {
enabled: true,

View File

@ -1,4 +1,5 @@
import { z } from "zod";
import { validatePublicAppJumpParam } from "@/shared/app-jump/appJumpConfig.js";
const appBannerDisplayScopes = ["home", "room", "recharge"] as const;
@ -70,6 +71,12 @@ export const appBannerSchema = z
if (value.bannerType === "h5" && /\s/.test(value.param)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "H5 链接不能包含空白字符", path: ["param"] });
}
if (value.bannerType === "app") {
const validation = validatePublicAppJumpParam(value.param);
if (!validation.valid) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: validation.message, path: ["param"] });
}
}
if (value.displayScopes.includes("room") && !value.roomSmallImageUrl) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
@ -84,6 +91,89 @@ export const appBannerSchema = z
export type AppBannerForm = z.infer<typeof appBannerSchema>;
export const appSplashScreenSchema = z
.object({
countryCode: z
.string()
.trim()
.transform((value) => value.toUpperCase())
.refine((value) => value === "" || /^[A-Z]{2,3}$/.test(value), "国家编码需为 2-3 位字母"),
coverUrl: z
.string()
.trim()
.min(1, "请上传封面图")
.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"]),
regionId: z.coerce.number().int().min(0, "区域不正确").default(0),
sortOrder: z.coerce.number().int("排序必须是整数").default(0),
splashType: z.enum(["h5", "app"]),
startsAtMs: z.coerce.number().int().min(0, "投放开始时间不正确").default(0),
status: z.enum(["active", "disabled", "expired"]),
})
.superRefine((value, ctx) => {
if (value.splashType === "h5" && !value.param) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "H5 类型需要填写链接", path: ["param"] });
}
if (value.splashType === "h5" && /\s/.test(value.param)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "H5 链接不能包含空白字符", path: ["param"] });
}
if (value.splashType === "app") {
const validation = validatePublicAppJumpParam(value.param);
if (!validation.valid) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: validation.message, path: ["param"] });
}
}
if (value.startsAtMs > 0 && value.endsAtMs > 0 && value.startsAtMs >= value.endsAtMs) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "投放结束时间必须晚于开始时间", path: ["endsAtMs"] });
}
});
export type AppSplashScreenForm = z.infer<typeof appSplashScreenSchema>;
export const appPopupSchema = z
.object({
code: z
.string()
.trim()
.min(1, "请填写弹窗code")
.max(80, "弹窗code不能超过 80 个字符")
.regex(/^[A-Za-z0-9_.:-]+$/, "弹窗code只能包含字母、数字、下划线、中横线、点和冒号"),
displayPeriodDays: z.coerce.number().int("展示周期必须是整数天").min(0, "展示周期不能小于 0").default(0),
endsAtMs: z.coerce.number().int().min(0, "生效结束时间不正确").default(0),
imageUrl: z
.string()
.trim()
.max(1024, "图片地址不能超过 1024 个字符")
.refine((value) => !/\s/.test(value), "图片地址不能包含空白字符"),
jumpType: z.enum(["h5", "app"]),
jumpUrl: z.string().trim().min(1, "请填写跳转地址").max(2048, "跳转地址不能超过 2048 个字符"),
name: z.string().trim().min(1, "请填写弹窗名字").max(80, "弹窗名字不能超过 80 个字符"),
sortOrder: z.coerce.number().int("排序必须是整数").default(0),
startsAtMs: z.coerce.number().int().min(0, "生效开始时间不正确").default(0),
status: z.enum(["active", "disabled", "expired"]),
})
.superRefine((value, ctx) => {
if (value.jumpType === "h5" && /\s/.test(value.jumpUrl)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "H5 链接不能包含空白字符", path: ["jumpUrl"] });
}
if (value.jumpType === "app") {
const validation = validatePublicAppJumpParam(value.jumpUrl);
if (!validation.valid) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: validation.message, path: ["jumpUrl"] });
}
}
if (value.startsAtMs > 0 && value.endsAtMs > 0 && value.startsAtMs >= value.endsAtMs) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "生效结束时间必须晚于开始时间", path: ["endsAtMs"] });
}
});
export type AppPopupForm = z.infer<typeof appPopupSchema>;
export const appVersionSchema = z.object({
buildNumber: z.coerce.number().int("编译版本号必须是整数").positive("编译版本号必须大于 0"),
description: z.string().trim().max(1000, "更新描述不能超过 1000 个字符").default(""),

View File

@ -1,12 +1,65 @@
import { afterEach, expect, test, vi } from "vitest";
import { setAccessToken } from "@/shared/api/request";
import { listAppUsers } from "./api";
import { getAppUser, 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",
@ -33,3 +86,76 @@ test("listAppUsers sends country region and time filters", async () => {
expect(String(url)).toContain("end_ms=2000");
expect(init?.method).toBe("GET");
});
test("listAppUsers normalizes pretty display id fields from snake case", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(
JSON.stringify({
code: 0,
data: {
items: [
{
avatar: "https://cdn.example/avatar.png",
coin: 99,
default_display_user_id: "123456",
display_user_id: "123456",
pretty_display_user_id: "VIP2026",
pretty_id: "pretty-2026",
user_id: "10001",
username: "tester",
},
],
page: 1,
page_size: 50,
total: 1,
},
}),
),
),
);
const result = await listAppUsers({ page: 1, page_size: 50 });
expect(result.pageSize).toBe(50);
expect(result.items[0]).toMatchObject({
defaultDisplayUserId: "123456",
displayUserId: "123456",
prettyDisplayUserId: "VIP2026",
prettyId: "pretty-2026",
userId: "10001",
username: "tester",
});
});
test("getAppUser uses detail endpoint and normalizes pretty display id", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(
JSON.stringify({
code: 0,
data: {
display_user_id: "123456",
default_display_user_id: "123456",
pretty_display_user_id: "VIP888",
pretty_id: "pretty-888",
user_id: "10001",
},
}),
),
),
);
const result = await getAppUser("10001");
const [url, init] = vi.mocked(fetch).mock.calls[0];
expect(String(url)).toContain("/api/v1/app/users/10001");
expect(init?.method).toBe("GET");
expect(result.defaultDisplayUserId).toBe("123456");
expect(result.prettyDisplayUserId).toBe("VIP888");
expect(result.prettyId).toBe("pretty-888");
});

View File

@ -7,54 +7,88 @@ import type {
AppUserDto,
AppUserPasswordPayload,
AppUserUpdatePayload,
AdminGrantPrettyDisplayIDResultDto,
EntityId,
GeneratePrettyDisplayIDsPayload,
GrantPrettyDisplayIDPayload,
PageQuery,
PrettyDisplayIDDto,
PrettyDisplayIDGenerationBatchDto,
PrettyDisplayIDPoolDto,
PrettyDisplayIDPoolPayload,
SetPrettyDisplayIDStatusPayload,
} from "@/shared/api/types";
export function listAppUsers(query: PageQuery = {}): Promise<ApiPage<AppUserDto>> {
export async function listAppUsers(query: PageQuery = {}): Promise<ApiPage<AppUserDto>> {
const endpoint = API_ENDPOINTS.appListUsers;
return apiRequest<ApiPage<AppUserDto>>(apiEndpointPath(API_OPERATIONS.appListUsers), {
method: endpoint.method,
query,
});
const data = await apiRequest<ApiPage<RawAppUser> & { page_size?: number }>(
apiEndpointPath(API_OPERATIONS.appListUsers),
{
method: endpoint.method,
query,
},
);
return normalizePage(data, normalizeAppUser);
}
export function listAppUserLoginLogs(query: PageQuery = {}): Promise<ApiPage<AppUserLoginLogDto>> {
export async function getAppUser(userId: EntityId): Promise<AppUserDto> {
const endpoint = API_ENDPOINTS.appGetUser;
const data = await apiRequest<RawAppUser>(apiEndpointPath(API_OPERATIONS.appGetUser, { id: userId }), {
method: endpoint.method,
});
return normalizeAppUser(data);
}
export async function listAppUserLoginLogs(query: PageQuery = {}): Promise<ApiPage<AppUserLoginLogDto>> {
const endpoint = API_ENDPOINTS.appListLoginLogs;
return apiRequest<ApiPage<AppUserLoginLogDto>>(apiEndpointPath(API_OPERATIONS.appListLoginLogs), {
method: endpoint.method,
query,
});
const data = await apiRequest<ApiPage<RawAppUserLoginLog> & { page_size?: number }>(
apiEndpointPath(API_OPERATIONS.appListLoginLogs),
{
method: endpoint.method,
query,
},
);
return normalizePage(data, normalizeAppUserLoginLog);
}
export function listAppUserBans(query: PageQuery = {}): Promise<ApiPage<AppUserBanRecordDto>> {
export async function listAppUserBans(query: PageQuery = {}): Promise<ApiPage<AppUserBanRecordDto>> {
const endpoint = API_ENDPOINTS.appListBannedUsers;
return apiRequest<ApiPage<AppUserBanRecordDto>>(apiEndpointPath(API_OPERATIONS.appListBannedUsers), {
method: endpoint.method,
query,
});
const data = await apiRequest<ApiPage<RawAppUserBanRecord> & { page_size?: number }>(
apiEndpointPath(API_OPERATIONS.appListBannedUsers),
{
method: endpoint.method,
query,
},
);
return normalizePage(data, normalizeAppUserBanRecord);
}
export function updateAppUser(userId: EntityId, payload: AppUserUpdatePayload): Promise<AppUserDto> {
export async function updateAppUser(userId: EntityId, payload: AppUserUpdatePayload): Promise<AppUserDto> {
const endpoint = API_ENDPOINTS.appUpdateUser;
return apiRequest<AppUserDto, AppUserUpdatePayload>(apiEndpointPath(API_OPERATIONS.appUpdateUser, { id: userId }), {
body: payload,
method: endpoint.method,
});
const data = await apiRequest<RawAppUser, AppUserUpdatePayload>(
apiEndpointPath(API_OPERATIONS.appUpdateUser, { id: userId }),
{
body: payload,
method: endpoint.method,
},
);
return normalizeAppUser(data);
}
export function banAppUser(userId: EntityId): Promise<AppUserDto> {
export async function banAppUser(userId: EntityId): Promise<AppUserDto> {
const endpoint = API_ENDPOINTS.appBanUser;
return apiRequest<AppUserDto>(apiEndpointPath(API_OPERATIONS.appBanUser, { id: userId }), {
const data = await apiRequest<RawAppUser>(apiEndpointPath(API_OPERATIONS.appBanUser, { id: userId }), {
method: endpoint.method,
});
return normalizeAppUser(data);
}
export function unbanAppUser(userId: EntityId): Promise<AppUserDto> {
export async function unbanAppUser(userId: EntityId): Promise<AppUserDto> {
const endpoint = API_ENDPOINTS.appUnbanUser;
return apiRequest<AppUserDto>(apiEndpointPath(API_OPERATIONS.appUnbanUser, { id: userId }), {
const data = await apiRequest<RawAppUser>(apiEndpointPath(API_OPERATIONS.appUnbanUser, { id: userId }), {
method: endpoint.method,
});
return normalizeAppUser(data);
}
export function setAppUserPassword(
@ -70,3 +104,483 @@ export function setAppUserPassword(
},
);
}
// 靓号管理接口路径全部来自生成的 OpenAPI 端点,页面层只依赖这些函数,避免手写 URL 和契约漂移。
export async function listPrettyDisplayIDPools(query: PageQuery = {}): Promise<ApiPage<PrettyDisplayIDPoolDto>> {
const endpoint = API_ENDPOINTS.listPrettyIdPools;
const data = await apiRequest<ApiPage<RawPrettyDisplayIDPool>>(apiEndpointPath(API_OPERATIONS.listPrettyIdPools), {
method: endpoint.method,
query,
});
return normalizePage(data, normalizePrettyDisplayIDPool);
}
export async function createPrettyDisplayIDPool(payload: PrettyDisplayIDPoolPayload): Promise<PrettyDisplayIDPoolDto> {
const endpoint = API_ENDPOINTS.createPrettyIdPool;
const data = await apiRequest<RawPrettyDisplayIDPool, PrettyDisplayIDPoolPayload>(
apiEndpointPath(API_OPERATIONS.createPrettyIdPool),
{
body: payload,
method: endpoint.method,
},
);
return normalizePrettyDisplayIDPool(data);
}
export async function updatePrettyDisplayIDPool(
poolId: EntityId,
payload: PrettyDisplayIDPoolPayload,
): Promise<PrettyDisplayIDPoolDto> {
const endpoint = API_ENDPOINTS.updatePrettyIdPool;
const data = await apiRequest<RawPrettyDisplayIDPool, PrettyDisplayIDPoolPayload>(
apiEndpointPath(API_OPERATIONS.updatePrettyIdPool, { pool_id: poolId }),
{
body: payload,
method: endpoint.method,
},
);
return normalizePrettyDisplayIDPool(data);
}
export async function generatePrettyDisplayIDs(
poolId: EntityId,
payload: GeneratePrettyDisplayIDsPayload,
): Promise<PrettyDisplayIDGenerationBatchDto> {
const endpoint = API_ENDPOINTS.generatePrettyIds;
const data = await apiRequest<RawPrettyDisplayIDGenerationBatch, GeneratePrettyDisplayIDsPayload>(
apiEndpointPath(API_OPERATIONS.generatePrettyIds, { pool_id: poolId }),
{
body: payload,
method: endpoint.method,
},
);
return normalizePrettyDisplayIDGenerationBatch(data);
}
export async function listPrettyDisplayIDs(query: PageQuery = {}): Promise<ApiPage<PrettyDisplayIDDto>> {
const endpoint = API_ENDPOINTS.listPrettyIds;
const data = await apiRequest<ApiPage<RawPrettyDisplayID>>(apiEndpointPath(API_OPERATIONS.listPrettyIds), {
method: endpoint.method,
query,
});
return normalizePage(data, normalizePrettyDisplayID);
}
export async function grantPrettyDisplayID(
payload: GrantPrettyDisplayIDPayload,
): Promise<AdminGrantPrettyDisplayIDResultDto> {
const endpoint = API_ENDPOINTS.grantPrettyId;
const data = await apiRequest<RawAdminGrantPrettyDisplayIDResult, GrantPrettyDisplayIDPayload>(
apiEndpointPath(API_OPERATIONS.grantPrettyId),
{
body: payload,
method: endpoint.method,
},
);
return normalizeAdminGrantPrettyDisplayIDResult(data);
}
export async function setPrettyDisplayIDStatus(
prettyId: EntityId,
payload: SetPrettyDisplayIDStatusPayload,
): Promise<PrettyDisplayIDDto> {
const endpoint = API_ENDPOINTS.setPrettyIdStatus;
const data = await apiRequest<RawPrettyDisplayID, SetPrettyDisplayIDStatusPayload>(
apiEndpointPath(API_OPERATIONS.setPrettyIdStatus, { pretty_id: prettyId }),
{
body: payload,
method: endpoint.method,
},
);
return normalizePrettyDisplayID(data);
}
// 用户相关接口历史上既返回过 snake_case也返回过 camelCase在 API 边界归一化后,共享用户组件只消费稳定字段。
interface RawAppUser {
avatar?: string;
coin?: number;
country?: string;
country_display_name?: string;
countryDisplayName?: string;
country_name?: string;
countryName?: string;
created_at_ms?: number;
createdAtMs?: number;
default_display_user_id?: string;
defaultDisplayUserId?: string;
diamond?: number;
display_user_id?: string;
displayUserId?: string;
gender?: string;
last_active_at_ms?: number;
lastActiveAtMs?: number;
pretty_display_user_id?: string;
prettyDisplayUserId?: string;
pretty_id?: string;
prettyId?: string;
region_id?: number;
regionId?: number;
region_name?: string;
regionName?: string;
status?: string;
updated_at_ms?: number;
updatedAtMs?: number;
user_id?: EntityId;
userId?: EntityId;
username?: string;
}
type RawAppUserBrief = RawAppUser;
interface RawAppUserBanOperator extends RawAppUserBrief {
account?: string;
admin_id?: EntityId;
adminId?: EntityId;
name?: string;
type?: string;
}
interface RawAppUserBanRecord {
created_at_ms?: number;
createdAtMs?: number;
id?: number;
new_status?: string;
newStatus?: string;
old_status?: string;
oldStatus?: string;
operator?: RawAppUserBanOperator | null;
reason?: string;
request_id?: string;
requestId?: string;
target?: RawAppUserBrief;
}
interface RawAppUserLoginLog extends RawAppUserBrief {
block_reason?: string;
blockReason?: string;
blocked?: boolean;
channel?: string;
country?: string;
created_at_ms?: number;
createdAtMs?: number;
failure_code?: string;
failureCode?: string;
id?: number;
ip_country_code?: string;
ipCountryCode?: string;
login_ip?: string;
loginIp?: string;
login_type?: string;
loginType?: string;
platform?: string;
provider?: string;
region_id?: number;
regionId?: number;
region_name?: string;
regionName?: string;
request_id?: string;
requestId?: string;
result?: string;
risk_highlighted?: boolean;
riskHighlighted?: boolean;
}
// Go 后台当前按 snake_case JSON 返回,生成类型和页面代码使用 camelCaseRaw 类型同时接收两种形态,便于后续切换生成客户端。
interface RawPrettyDisplayIDPool {
app_code?: string;
appCode?: string;
created_at_ms?: number;
createdAtMs?: number;
created_by_admin_id?: EntityId;
createdByAdminId?: EntityId;
level_track?: string;
levelTrack?: string;
max_level?: number;
maxLevel?: number;
min_level?: number;
minLevel?: number;
name?: string;
pool_id?: string;
poolId?: string;
rule_config_json?: string;
ruleConfigJson?: string;
rule_type?: string;
ruleType?: string;
sort_order?: number;
sortOrder?: number;
status?: string;
updated_at_ms?: number;
updatedAtMs?: number;
updated_by_admin_id?: EntityId;
updatedByAdminId?: EntityId;
}
interface RawPrettyDisplayID {
app_code?: string;
appCode?: string;
assigned_at_ms?: number;
assignedAtMs?: number;
assigned_lease_id?: string;
assignedLeaseId?: string;
assigned_user_id?: EntityId;
assignedUserId?: EntityId;
created_at_ms?: number;
createdAtMs?: number;
created_by_admin_id?: EntityId;
createdByAdminId?: EntityId;
display_user_id?: string;
displayUserId?: string;
generated_batch_id?: string;
generatedBatchId?: string;
pool?: RawPrettyDisplayIDPool | null;
pool_id?: string;
poolId?: string;
pretty_id?: string;
prettyId?: string;
release_reason?: string;
releaseReason?: string;
released_at_ms?: number;
releasedAtMs?: number;
source?: string;
status?: string;
updated_at_ms?: number;
updatedAtMs?: number;
}
interface RawPrettyDisplayIDGenerationBatch {
app_code?: string;
appCode?: string;
batch_id?: string;
batchId?: string;
created_at_ms?: number;
createdAtMs?: number;
generated_count?: number;
generatedCount?: number;
operator_admin_id?: EntityId;
operatorAdminId?: EntityId;
pool_id?: string;
poolId?: string;
request_id?: string;
requestId?: string;
requested_count?: number;
requestedCount?: number;
rule_config_json?: string;
ruleConfigJson?: string;
rule_type?: string;
ruleType?: string;
skipped_conflict_count?: number;
skippedConflictCount?: number;
status?: string;
updated_at_ms?: number;
updatedAtMs?: number;
}
interface RawAdminGrantPrettyDisplayIDResult {
identity?: {
default_display_user_id?: string;
defaultDisplayUserId?: string;
display_user_id?: string;
displayUserId?: string;
pretty_display_user_id?: string;
prettyDisplayUserId?: string;
pretty_id?: string;
prettyId?: string;
user_id?: EntityId;
userId?: EntityId;
};
lease_id?: string;
leaseId?: string;
pretty_id?: string;
prettyId?: string;
}
// 分页结构也做一次兼容page_size 来自 Go 响应pageSize 来自前端 ApiPage 约定。
function normalizePage<TRaw, TItem>(
data: ApiPage<TRaw> & { page_size?: number },
normalize: (item: TRaw) => TItem,
): ApiPage<TItem> {
return {
items: (data.items || []).map(normalize),
page: Number(data.page || 1),
pageSize: Number(data.pageSize || data.page_size || 20),
total: Number(data.total || 0),
};
}
function normalizeAppUser(item: RawAppUser = {}): AppUserDto {
return {
avatar: stringValue(item.avatar),
coin: numberValue(item.coin),
country: stringValue(item.country),
countryDisplayName: stringValue(item.countryDisplayName ?? item.country_display_name),
countryName: stringValue(item.countryName ?? item.country_name),
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
defaultDisplayUserId: stringValue(item.defaultDisplayUserId ?? item.default_display_user_id),
diamond: numberValue(item.diamond),
displayUserId: stringValue(item.displayUserId ?? item.display_user_id),
gender: stringValue(item.gender),
lastActiveAtMs: numberValue(item.lastActiveAtMs ?? item.last_active_at_ms),
prettyDisplayUserId: stringValue(item.prettyDisplayUserId ?? item.pretty_display_user_id),
prettyId: stringValue(item.prettyId ?? item.pretty_id),
regionId: numberValue(item.regionId ?? item.region_id),
regionName: stringValue(item.regionName ?? item.region_name),
status: stringValue(item.status),
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
userId: stringValue(item.userId ?? item.user_id),
username: stringValue(item.username),
};
}
function normalizeAppUserBrief(item: RawAppUserBrief = {}) {
const user = normalizeAppUser(item);
return {
avatar: user.avatar,
defaultDisplayUserId: user.defaultDisplayUserId,
displayUserId: user.displayUserId,
prettyDisplayUserId: user.prettyDisplayUserId,
prettyId: user.prettyId,
userId: user.userId,
username: user.username,
};
}
function normalizeAppUserBanOperator(item?: RawAppUserBanOperator | null) {
if (!item) {
return undefined;
}
const user = normalizeAppUserBrief(item);
return {
...user,
account: stringValue(item.account),
adminId: stringValue(item.adminId ?? item.admin_id),
name: stringValue(item.name),
type: stringValue(item.type || "system"),
};
}
function normalizeAppUserBanRecord(item: RawAppUserBanRecord = {}): AppUserBanRecordDto {
return {
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
id: numberValue(item.id),
newStatus: stringValue(item.newStatus ?? item.new_status),
oldStatus: stringValue(item.oldStatus ?? item.old_status),
operator: normalizeAppUserBanOperator(item.operator),
reason: stringValue(item.reason),
requestId: stringValue(item.requestId ?? item.request_id),
target: normalizeAppUserBrief(item.target || {}),
};
}
function normalizeAppUserLoginLog(item: RawAppUserLoginLog = {}): AppUserLoginLogDto {
const user = normalizeAppUserBrief(item);
return {
...user,
blockReason: stringValue(item.blockReason ?? item.block_reason),
blocked: Boolean(item.blocked),
channel: stringValue(item.channel),
country: stringValue(item.country),
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
failureCode: stringValue(item.failureCode ?? item.failure_code),
id: numberValue(item.id),
ipCountryCode: stringValue(item.ipCountryCode ?? item.ip_country_code),
loginIp: stringValue(item.loginIp ?? item.login_ip),
loginType: stringValue(item.loginType ?? item.login_type),
platform: stringValue(item.platform),
provider: stringValue(item.provider),
regionId: numberValue(item.regionId ?? item.region_id),
regionName: stringValue(item.regionName ?? item.region_name),
requestId: stringValue(item.requestId ?? item.request_id),
result: stringValue(item.result),
riskHighlighted: Boolean(item.riskHighlighted ?? item.risk_highlighted),
};
}
// 池 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 ?? "",
};
}
function stringValue(value: unknown): string {
return value === undefined || value === null ? "" : String(value).trim();
}
function numberValue(value: unknown): number {
const parsed = Number(value || 0);
return Number.isFinite(parsed) ? parsed : 0;
}
// 靓号明细包含池号和后台发放两类来源pool 可能为空,归一化后页面只判断 source/status 即可。
function normalizePrettyDisplayID(item: RawPrettyDisplayID): PrettyDisplayIDDto {
return {
appCode: item.appCode ?? item.app_code ?? "",
assignedAtMs: item.assignedAtMs ?? item.assigned_at_ms ?? 0,
assignedLeaseId: item.assignedLeaseId ?? item.assigned_lease_id ?? "",
assignedUserId: item.assignedUserId ?? item.assigned_user_id ?? "",
createdAtMs: item.createdAtMs ?? item.created_at_ms ?? 0,
createdByAdminId: item.createdByAdminId ?? item.created_by_admin_id ?? "",
displayUserId: item.displayUserId ?? item.display_user_id ?? "",
generatedBatchId: item.generatedBatchId ?? item.generated_batch_id ?? "",
pool: item.pool ? normalizePrettyDisplayIDPool(item.pool) : null,
poolId: item.poolId ?? item.pool_id ?? "",
prettyId: item.prettyId ?? item.pretty_id ?? "",
releaseReason: item.releaseReason ?? item.release_reason ?? "",
releasedAtMs: item.releasedAtMs ?? item.released_at_ms ?? 0,
source: item.source ?? "",
status: item.status ?? "",
updatedAtMs: item.updatedAtMs ?? item.updated_at_ms ?? 0,
};
}
// 生成结果用于展示本次批量生成的真实落库数量和冲突跳过数量,数值统一转换,避免字符串数字影响统计。
function normalizePrettyDisplayIDGenerationBatch(
item: RawPrettyDisplayIDGenerationBatch,
): PrettyDisplayIDGenerationBatchDto {
return {
appCode: item.appCode ?? item.app_code ?? "",
batchId: item.batchId ?? item.batch_id ?? "",
createdAtMs: item.createdAtMs ?? item.created_at_ms ?? 0,
generatedCount: Number(item.generatedCount ?? item.generated_count ?? 0),
operatorAdminId: item.operatorAdminId ?? item.operator_admin_id ?? "",
poolId: item.poolId ?? item.pool_id ?? "",
requestId: item.requestId ?? item.request_id ?? "",
requestedCount: Number(item.requestedCount ?? item.requested_count ?? 0),
ruleConfigJson: item.ruleConfigJson ?? item.rule_config_json ?? "",
ruleType: item.ruleType ?? item.rule_type ?? "",
skippedConflictCount: Number(item.skippedConflictCount ?? item.skipped_conflict_count ?? 0),
status: item.status ?? "",
updatedAtMs: item.updatedAtMs ?? item.updated_at_ms ?? 0,
};
}
// 后台发放返回的是新 lease 和用户当前身份快照identity 归一化后能直接用于测试和后续结果提示。
function normalizeAdminGrantPrettyDisplayIDResult(
item: RawAdminGrantPrettyDisplayIDResult,
): AdminGrantPrettyDisplayIDResultDto {
const identity = item.identity || {};
return {
identity: {
defaultDisplayUserId: identity.defaultDisplayUserId ?? identity.default_display_user_id ?? "",
displayUserId: identity.displayUserId ?? identity.display_user_id ?? "",
prettyDisplayUserId: identity.prettyDisplayUserId ?? identity.pretty_display_user_id ?? "",
prettyId: identity.prettyId ?? identity.pretty_id ?? "",
userId: String(identity.userId ?? identity.user_id ?? ""),
},
leaseId: item.leaseId ?? item.lease_id ?? "",
prettyId: item.prettyId ?? item.pretty_id ?? "",
};
}

View File

@ -1,273 +1,487 @@
.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;
}
.detailHero {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding-bottom: var(--space-4);
border-bottom: 1px solid var(--border);
}
.detailLoading {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--primary-border);
border-radius: var(--radius-sm);
background: var(--primary-surface);
color: var(--primary);
font-size: 12px;
font-weight: 700;
}
.detailSection {
display: grid;
gap: var(--space-3);
padding: var(--space-4);
border: 1px solid var(--border);
border-radius: var(--radius-card);
background: var(--bg-card-strong);
}
.detailSectionTitle {
margin: 0;
color: var(--text-primary);
font-size: var(--admin-font-size);
font-weight: 800;
}
.detailGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
}
.detailItem {
display: grid;
min-width: 0;
gap: 4px;
}
.detailLabel {
color: var(--text-tertiary);
font-size: 12px;
}
.detailValue {
min-width: 0;
overflow-wrap: anywhere;
color: var(--text-primary);
font-size: var(--admin-font-size);
font-weight: 650;
}
.stack {
display: grid;
gap: 2px;
display: grid;
gap: 1px;
min-width: 0;
}
.primaryText {
color: var(--text-primary);
font-weight: 650;
color: var(--text-primary);
font-weight: 650;
}
.countryButton {
display: inline-flex;
width: fit-content;
max-width: 100%;
align-items: center;
justify-content: flex-start;
overflow: hidden;
border: 0;
background: transparent;
color: var(--primary);
cursor: pointer;
font: inherit;
font-weight: 650;
padding: 0;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-flex;
width: fit-content;
max-width: 100%;
align-items: center;
justify-content: flex-start;
overflow: hidden;
border: 0;
background: transparent;
color: var(--primary);
cursor: pointer;
font: inherit;
font-weight: 650;
padding: 0;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.countryButton:hover {
color: var(--primary-strong);
color: var(--primary-strong);
}
.rowActions {
display: inline-flex;
align-items: center;
gap: var(--space-2);
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.formGridTwo {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
}
@media (max-width: 760px) {
.toolbar,
.toolbarLeft,
.filters {
align-items: stretch;
flex-direction: column;
}
.prettyWorkspace {
grid-template-columns: 1fr;
}
.search,
.statusSelect,
.timeFilter {
flex: 1 1 auto;
width: 100%;
}
.prettyPoolPane {
min-height: 42dvh;
border-right: 0;
border-bottom: 1px solid var(--border);
}
.prettyIDPane {
min-height: 50dvh;
}
.prettyPaneHeader,
.toolbar,
.toolbarLeft,
.toolbarActions,
.filters,
.prettyFilters {
align-items: stretch;
flex-direction: column;
}
.search,
.compactControl,
.userIDControl,
.statusSelect,
.timeFilter {
flex: 1 1 auto;
width: 100%;
}
.formGridTwo {
grid-template-columns: 1fr;
}
}

View File

@ -0,0 +1,9 @@
import { createContext, useContext } from "react";
export const AppUserDetailContext = createContext({
openAppUserDetail: null,
});
export function useAppUserDetail() {
return useContext(AppUserDetailContext);
}

View File

@ -0,0 +1,201 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { AdminPrettyDisplayID, AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { formatMillis } from "@/shared/utils/time.js";
import { getAppUser } from "@/features/app-users/api";
import { appUserStatusLabels } from "@/features/app-users/constants.js";
import { AppUserDetailContext } from "@/features/app-users/components/AppUserDetailContext.jsx";
import styles from "@/features/app-users/app-users.module.css";
export function AppUserDetailProvider({ children }) {
const { showToast } = useToast();
const requestSeqRef = useRef(0);
const [detailUser, setDetailUser] = useState(null);
const [loadingUserId, setLoadingUserId] = useState("");
const closeAppUserDetail = useCallback(() => {
requestSeqRef.current += 1;
setDetailUser(null);
setLoadingUserId("");
}, []);
const openAppUserDetail = useCallback(
async (source) => {
const seedUser = normalizeDetailSeed(source);
if (!seedUser.userId) {
showToast("缺少用户 ID无法打开用户详情", "error");
return;
}
const requestSeq = requestSeqRef.current + 1;
requestSeqRef.current = requestSeq;
setDetailUser(seedUser);
setLoadingUserId(seedUser.userId);
try {
const latestUser = await getAppUser(seedUser.userId);
if (requestSeqRef.current === requestSeq) {
setDetailUser({ ...seedUser, ...latestUser });
}
} catch (err) {
if (requestSeqRef.current === requestSeq) {
showToast(err.message || "加载用户详情失败", "error");
}
} finally {
if (requestSeqRef.current === requestSeq) {
setLoadingUserId("");
}
}
},
[showToast],
);
const value = useMemo(() => ({ closeAppUserDetail, openAppUserDetail }), [closeAppUserDetail, openAppUserDetail]);
return (
<AppUserDetailContext.Provider value={value}>
{children}
<AppUserDetailDrawer
loading={Boolean(detailUser?.userId) && loadingUserId === detailUser.userId}
open={Boolean(detailUser)}
user={detailUser}
onClose={closeAppUserDetail}
/>
</AppUserDetailContext.Provider>
);
}
function AppUserDetailDrawer({ loading, onClose, open, user }) {
if (!open || !user) {
return null;
}
return (
<SideDrawer open={open} title="用户详情" width="wide" onClose={onClose}>
<div className={styles.detailHero}>
<AdminUserIdentity user={user} size="large" />
<AppUserStatus status={user.status} />
</div>
{loading ? <div className={styles.detailLoading}>正在刷新用户详情...</div> : null}
<section className={styles.detailSection}>
<h3 className={styles.detailSectionTitle}>身份信息</h3>
<div className={styles.detailGrid}>
<DetailItem label="用户 ID" value={user.userId} />
<DetailItem label="短 ID" value={user.defaultDisplayUserId || user.displayUserId || user.userId} />
<DetailItem label="靓号" value={<PrettyValue user={user} />} />
<DetailItem label="靓号记录 ID" value={user.prettyId} />
<DetailItem label="昵称" value={user.username} />
<DetailItem label="性别" value={formatGender(user.gender)} />
</div>
</section>
<section className={styles.detailSection}>
<h3 className={styles.detailSectionTitle}>账号状态</h3>
<div className={styles.detailGrid}>
<DetailItem label="状态" value={appUserStatusLabels[user.status] || user.status || "-"} />
<DetailItem label="金币" value={formatNumber(user.coin)} />
<DetailItem label="钻石" value={formatNumber(user.diamond)} />
<DetailItem label="国家" value={formatCountry(user)} />
<DetailItem
label="区域"
value={user.regionName || (user.regionId ? `区域 ${user.regionId}` : "-")}
/>
<DetailItem label="最近活跃" value={formatMillis(user.lastActiveAtMs)} />
<DetailItem label="创建时间" value={formatMillis(user.createdAtMs)} />
<DetailItem label="更新时间" value={formatMillis(user.updatedAtMs)} />
</div>
</section>
</SideDrawer>
);
}
function DetailItem({ label, value }) {
const isElement = value && typeof value === "object";
return (
<div className={styles.detailItem}>
<span className={styles.detailLabel}>{label}</span>
<span className={styles.detailValue}>
{isElement ? value : value === undefined || value === null || value === "" ? "-" : value}
</span>
</div>
);
}
function PrettyValue({ user }) {
if (!user.prettyId || !user.prettyDisplayUserId) {
return "-";
}
return <AdminPrettyDisplayID prettyDisplayUserId={user.prettyDisplayUserId} prettyId={user.prettyId} />;
}
function AppUserStatus({ status }) {
const tone = status === "active" ? "running" : "danger";
return (
<span className={`status-badge status-badge--${tone}`}>
<span className="status-point" />
{appUserStatusLabels[status] || status || "-"}
</span>
);
}
function normalizeDetailSeed(source) {
if (source === undefined || source === null) {
return {};
}
if (typeof source === "string" || typeof source === "number") {
return { userId: String(source).trim() };
}
const userId = firstNonEmpty(
source.userId,
source.user_id,
source.assignedUserId,
source.assigned_user_id,
source.targetUserId,
source.target_user_id,
);
return {
...source,
avatar: firstNonEmpty(source.avatar),
defaultDisplayUserId: firstNonEmpty(source.defaultDisplayUserId, source.default_display_user_id),
displayUserId: firstNonEmpty(source.displayUserId, source.display_user_id),
prettyDisplayUserId: firstNonEmpty(source.prettyDisplayUserId, source.pretty_display_user_id),
prettyId: firstNonEmpty(source.prettyId, source.pretty_id),
userId,
username: firstNonEmpty(source.username, source.name, source.account),
};
}
function formatCountry(user) {
return user.countryDisplayName || user.countryName || user.country || "-";
}
function formatGender(gender) {
const value = String(gender || "").toLowerCase();
if (value === "male" || value === "m" || value === "男") {
return "男";
}
if (value === "female" || value === "f" || value === "女") {
return "女";
}
if (value === "non_binary") {
return "非二元";
}
return gender || "-";
}
function formatNumber(value) {
const number = Number(value || 0);
return Number.isFinite(number) ? number.toLocaleString() : "-";
}
function firstNonEmpty(...values) {
for (const value of values) {
const normalized = String(value ?? "").trim();
if (normalized) {
return normalized;
}
}
return "";
}

View File

@ -1,18 +1,103 @@
export const appUserStatusFilters = [
["", "全部"],
["active", "正常"],
["banned", "封禁"]
["", "全部"],
["active", "正常"],
["banned", "封禁"],
];
export const appUserStatusLabels = {
active: "正常",
banned: "封禁",
disabled: "封禁"
active: "正常",
banned: "封禁",
disabled: "封禁",
};
export const genderOptions = [
["", "未设置"],
["male", "男"],
["female", "女"],
["unknown", "未知"]
["", "未设置"],
["male", "男"],
["female", "女"],
["unknown", "未知"],
];
export const prettyLevelTrackOptions = [
["wealth", "财富等级"],
["game", "游戏等级"],
["charm", "魅力等级"],
];
export const prettyPoolStatusOptions = [
["", "全部状态"],
["active", "启用"],
["disabled", "禁用"],
];
export const prettyPoolStatusLabels = {
active: "启用",
disabled: "禁用",
};
export const prettyIdStatusOptions = [
["", "全部状态"],
["available", "可申请"],
["assigned", "已占用"],
["disabled", "禁用"],
["reserved", "保留"],
["released", "已释放"],
["deleted", "已删除"],
];
export const prettyIdStatusLabels = {
available: "可申请",
assigned: "已占用",
disabled: "禁用",
reserved: "保留",
released: "已释放",
deleted: "已删除",
};
export const prettyIdSourceOptions = [
["", "全部来源"],
["pool", "靓号池"],
["admin_grant", "后台发放"],
];
export const prettyIdSourceLabels = {
admin_grant: "后台发放",
pool: "靓号池",
};
export const prettyRuleOptions = [
["aa", "AA · 两连"],
["aaa", "AAA · 三连"],
["aaaa", "AAAA · 四连"],
["aaaaa", "5A · 五连"],
["aaaaaa", "6A · 六连"],
["aaaaaaa", "7A · 七连"],
["aabb", "AABB · 两对子"],
["aabbcc", "AABBCC · 三对子"],
["aabbccdd", "AABBCCDD · 四对子"],
["abab", "ABAB · 交替"],
["ababab", "ABABAB · 三交替"],
["abba", "ABBA · 回文"],
["abcba", "ABCBA · 五位回文"],
["abccba", "ABCCBA · 六位回文"],
["aaab", "AAAB · 三拖一"],
["aaaab", "AAAAB · 四拖一"],
["aaaaab", "AAAAAB · 五拖一"],
["aaabbb", "AAABBB · 双豹子"],
["aaaabbbb", "AAAABBBB · 双炸弹"],
["abc", "ABC · 三顺"],
["abcd", "ABCD · 四顺"],
["abcde", "ABCDE · 五顺"],
["abcdef", "ABCDEF · 六顺"],
["cba", "CBA · 三倒顺"],
["dcba", "DCBA · 四倒顺"],
["edcba", "EDCBA · 五倒顺"],
["fedcba", "FEDCBA · 六倒顺"],
["abcabc", "ABCABC · 顺子重复"],
["date_yyyymmdd", "YYYYMMDD · 生日"],
["date_yymmdd", "YYMMDD · 生日"],
["love", "520/1314 · 寓意"],
["custom_values", "自定义候选"],
["custom_pattern", "自定义模式"],
];
export const prettyRuleValues = prettyRuleOptions.map(([value]) => value);

View File

@ -4,7 +4,13 @@ import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { banAppUser, listAppUsers, setAppUserPassword, unbanAppUser, updateAppUser } from "@/features/app-users/api";
import {
banAppUser,
listAppUsers,
setAppUserPassword,
unbanAppUser,
updateAppUser,
} from "@/features/app-users/api";
import { useAppUserAbilities } from "@/features/app-users/permissions.js";
import { appUserCountrySchema, appUserPasswordSchema, appUserUpdateSchema } from "@/features/app-users/schema";
import { useCountryOptions } from "@/features/host-org/hooks/useCountryOptions.js";
@ -70,7 +76,6 @@ export function useAppUsersPage() {
items: items.map((user) => (patchedUsers[user.userId] ? { ...user, ...patchedUsers[user.userId] } : user)),
};
}, [patchedUsers, queryData]);
useEffect(() => {
setSelectedUserIds([]);
setPatchedUsers({});

View File

@ -0,0 +1,346 @@
import { useMemo, useState } from "react";
import { parseForm } from "@/shared/forms/validation";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import {
createPrettyDisplayIDPool,
generatePrettyDisplayIDs,
grantPrettyDisplayID,
listPrettyDisplayIDPools,
listPrettyDisplayIDs,
setPrettyDisplayIDStatus,
updatePrettyDisplayIDPool,
} from "@/features/app-users/api";
import { useAppUserAbilities } from "@/features/app-users/permissions.js";
import {
prettyDisplayIDGenerateSchema,
prettyDisplayIDGrantSchema,
prettyDisplayIDPoolSchema,
prettyDisplayIDStatusSchema,
} from "@/features/app-users/schema";
const pageSize = 20;
const emptyPoolForm = () => ({
levelTrack: "wealth",
maxLevel: 10,
minLevel: 1,
name: "",
ruleConfigJson: "",
ruleType: "aabbcc",
sortOrder: 0,
status: "active",
});
const emptyGenerateForm = () => ({ count: 100, poolId: "", ruleConfigJson: "", ruleType: "aabbcc" });
const emptyGrantForm = () => ({ displayUserId: "", durationDays: "", reason: "", targetUserId: "" });
const emptyStatusForm = () => ({ prettyId: "", reason: "", status: "available" });
export function usePrettyIdsPage() {
const abilities = useAppUserAbilities();
const { showToast } = useToast();
const [poolPage, setPoolPage] = useState(1);
const [poolStatus, setPoolStatus] = useState("");
const [poolTrack, setPoolTrack] = useState("");
const [idPage, setIdPage] = useState(1);
const [idKeyword, setIdKeyword] = useState("");
const [idStatus, setIdStatus] = useState("");
const [idSource, setIdSource] = useState("");
const [idPoolId, setIdPoolId] = useState("");
const [assignedUserId, setAssignedUserId] = useState("");
const [activeDialog, setActiveDialog] = useState("");
const [activePool, setActivePool] = useState(null);
const [activePrettyID, setActivePrettyID] = useState(null);
const [poolForm, setPoolForm] = useState(emptyPoolForm);
const [generateForm, setGenerateForm] = useState(emptyGenerateForm);
const [grantForm, setGrantForm] = useState(emptyGrantForm);
const [statusForm, setStatusForm] = useState(emptyStatusForm);
const [loadingAction, setLoadingAction] = useState("");
// 靓号池和靓号明细是两个独立分页接口,筛选条件保持服务端字段名,避免在 hook 内再做一次字段映射。
const poolFilters = useMemo(
() => ({
level_track: poolTrack,
status: poolStatus,
}),
[poolStatus, poolTrack],
);
// 靓号明细筛选会直接透传到后台列表接口assigned_user_id 在输入阶段已 trimkeyword 保留原值用于模糊搜索。
const idFilters = useMemo(
() => ({
assigned_user_id: assignedUserId,
keyword: idKeyword,
pool_id: idPoolId,
source: idSource,
status: idStatus,
}),
[assignedUserId, idKeyword, idPoolId, idSource, idStatus],
);
const poolQuery = usePaginatedQuery({
errorMessage: "加载靓号池失败",
fetcher: listPrettyDisplayIDPools,
filters: poolFilters,
page: poolPage,
pageSize,
queryKey: ["pretty-id-pools", poolFilters, poolPage],
});
const idQuery = usePaginatedQuery({
errorMessage: "加载靓号列表失败",
fetcher: listPrettyDisplayIDs,
filters: idFilters,
page: idPage,
pageSize,
queryKey: ["pretty-ids", idFilters, idPage],
});
// 生成弹窗和明细筛选只需要当前已加载池列表;没有额外拉全量,避免页面初始化时放大后台查询。
const poolOptions = useMemo(
() =>
(poolQuery.data.items || []).map((pool) => ({
label: `${pool.name} · ${pool.minLevel}-${pool.maxLevel}`,
value: pool.poolId,
})),
[poolQuery.data.items],
);
const openCreatePool = () => {
setActivePool(null);
setPoolForm(emptyPoolForm());
setActiveDialog("pool");
};
const openEditPool = (pool) => {
setActivePool(pool);
// 编辑弹窗以服务端当前值为准回填,缺失字段只兜底到首版规则,避免把旧数据误清空。
setPoolForm({
levelTrack: pool.levelTrack || "wealth",
maxLevel: pool.maxLevel ?? 10,
minLevel: pool.minLevel ?? 1,
name: pool.name || "",
ruleConfigJson: pool.ruleConfigJson || "",
ruleType: pool.ruleType || "aabbcc",
sortOrder: pool.sortOrder || 0,
status: pool.status || "active",
});
setActiveDialog("pool");
};
const openGenerate = (pool) => {
setActivePool(pool || null);
// 从池行进入时优先使用该池,从工具栏进入时沿用列表筛选池,减少管理员重复选择。
setGenerateForm({
...emptyGenerateForm(),
poolId: pool?.poolId || idPoolId || "",
ruleConfigJson: pool?.ruleConfigJson || "",
ruleType: pool?.ruleType || "aabbcc",
});
setActiveDialog("generate");
};
const openGrant = () => {
setGrantForm(emptyGrantForm());
setActiveDialog("grant");
};
const openStatus = (item) => {
setActivePrettyID(item);
// 状态弹窗默认给出最常用的反向操作:禁用中的号码恢复可用,其余未占用号码默认禁用。
setStatusForm({
prettyId: item?.prettyId || "",
reason: "",
status: item?.status === "disabled" ? "available" : "disabled",
});
setActiveDialog("status");
};
const closeDialog = () => {
setActiveDialog("");
setActivePool(null);
setActivePrettyID(null);
};
const submitPool = async (event) => {
event.preventDefault();
await runAction("pool", activePool ? "靓号池已更新" : "靓号池已创建", async () => {
// 表单字段使用前端 camelCase提交边界统一转换为后端 JSON 的 snake_case。
const form = parseForm(prettyDisplayIDPoolSchema, poolForm);
const payload = {
level_track: form.levelTrack,
max_level: form.maxLevel,
min_level: form.minLevel,
name: form.name,
rule_config_json: form.ruleConfigJson,
rule_type: form.ruleType,
sort_order: form.sortOrder,
status: form.status,
};
if (activePool?.poolId) {
await updatePrettyDisplayIDPool(activePool.poolId, payload);
} else {
await createPrettyDisplayIDPool(payload);
}
closeDialog();
await reloadAll();
});
};
const submitGenerate = async (event) => {
event.preventDefault();
await runAction("generate", "靓号已生成", async () => {
// 批量生成只让后台接收池 ID、规则和数量冲突过滤与批次审计由 user-service 事务完成。
const form = parseForm(prettyDisplayIDGenerateSchema, generateForm);
await generatePrettyDisplayIDs(form.poolId, {
count: form.count,
rule_config_json: form.ruleConfigJson,
rule_type: form.ruleType,
});
closeDialog();
await reloadAll();
});
};
const submitGrant = async (event) => {
event.preventDefault();
await runAction("grant", "靓号已发放", async () => {
const form = parseForm(prettyDisplayIDGrantSchema, grantForm);
// 后台发放用 0 表示长期,正数天数在前端换算成毫秒,服务端只处理 duration_ms 这个统一单位。
const days = Number(form.durationDays || 0);
await grantPrettyDisplayID({
display_user_id: form.displayUserId,
duration_ms: days > 0 ? days * 24 * 60 * 60 * 1000 : 0,
reason: form.reason,
target_user_id: form.targetUserId,
});
closeDialog();
await idQuery.reload();
});
};
const submitStatus = async (event) => {
event.preventDefault();
await runAction("status", "靓号状态已更新", async () => {
// 状态变更只允许未分配号码,前端提交 pretty_id 和目标状态,真正可变状态由后端再次校验。
const form = parseForm(prettyDisplayIDStatusSchema, statusForm);
await setPrettyDisplayIDStatus(form.prettyId, {
reason: form.reason,
status: form.status,
});
closeDialog();
await idQuery.reload();
});
};
const changePoolStatus = (value) => {
setPoolStatus(value);
// 任意筛选变化都回到第一页,避免旧页码在新条件下落到空页。
setPoolPage(1);
};
const changePoolTrack = (value) => {
setPoolTrack(value);
setPoolPage(1);
};
const changeIDKeyword = (value) => {
setIdKeyword(value);
// 明细列表的每个筛选项都会改变总数,页码统一回到第一页。
setIdPage(1);
};
const changeIDStatus = (value) => {
setIdStatus(value);
setIdPage(1);
};
const changeIDSource = (value) => {
setIdSource(value);
setIdPage(1);
};
const changeIDPool = (value) => {
setIdPoolId(value);
setIdPage(1);
};
const changeAssignedUserId = (value) => {
setAssignedUserId(value.trim());
setIdPage(1);
};
const resetPoolFilters = () => {
// 重置筛选后立即回到第一页,保持 URL 之外的页面状态和查询条件同步。
setPoolStatus("");
setPoolTrack("");
setPoolPage(1);
};
const resetIDFilters = () => {
// 靓号列表筛选项彼此独立,重置时一次性清空,避免残留 assigned_user_id 影响排查。
setAssignedUserId("");
setIdKeyword("");
setIdPoolId("");
setIdSource("");
setIdStatus("");
setIdPage(1);
};
const reloadAll = async () => {
// 池配置会影响生成入口和号码列表展示,池相关操作完成后两个分页一起刷新。
await Promise.all([poolQuery.reload(), idQuery.reload()]);
};
const runAction = async (action, successMessage, submitter) => {
// 所有写操作共享 loading、toast 和异常展示;弹窗只在 submitter 内确认上游成功后关闭。
setLoadingAction(action);
try {
await submitter();
showToast(successMessage, "success");
} catch (err) {
showToast(err.message || "操作失败", "error");
} finally {
setLoadingAction("");
}
};
return {
abilities,
activeDialog,
activePool,
activePrettyID,
assignedUserId,
changeAssignedUserId,
changeIDKeyword,
changeIDPool,
changeIDSource,
changeIDStatus,
changePoolStatus,
changePoolTrack,
closeDialog,
generateForm,
grantForm,
idKeyword,
idPage,
idPoolId,
idQuery,
idSource,
idStatus,
loadingAction,
openCreatePool,
openEditPool,
openGenerate,
openGrant,
openStatus,
poolForm,
poolOptions,
poolPage,
poolQuery,
poolStatus,
poolTrack,
reloadAll,
resetIDFilters,
resetPoolFilters,
setGenerateForm,
setGrantForm,
setIdPage,
setPoolForm,
setPoolPage,
setStatusForm,
statusForm,
submitGenerate,
submitGrant,
submitPool,
submitStatus,
};
}

View File

@ -1,5 +1,5 @@
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
import { useCallback, useMemo, useState } from "react";
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { createTextColumnFilter } from "@/shared/ui/tableFilters.js";
@ -122,14 +122,7 @@ export function AppUserBanListPage() {
}
function TargetIdentity({ target }) {
return (
<UserIdentity
avatar={target?.avatar}
name={target?.username}
primaryFallback={target?.displayUserId || target?.userId}
rows={[target?.displayUserId, target?.userId]}
/>
);
return <AdminUserIdentity openInAppUserDetail rows={[target?.displayUserId, target?.userId]} user={target} />;
}
function OperatorIdentity({ operator }) {
@ -138,52 +131,26 @@ function OperatorIdentity({ operator }) {
}
if (operator.type === "admin") {
return (
<UserIdentity
<AdminUserIdentity
name={operator.account}
primaryFallback={operator.adminId}
rows={[operator.name, operator.adminId ? `Admin ID ${operator.adminId}` : "后台管理员"]}
user={operator}
/>
);
}
if (operator.type === "app_user") {
return (
<UserIdentity
avatar={operator.avatar}
<AdminUserIdentity
openInAppUserDetail
name={operator.name}
primaryFallback={operator.displayUserId || operator.userId}
rows={[operator.displayUserId, operator.userId]}
user={operator}
/>
);
}
return <span className="muted">system</span>;
}
function UserIdentity({ avatar, name, primaryFallback, rows }) {
const title = name || primaryFallback || "-";
const visibleRows = rows.filter(Boolean);
return (
<div className={styles.identity}>
<span className={styles.avatar}>
{avatar ? <img src={avatar} alt="" /> : <PersonOutlineOutlined fontSize="small" />}
</span>
<div className={styles.identityText}>
<div className={styles.nameLine}>
<span className={styles.name}>{title}</span>
</div>
{visibleRows.length ? (
<div className={styles.stack}>
{visibleRows.map((value, index) => (
<span className={styles.meta} key={`${value}-${index}`}>
{value}
</span>
))}
</div>
) : null}
</div>
</div>
);
}
function UserStatus({ status }) {
return (
<span className="status-badge status-badge--danger">

View File

@ -4,7 +4,6 @@ import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutl
import KeyboardArrowUpOutlined from "@mui/icons-material/KeyboardArrowUpOutlined";
import LockOpenOutlined from "@mui/icons-material/LockOpenOutlined";
import PasswordOutlined from "@mui/icons-material/PasswordOutlined";
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
import UnfoldMoreOutlined from "@mui/icons-material/UnfoldMoreOutlined";
import Autocomplete from "@mui/material/Autocomplete";
import Checkbox from "@mui/material/Checkbox";
@ -12,6 +11,7 @@ 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 { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
@ -24,15 +24,6 @@ import { appUserStatusFilters, appUserStatusLabels, genderOptions } from "@/feat
import { useAppUsersPage } from "@/features/app-users/hooks/useAppUsersPage.js";
import styles from "@/features/app-users/app-users.module.css";
const columns = [
{
key: "identity",
label: "用户",
width: "minmax(240px, 1.5fr)",
render: (user) => <UserIdentity user={user} />,
},
];
export function AppUserListPage() {
const page = useAppUsersPage();
const items = page.data.items || [];
@ -49,7 +40,9 @@ export function AppUserListPage() {
);
return;
}
page.setSelectedUserIds((current) => Array.from(new Set([...current, ...selectableItems.map((user) => user.userId)])));
page.setSelectedUserIds((current) =>
Array.from(new Set([...current, ...selectableItems.map((user) => user.userId)])),
);
};
const tableColumns = [
{
@ -85,12 +78,15 @@ export function AppUserListPage() {
},
},
{
...columns[0],
key: "identity",
label: "用户",
width: "minmax(240px, 1.5fr)",
filter: createTextColumnFilter({
placeholder: "搜索名称、短 ID、用户 ID",
value: page.query,
onChange: page.changeQuery,
}),
render: (user) => <AdminUserIdentity openInAppUserDetail user={user} />,
},
{
key: "location",
@ -305,26 +301,6 @@ function UserLocation({ page, user }) {
);
}
function UserIdentity({ user }) {
const name = user.username || "-";
const shortId = user.displayUserId || user.userId;
const gender = genderView(user.gender);
return (
<div className={styles.identity}>
<span className={styles.avatar}>
{user.avatar ? <img src={user.avatar} alt="" /> : <PersonOutlineOutlined fontSize="small" />}
</span>
<div className={styles.identityText}>
<div className={styles.nameLine}>
<span className={styles.name}>{name}</span>
<span className={`${styles.gender} ${styles[gender.className]}`}>{gender.symbol}</span>
</div>
<div className={styles.meta}>{shortId}</div>
</div>
</div>
);
}
function UserActions({ page, user }) {
const banned = user.status === "banned" || user.status === "disabled";
return (
@ -412,17 +388,6 @@ function ActionModal({ children, disabled, loading, onClose, onSubmit, open, sec
);
}
function genderView(gender) {
const value = String(gender || "").toLowerCase();
if (value === "male" || value === "m" || value === "男") {
return { className: "genderMale", symbol: "♂" };
}
if (value === "female" || value === "f" || value === "女") {
return { className: "genderFemale", symbol: "♀" };
}
return { className: "genderUnknown", symbol: "-" };
}
function formatNumber(value) {
return Number(value || 0).toLocaleString("zh-CN");
}

View File

@ -1,7 +1,7 @@
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { toPageQuery } from "@/shared/api/query";
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
@ -45,7 +45,12 @@ export function AppUserLoginLogsPage() {
[page, query, regionId, result, userFilter],
);
const loadLogs = useCallback(() => listAppUserLoginLogs(requestQuery), [requestQuery]);
const { data, error, loading: queryLoading, reload } = useAdminQuery(loadLogs, {
const {
data,
error,
loading: queryLoading,
reload,
} = useAdminQuery(loadLogs, {
errorMessage: "加载用户登录日志失败",
initialData: emptyData,
keepPreviousData: true,
@ -149,7 +154,7 @@ export function AppUserLoginLogsPage() {
value: query,
onChange: changeIdentityFilter,
}),
render: (log) => <UserIdentity log={log} />,
render: (log) => <AdminUserIdentity openInAppUserDetail user={log} />,
},
{
key: "ip",
@ -292,24 +297,6 @@ function loginLogRowProps(log, onOpenHistory) {
};
}
function UserIdentity({ log }) {
const name = log.username || "-";
const shortId = log.displayUserId || log.userId || "-";
return (
<div className={styles.identity}>
<span className={styles.avatar}>
{log.avatar ? <img src={log.avatar} alt="" /> : <PersonOutlineOutlined fontSize="small" />}
</span>
<div className={styles.identityText}>
<div className={styles.nameLine}>
<span className={styles.name}>{name}</span>
</div>
<div className={styles.meta}>{shortId}</div>
</div>
</div>
);
}
function ResultBadge({ log }) {
const result = String(log.result || "").toLowerCase();
const blocked = log.blocked || result === "blocked";

View File

@ -0,0 +1,679 @@
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 { useAppUserDetail } from "@/features/app-users/components/AppUserDetailContext.jsx";
import { formatMillis } from "@/shared/utils/time.js";
import {
prettyIdSourceLabels,
prettyIdSourceOptions,
prettyIdStatusLabels,
prettyIdStatusOptions,
prettyLevelTrackOptions,
prettyPoolStatusLabels,
prettyPoolStatusOptions,
prettyRuleOptions,
} from "@/features/app-users/constants.js";
import { usePrettyIdsPage } from "@/features/app-users/hooks/usePrettyIdsPage.js";
import styles from "@/features/app-users/app-users.module.css";
export function AppUserPrettyIdsPage() {
const page = usePrettyIdsPage();
const pools = page.poolQuery.data.items || [];
const prettyIds = page.idQuery.data.items || [];
return (
<section className={styles.root}>
<div className={`${styles.contentPanel} ${styles.prettyContentPanel}`}>
<div className={styles.prettyWorkspace}>
<section className={`${styles.prettyPane} ${styles.prettyPoolPane}`}>
<PaneHeader
actions={
<>
{page.abilities.canPrettyGenerate ? (
<Button
startIcon={<AutoAwesomeOutlined fontSize="small" />}
onClick={() => page.openGenerate(null)}
>
批量生成
</Button>
) : null}
{page.abilities.canPrettyUpdate ? (
<Button
startIcon={<AddCircleOutlineOutlined fontSize="small" />}
variant="primary"
onClick={page.openCreatePool}
>
新增池
</Button>
) : null}
</>
}
count={page.poolQuery.data.total}
title="靓号池"
/>
<div className={styles.prettyFilters}>
<TextField
className={styles.compactControl}
label="等级轨道"
select
size="small"
value={page.poolTrack}
onChange={(event) => page.changePoolTrack(event.target.value)}
>
<MenuItem value="">全部等级轨道</MenuItem>
{prettyLevelTrackOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
className={styles.compactControl}
label="状态"
select
size="small"
value={page.poolStatus}
onChange={(event) => page.changePoolStatus(event.target.value)}
>
{prettyPoolStatusOptions.map(([value, label]) => (
<MenuItem key={value || "all"} value={value}>
{label}
</MenuItem>
))}
</TextField>
<Button startIcon={<RestartAltOutlined fontSize="small" />} onClick={page.resetPoolFilters}>
重置
</Button>
</div>
<DataState
error={page.poolQuery.error}
loading={page.poolQuery.loading}
onRetry={page.poolQuery.reload}
>
<DataTable
className={styles.compactTableFrame}
columns={poolColumns(page)}
items={pools}
minWidth="720px"
pagination={
page.poolQuery.data.total > 0
? {
page: page.poolPage,
pageSize: page.poolQuery.data.pageSize || 20,
total: page.poolQuery.data.total,
onPageChange: page.setPoolPage,
}
: undefined
}
rowKey={(pool) => pool.poolId}
/>
</DataState>
</section>
<section className={`${styles.prettyPane} ${styles.prettyIDPane}`}>
<PaneHeader
actions={
page.abilities.canPrettyGrant ? (
<Button
startIcon={<SendOutlined fontSize="small" />}
variant="primary"
onClick={page.openGrant}
>
后台发放
</Button>
) : null
}
count={page.idQuery.data.total}
title="靓号列表"
/>
<div className={styles.prettyFilters}>
<TextField
className={styles.search}
label="搜索"
size="small"
value={page.idKeyword}
onChange={(event) => page.changeIDKeyword(event.target.value)}
/>
<TextField
className={styles.compactControl}
label="靓号池"
select
size="small"
value={page.idPoolId}
onChange={(event) => page.changeIDPool(event.target.value)}
>
<MenuItem value="">全部靓号池</MenuItem>
{page.poolOptions.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</TextField>
<TextField
className={styles.compactControl}
label="来源"
select
size="small"
value={page.idSource}
onChange={(event) => page.changeIDSource(event.target.value)}
>
{prettyIdSourceOptions.map(([value, label]) => (
<MenuItem key={value || "all"} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
className={styles.compactControl}
label="状态"
select
size="small"
value={page.idStatus}
onChange={(event) => page.changeIDStatus(event.target.value)}
>
{prettyIdStatusOptions.map(([value, label]) => (
<MenuItem key={value || "all"} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
className={styles.userIDControl}
label="占用用户"
size="small"
value={page.assignedUserId}
onChange={(event) => page.changeAssignedUserId(event.target.value)}
/>
<Button startIcon={<RestartAltOutlined fontSize="small" />} onClick={page.resetIDFilters}>
重置
</Button>
</div>
<DataState
error={page.idQuery.error}
loading={page.idQuery.loading}
onRetry={page.idQuery.reload}
>
<DataTable
className={styles.compactTableFrame}
columns={idColumns(page)}
items={prettyIds}
minWidth="980px"
pagination={
page.idQuery.data.total > 0
? {
page: page.idPage,
pageSize: page.idQuery.data.pageSize || 20,
total: page.idQuery.data.total,
onPageChange: page.setIdPage,
}
: undefined
}
rowKey={(item) => item.prettyId}
/>
</DataState>
</section>
</div>
</div>
<PoolDialog page={page} />
<GenerateDialog page={page} />
<GrantDialog page={page} />
<StatusDialog page={page} />
</section>
);
}
function PaneHeader({ actions, count, title }) {
return (
<div className={styles.prettyPaneHeader}>
<div className={styles.prettyPaneTitle}>
<h2>{title}</h2>
<span> {Number(count || 0)} </span>
</div>
<div className={styles.toolbarActions}>{actions}</div>
</div>
);
}
function poolColumns(page) {
return [
{
key: "pool",
label: "靓号池",
width: "minmax(220px, 1.2fr)",
render: (pool) => (
<div className={styles.stack}>
<span className={styles.primaryText}>{pool.name || "-"}</span>
<span className={styles.meta}>{pool.poolId}</span>
<span className={styles.meta}>更新 {formatMillis(pool.updatedAtMs || pool.createdAtMs)}</span>
</div>
),
},
{
key: "rule",
label: "规则 / 等级",
width: "minmax(160px, 0.9fr)",
render: (pool) => (
<div className={styles.stack}>
<span>{ruleLabel(pool.ruleType)}</span>
<span className={styles.meta}>
{trackLabel(pool.levelTrack)} {pool.minLevel}-{pool.maxLevel}
</span>
</div>
),
},
{
key: "status",
label: "状态",
width: "minmax(110px, 0.6fr)",
render: (pool) => <StatusBadge labels={prettyPoolStatusLabels} status={pool.status} />,
},
{
key: "actions",
label: "操作",
width: "104px",
render: (pool) => (
<div className={styles.rowActions}>
{page.abilities.canPrettyUpdate ? (
<ActionIcon label="编辑" onClick={() => page.openEditPool(pool)}>
<EditOutlined fontSize="small" />
</ActionIcon>
) : null}
{page.abilities.canPrettyGenerate ? (
<ActionIcon label="生成" onClick={() => page.openGenerate(pool)}>
<AutoAwesomeOutlined fontSize="small" />
</ActionIcon>
) : null}
</div>
),
},
];
}
function idColumns(page) {
return [
{
key: "display",
label: "靓号",
width: "minmax(190px, 1fr)",
render: (item) => (
<div className={styles.stack}>
<div className={styles.nameLine}>
<span className={styles.name}>{item.displayUserId || "-"}</span>
</div>
<div className={styles.meta}>{item.prettyId}</div>
</div>
),
},
{
key: "pool",
label: "来源 / 池",
width: "minmax(180px, 0.9fr)",
render: (item) => (
<div className={styles.stack}>
<span>{prettyIdSourceLabels[item.source] || item.source || "-"}</span>
<span className={styles.meta}>{item.pool?.name || item.poolId || "-"}</span>
</div>
),
},
{
key: "status",
label: "状态",
width: "minmax(110px, 0.6fr)",
render: (item) => <StatusBadge labels={prettyIdStatusLabels} status={item.status} />,
},
{
key: "assigned",
label: "占用 / 时间",
width: "minmax(190px, 0.9fr)",
render: (item) => (
<div className={styles.stack}>
<AssignedUserCell item={item} />
<span className={styles.meta}>
{item.assignedAtMs ? formatMillis(item.assignedAtMs) : formatMillis(item.createdAtMs)}
</span>
<span className={styles.meta}>
{item.releasedAtMs ? `释放 ${formatMillis(item.releasedAtMs)}` : "-"}
</span>
</div>
),
},
{
key: "actions",
label: "操作",
width: "minmax(96px, 0.5fr)",
render: (item) =>
page.abilities.canPrettyUpdate && canChangePrettyStatus(item) ? (
<div className={styles.rowActions}>
<ActionIcon label="状态" onClick={() => page.openStatus(item)}>
<SettingsOutlined fontSize="small" />
</ActionIcon>
</div>
) : (
"-"
),
},
];
}
function AssignedUserCell({ item }) {
const { openAppUserDetail } = useAppUserDetail();
const assignedUserId = formatEntityID(item.assignedUserId);
if (!item.assignedUserId || !openAppUserDetail) {
return <span>{assignedUserId}</span>;
}
return (
<button
className={styles.countryButton}
type="button"
onClick={(event) => {
event.stopPropagation();
openAppUserDetail({
displayUserId: assignedUserId,
prettyDisplayUserId: item.displayUserId,
prettyId: item.prettyId,
userId: item.assignedUserId,
});
}}
>
{assignedUserId}
</button>
);
}
function PoolDialog({ page }) {
const editing = Boolean(page.activePool);
return (
<AdminFormDialog
loading={page.loadingAction === "pool"}
open={page.activeDialog === "pool"}
submitDisabled={!page.abilities.canPrettyUpdate || page.loadingAction === "pool"}
title={editing ? "编辑靓号池" : "新增靓号池"}
onClose={page.closeDialog}
onSubmit={page.submitPool}
>
<AdminFormSection title="池配置">
<TextField
label="名称"
required
value={page.poolForm.name}
onChange={(event) => page.setPoolForm({ ...page.poolForm, name: event.target.value })}
/>
<TextField
label="等级轨道"
required
select
value={page.poolForm.levelTrack}
onChange={(event) => page.setPoolForm({ ...page.poolForm, levelTrack: event.target.value })}
>
{prettyLevelTrackOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<div className={styles.formGridTwo}>
<TextField
label="最低等级"
required
type="number"
value={page.poolForm.minLevel}
onChange={(event) => page.setPoolForm({ ...page.poolForm, minLevel: event.target.value })}
/>
<TextField
label="最高等级"
required
type="number"
value={page.poolForm.maxLevel}
onChange={(event) => page.setPoolForm({ ...page.poolForm, maxLevel: event.target.value })}
/>
</div>
<div className={styles.formGridTwo}>
<TextField
label="规则"
required
select
value={page.poolForm.ruleType}
onChange={(event) => page.setPoolForm({ ...page.poolForm, ruleType: event.target.value })}
>
{prettyRuleOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
label="状态"
required
select
value={page.poolForm.status}
onChange={(event) => page.setPoolForm({ ...page.poolForm, status: event.target.value })}
>
{prettyPoolStatusOptions
.filter(([value]) => value)
.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
</div>
<TextField
label="排序"
type="number"
value={page.poolForm.sortOrder}
onChange={(event) => page.setPoolForm({ ...page.poolForm, sortOrder: event.target.value })}
/>
<TextField
label="规则配置"
minRows={3}
multiline
value={page.poolForm.ruleConfigJson}
onChange={(event) => page.setPoolForm({ ...page.poolForm, ruleConfigJson: event.target.value })}
/>
</AdminFormSection>
</AdminFormDialog>
);
}
function GenerateDialog({ page }) {
return (
<AdminFormDialog
loading={page.loadingAction === "generate"}
open={page.activeDialog === "generate"}
submitDisabled={!page.abilities.canPrettyGenerate || page.loadingAction === "generate"}
title="批量生成靓号"
onClose={page.closeDialog}
onSubmit={page.submitGenerate}
>
<AdminFormSection title="生成配置">
<TextField
label="靓号池"
required
select
value={page.generateForm.poolId}
onChange={(event) => page.setGenerateForm({ ...page.generateForm, poolId: event.target.value })}
>
{page.poolOptions.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</TextField>
<TextField
label="规则"
required
select
value={page.generateForm.ruleType}
onChange={(event) => page.setGenerateForm({ ...page.generateForm, ruleType: event.target.value })}
>
{prettyRuleOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
label="生成数量"
required
type="number"
value={page.generateForm.count}
onChange={(event) => page.setGenerateForm({ ...page.generateForm, count: event.target.value })}
/>
<TextField
label="规则配置"
minRows={3}
multiline
value={page.generateForm.ruleConfigJson}
onChange={(event) =>
page.setGenerateForm({ ...page.generateForm, ruleConfigJson: event.target.value })
}
/>
</AdminFormSection>
</AdminFormDialog>
);
}
function GrantDialog({ page }) {
return (
<AdminFormDialog
loading={page.loadingAction === "grant"}
open={page.activeDialog === "grant"}
submitDisabled={!page.abilities.canPrettyGrant || page.loadingAction === "grant"}
title="后台发放靓号"
onClose={page.closeDialog}
onSubmit={page.submitGrant}
>
<AdminFormSection title="发放信息">
<TextField
label="用户 ID"
required
value={page.grantForm.targetUserId}
onChange={(event) => page.setGrantForm({ ...page.grantForm, targetUserId: event.target.value })}
/>
<TextField
label="靓号内容"
required
value={page.grantForm.displayUserId}
onChange={(event) => page.setGrantForm({ ...page.grantForm, displayUserId: event.target.value })}
/>
<TextField
label="有效天数"
type="number"
value={page.grantForm.durationDays}
onChange={(event) => page.setGrantForm({ ...page.grantForm, durationDays: event.target.value })}
/>
<TextField
label="原因"
value={page.grantForm.reason}
onChange={(event) => page.setGrantForm({ ...page.grantForm, reason: event.target.value })}
/>
</AdminFormSection>
</AdminFormDialog>
);
}
function StatusDialog({ page }) {
return (
<AdminFormDialog
loading={page.loadingAction === "status"}
open={page.activeDialog === "status"}
submitDisabled={!page.abilities.canPrettyUpdate || page.loadingAction === "status"}
title="修改靓号状态"
onClose={page.closeDialog}
onSubmit={page.submitStatus}
>
<AdminFormSection title="状态信息">
<TextField disabled label="靓号" value={page.activePrettyID?.displayUserId || ""} />
<TextField
label="状态"
required
select
value={page.statusForm.status}
onChange={(event) => page.setStatusForm({ ...page.statusForm, status: event.target.value })}
>
{prettyIdStatusOptions
.filter(([value]) => ["available", "disabled", "reserved"].includes(value))
.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
label="原因"
value={page.statusForm.reason}
onChange={(event) => page.setStatusForm({ ...page.statusForm, reason: event.target.value })}
/>
</AdminFormSection>
</AdminFormDialog>
);
}
function ActionIcon({ children, label, onClick }) {
return (
<Tooltip arrow title={label}>
<span>
<IconButton label={label} onClick={onClick}>
{children}
</IconButton>
</span>
</Tooltip>
);
}
function StatusBadge({ labels, status }) {
return (
<span className={`status-badge status-badge--${statusTone(status)}`}>
<span className="status-point" />
{labels[status] || status || "-"}
</span>
);
}
function statusTone(status) {
if (status === "active" || status === "available") {
return "running";
}
if (status === "assigned" || status === "reserved") {
return "warning";
}
if (status === "released" || status === "deleted") {
return "stopped";
}
return "danger";
}
function trackLabel(track) {
return prettyLevelTrackOptions.find(([value]) => value === track)?.[1] || track || "-";
}
function ruleLabel(ruleType) {
return prettyRuleOptions.find(([value]) => value === ruleType)?.[1] || ruleType || "-";
}
function formatEntityID(value) {
if (value === 0 || value === "0" || value === "" || value === undefined || value === null) {
return "-";
}
return String(value);
}
function canChangePrettyStatus(item) {
const assigned = item.assignedUserId;
const unassigned =
assigned === 0 || assigned === "0" || assigned === "" || assigned === undefined || assigned === null;
return ["available", "disabled", "reserved"].includes(item.status) && unassigned;
}

View File

@ -2,13 +2,17 @@ import { useAuth } from "@/app/auth/AuthProvider.jsx";
import { PERMISSIONS } from "@/app/permissions";
export function useAppUserAbilities() {
const { can } = useAuth();
const { can } = useAuth();
return {
canPassword: can(PERMISSIONS.appUserPassword),
canStatus: can(PERMISSIONS.appUserStatus),
canUpdate: can(PERMISSIONS.appUserUpdate),
canUpload: can(PERMISSIONS.uploadCreate),
canView: can(PERMISSIONS.appUserView)
};
return {
canPassword: can(PERMISSIONS.appUserPassword),
canPrettyGenerate: can(PERMISSIONS.prettyIdGenerate),
canPrettyGrant: can(PERMISSIONS.prettyIdGrant),
canPrettyUpdate: can(PERMISSIONS.prettyIdUpdate),
canPrettyView: can(PERMISSIONS.prettyIdView),
canStatus: can(PERMISSIONS.appUserStatus),
canUpdate: can(PERMISSIONS.appUserUpdate),
canUpload: can(PERMISSIONS.uploadCreate),
canView: can(PERMISSIONS.appUserView),
};
}

View File

@ -25,4 +25,12 @@ export const appUserRoutes = [
path: "/app/users/bans",
permission: PERMISSIONS.appUserView,
},
{
label: "靓号管理",
loader: () => import("./pages/AppUserPrettyIdsPage.jsx").then((module) => module.AppUserPrettyIdsPage),
menuCode: MENU_CODES.appUserPrettyIds,
pageKey: "app-user-pretty-ids",
path: "/app/users/pretty-ids",
permission: PERMISSIONS.prettyIdView,
},
];

View File

@ -1,19 +1,120 @@
import { z } from "zod";
export const appUserUpdateSchema = z.object({
avatar: z.string().trim().max(512, "头像不能超过 512 个字符").optional().default(""),
gender: z.string().trim().max(32, "性别不能超过 32 个字符").optional().default(""),
username: z.string().trim().max(64, "用户名称不能超过 64 个字符").optional().default("")
avatar: z.string().trim().max(512, "头像不能超过 512 个字符").optional().default(""),
gender: z.string().trim().max(32, "性别不能超过 32 个字符").optional().default(""),
username: z.string().trim().max(64, "用户名称不能超过 64 个字符").optional().default(""),
});
export const appUserCountrySchema = z.object({
country: z.string().trim().min(2, "请选择国家").max(16, "国家码不能超过 16 个字符")
country: z.string().trim().min(2, "请选择国家").max(16, "国家码不能超过 16 个字符"),
});
export const appUserPasswordSchema = z.object({
password: z.string().trim().min(6, "密码至少 6 位").max(128, "密码不能超过 128 个字符")
password: z.string().trim().min(6, "密码至少 6 位").max(128, "密码不能超过 128 个字符"),
});
// 与服务端后台发放规则保持一致:只允许 Unicode 字母、数字和组合标记空格、标点、emoji 都在前端先拦截。
const prettyContentPattern = /^[\p{L}\p{N}\p{M}]+$/u;
const prettyRuleValues = [
"aa",
"aaa",
"aaaa",
"aaaaa",
"aaaaaa",
"aaaaaaa",
"aabb",
"aabbcc",
"aabbccdd",
"abab",
"ababab",
"abba",
"abcba",
"abccba",
"aaab",
"aaaab",
"aaaaab",
"aaabbb",
"aaaabbbb",
"abc",
"abcd",
"abcde",
"abcdef",
"cba",
"dcba",
"edcba",
"fedcba",
"abcabc",
"date_yyyymmdd",
"date_yymmdd",
"love",
"custom_values",
"custom_pattern",
] as const;
// 靓号池只配置等级区间和生成规则,等级上下限在表单层先做顺序校验,后端仍会再次校验权限和状态。
export const prettyDisplayIDPoolSchema = z
.object({
levelTrack: z.enum(["wealth", "game", "charm"], "请选择等级轨道"),
maxLevel: z.coerce.number().int("最高等级必须是整数").min(0, "最高等级不能小于 0"),
minLevel: z.coerce.number().int("最低等级必须是整数").min(0, "最低等级不能小于 0"),
name: z.string().trim().min(1, "请输入靓号池名称").max(128, "靓号池名称不能超过 128 个字符"),
ruleConfigJson: z.string().trim().max(4000, "规则配置不能超过 4000 个字符").optional().default(""),
ruleType: z.enum(prettyRuleValues, "请选择生成规则"),
sortOrder: z.coerce.number().int("排序必须是整数").min(0, "排序不能小于 0").optional().default(0),
status: z.enum(["active", "disabled"], "请选择状态"),
})
.refine((value) => value.maxLevel >= value.minLevel, {
message: "最高等级不能小于最低等级",
path: ["maxLevel"],
});
// 批量生成支持市面常见靓号模式,单次最多 1000 个,避免一次生成把候选空间和数据库锁时间放大。
export const prettyDisplayIDGenerateSchema = z.object({
count: z.coerce.number().int("生成数量必须是整数").min(1, "生成数量至少 1 个").max(1000, "单次最多生成 1000 个"),
poolId: z.string().trim().min(1, "请选择靓号池"),
ruleConfigJson: z.string().trim().max(4000, "规则配置不能超过 4000 个字符").optional().default(""),
ruleType: z.enum(prettyRuleValues, "请选择生成规则"),
});
// 后台发放允许管理员输入非数字靓号durationDays 为空或 0 都会转成 duration_ms=0表示长期持有。
export const prettyDisplayIDGrantSchema = z.object({
displayUserId: z
.string()
.trim()
.min(1, "请输入靓号内容")
.max(64, "靓号不能超过 64 个字符")
.regex(prettyContentPattern, "靓号只支持字母、数字和组合标记"),
durationDays: z
.string()
.trim()
.optional()
.default("")
.refine((value) => {
if (!value) {
return true;
}
const days = Number(value);
return Number.isInteger(days) && days >= 0 && days <= 36500;
}, "有效天数必须是 0 到 36500 的整数"),
reason: z.string().trim().max(64, "原因不能超过 64 个字符").optional().default(""),
targetUserId: z
.string()
.trim()
.regex(/^[1-9]\d*$/, "请输入用户 ID"),
});
// 状态变更只暴露未占用号码的 available/disabled/reservedassigned 号码必须通过释放或覆盖链路改变。
export const prettyDisplayIDStatusSchema = z.object({
prettyId: z.string().trim().min(1, "请选择靓号"),
reason: z.string().trim().max(64, "原因不能超过 64 个字符").optional().default(""),
status: z.enum(["available", "disabled", "reserved"], "请选择状态"),
});
export type AppUserUpdateForm = z.infer<typeof appUserUpdateSchema>;
export type AppUserCountryForm = z.infer<typeof appUserCountrySchema>;
export type AppUserPasswordForm = z.infer<typeof appUserPasswordSchema>;
export type PrettyDisplayIDPoolForm = z.infer<typeof prettyDisplayIDPoolSchema>;
export type PrettyDisplayIDGenerateForm = z.infer<typeof prettyDisplayIDGenerateSchema>;
export type PrettyDisplayIDGrantForm = z.infer<typeof prettyDisplayIDGrantSchema>;
export type PrettyDisplayIDStatusForm = z.infer<typeof prettyDisplayIDStatusSchema>;

View File

@ -39,6 +39,8 @@ export interface CumulativeRechargeRewardConfigPayload {
export interface CumulativeRechargeRewardGrantUserDto {
userId?: number;
displayUserId?: string;
prettyDisplayUserId?: string;
prettyId?: string;
username?: string;
avatar?: string;
}
@ -145,6 +147,8 @@ function normalizeGrant(item: RawGrant): CumulativeRechargeRewardGrantDto {
user: {
userId: numberValue(rawUser.userId ?? rawUser.user_id),
displayUserId: stringValue(rawUser.displayUserId ?? rawUser.display_user_id),
prettyDisplayUserId: stringValue(rawUser.prettyDisplayUserId ?? rawUser.pretty_display_user_id),
prettyId: stringValue(rawUser.prettyId ?? rawUser.pretty_id),
username: stringValue(rawUser.username),
avatar: stringValue(rawUser.avatar),
},

View File

@ -1,9 +1,9 @@
import Avatar from "@mui/material/Avatar";
import { CumulativeRechargeRewardConfigDrawer } from "@/features/cumulative-recharge-reward/components/CumulativeRechargeRewardConfigDrawer.jsx";
import { CumulativeRechargeRewardConfigSummary } from "@/features/cumulative-recharge-reward/components/CumulativeRechargeRewardConfigSummary.jsx";
import { useCumulativeRechargeRewardPage } from "@/features/cumulative-recharge-reward/hooks/useCumulativeRechargeRewardPage.js";
import styles from "@/features/cumulative-recharge-reward/cumulative-recharge-reward.module.css";
import { AdminListBody, AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
@ -179,21 +179,7 @@ export function CumulativeRechargeRewardPage() {
function GrantUser({ grant }) {
const user = grant.user || {};
return (
<div className={styles.identity}>
<Avatar
alt={user.username || String(grant.userId)}
src={user.avatar || ""}
sx={{ width: 36, height: 36 }}
/>
<div className={styles.stack}>
<span className={styles.name}>{user.username || `用户 ${grant.userId}`}</span>
<span className={styles.meta}>
{user.displayUserId ? `短号 ${user.displayUserId}` : `ID ${grant.userId}`}
</span>
</div>
</div>
);
return <AdminUserIdentity openInAppUserDetail user={{ ...user, userId: user.userId || grant.userId }} />;
}
function grantStatusLabel(status) {

View File

@ -9,6 +9,13 @@ export interface TaskDefinitionDto {
metricType: string;
title: string;
description?: string;
audienceType: string;
iconKey: string;
iconUrl: string;
actionType: string;
actionParam: string;
actionPayloadJson: string;
dimensionFilterJson: string;
targetValue: number;
targetUnit: string;
rewardCoinAmount: number;
@ -29,6 +36,13 @@ export interface TaskDefinitionPayload {
metric_type: string;
title: string;
description: string;
audience_type: string;
icon_key: string;
icon_url: string;
action_type: string;
action_param: string;
action_payload_json: string;
dimension_filter_json: string;
target_value: number;
target_unit: string;
reward_coin_amount: number;
@ -100,6 +114,13 @@ function normalizeTaskDefinition(task: RawTaskDefinitionDto): TaskDefinitionDto
metricType: stringValue(task.metricType ?? task.metric_type),
title: stringValue(task.title),
description: stringValue(task.description),
audienceType: stringValue(task.audienceType ?? task.audience_type),
iconKey: stringValue(task.iconKey ?? task.icon_key),
iconUrl: stringValue(task.iconUrl ?? task.icon_url),
actionType: stringValue(task.actionType ?? task.action_type),
actionParam: stringValue(task.actionParam ?? task.action_param),
actionPayloadJson: stringValue(task.actionPayloadJson ?? task.action_payload_json),
dimensionFilterJson: stringValue(task.dimensionFilterJson ?? task.dimension_filter_json),
targetValue: numberValue(task.targetValue ?? task.target_value),
targetUnit: stringValue(task.targetUnit ?? task.target_unit),
rewardCoinAmount: numberValue(task.rewardCoinAmount ?? task.reward_coin_amount),

View File

@ -29,3 +29,47 @@ export const taskUnitLabels = {
count: "次数",
minute: "分钟",
};
export const taskAudienceOptions = [
["all", "全部用户"],
["newbie", "新人专属"],
];
export const taskAudienceLabels = {
all: "全部用户",
newbie: "新人专属",
};
export const taskActionOptions = [
["none", "无跳转"],
["app", "APP 公共跳转"],
];
export const taskActionLabels = {
app: "APP 公共跳转",
explore_game: "Explore 游戏 H5",
gift_panel: "礼物面板",
gift_panel_specific: "指定礼物面板",
room: "指定房间",
room_game: "指定房内拉起游戏",
none: "无跳转",
room_random_window: "随机房内拉起窗口",
room_window: "指定房内拉起窗口",
room_random: "随机房间",
room_random_game: "随机房间并打开游戏",
wallet: "钱包",
};
export const taskMetricOptions = [
["game_spend_coin", "游戏消耗金币"],
["game_dice_play_count", "玩 Dice Battle 局数"],
["game_rock_play_count", "玩 Rock Paper Scissors 局数"],
["gift_spend_coin", "普通礼物消耗金币"],
["gift_send_count", "赠送礼物次数"],
["lucky_gift_spend_coin", "幸运礼物消耗金币"],
["lucky_gift_send_count", "赠送幸运礼物次数"],
["recharge_coin", "累计充值金币"],
["cp_relationship_created", "组成 CP"],
["mic_online_minute", "麦克风使用时长"],
["mood_publish_count", "发布心情"],
];

View File

@ -43,9 +43,64 @@
line-height: 1;
}
.taskIcon {
display: inline-flex;
width: 32px;
height: 32px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
background: var(--surface-muted);
color: var(--text-secondary);
font-size: var(--admin-font-size-sm);
font-weight: 700;
}
.taskIcon img {
width: 100%;
height: 100%;
object-fit: cover;
}
.statusCell {
display: inline-flex;
min-width: 0;
align-items: center;
gap: var(--space-1);
}
.jumpConfigGrid {
display: grid;
grid-template-columns: minmax(220px, 0.72fr) minmax(320px, 1.28fr);
align-items: start;
gap: var(--space-3);
width: 100%;
}
.jumpConfigSide {
display: grid;
min-width: 0;
gap: var(--space-3);
}
.iconUpload {
min-width: 0;
}
.flutterJumpField {
min-width: 0;
}
.flutterJumpField :global(.MuiInputBase-root) {
min-height: 162px;
align-items: flex-start;
}
@media (max-width: 760px) {
.jumpConfigGrid {
grid-template-columns: 1fr;
}
}

View File

@ -10,23 +10,33 @@ import {
} from "@/features/daily-tasks/api";
import { useDailyTaskAbilities } from "@/features/daily-tasks/permissions.js";
import { dailyTaskFormSchema } from "@/features/daily-tasks/schema";
import { buildAppJumpParam } from "@/shared/app-jump/appJumpConfig.js";
const pageSize = 50;
const emptyData = { items: [], page: 1, pageSize, total: 0 };
const emptyForm = (task = {}) => ({
category: task.category || "",
description: task.description || "",
effectiveFrom: msToDatetimeLocal(task.effectiveFromMs),
effectiveTo: msToDatetimeLocal(task.effectiveToMs),
metricType: task.metricType || "",
rewardCoinAmount: task.rewardCoinAmount === 0 || task.rewardCoinAmount ? String(task.rewardCoinAmount) : "",
sortOrder: task.sortOrder === 0 || task.sortOrder ? String(task.sortOrder) : "0",
status: task.status || "draft",
targetUnit: task.targetUnit || "count",
targetValue: task.targetValue === 0 || task.targetValue ? String(task.targetValue) : "",
taskType: task.taskType || "daily",
title: task.title || "",
});
const emptyForm = (task = {}) => {
const actionType = task.actionType || "none";
const appJumpParam = taskAppJumpParam(actionType, task.actionParam, task.actionPayloadJson);
return {
appJumpParam,
actionParam: task.actionParam || "",
actionType: actionType === "none" ? "none" : appJumpTypeFromParam(appJumpParam),
category: task.category || "",
description: task.description || "",
effectiveFrom: msToDatetimeLocal(task.effectiveFromMs),
effectiveTo: msToDatetimeLocal(task.effectiveToMs),
iconUrl: task.iconUrl || "",
metricType: task.metricType || "",
rewardCoinAmount: task.rewardCoinAmount === 0 || task.rewardCoinAmount ? String(task.rewardCoinAmount) : "",
sortOrder: task.sortOrder === 0 || task.sortOrder ? String(task.sortOrder) : "0",
status: task.status || "draft",
targetUnit: task.targetUnit || "count",
targetValue: task.targetValue === 0 || task.targetValue ? String(task.targetValue) : "",
taskType: task.taskType || "daily",
title: task.title || "",
};
};
export function useDailyTasksPage() {
const abilities = useDailyTaskAbilities();
@ -34,7 +44,6 @@ export function useDailyTasksPage() {
const [query, setQuery] = useState("");
const [status, setStatus] = useState("");
const [taskType, setTaskType] = useState("");
const [category, setCategory] = useState("");
const [page, setPage] = useState(1);
const [activeAction, setActiveAction] = useState("");
const [selectedTask, setSelectedTask] = useState(null);
@ -42,12 +51,11 @@ export function useDailyTasksPage() {
const [loadingAction, setLoadingAction] = useState("");
const filters = useMemo(
() => ({
category,
keyword: query,
status,
task_type: taskType,
}),
[category, query, status, taskType],
[query, status, taskType],
);
const {
data = emptyData,
@ -66,13 +74,11 @@ export function useDailyTasksPage() {
const changeQuery = resetSetter(setQuery, setPage);
const changeStatus = resetSetter(setStatus, setPage);
const changeTaskType = resetSetter(setTaskType, setPage);
const changeCategory = resetSetter(setCategory, setPage);
const resetFilters = () => {
setQuery("");
setStatus("");
setTaskType("");
setCategory("");
setPage(1);
};
@ -96,6 +102,16 @@ export function useDailyTasksPage() {
setSelectedTask(null);
};
const changeFormActionType = (actionType) => {
const nextAppJumpParam = actionType === "app" ? buildAppJumpParam({ target: "wallet" }) : "";
setForm((current) => ({
...current,
actionParam: "",
actionType: actionType === "app" ? "wallet" : "none",
appJumpParam: nextAppJumpParam,
}));
};
const submitTask = async (event) => {
event.preventDefault();
const editing = activeAction === "edit";
@ -144,8 +160,8 @@ export function useDailyTasksPage() {
return {
abilities,
activeAction,
category,
changeCategory,
appJumpTypeFromParam,
changeFormActionType,
changeQuery,
changeStatus,
changeTaskType,
@ -171,12 +187,25 @@ export function useDailyTasksPage() {
};
}
function buildTaskPayload(form) {
export function buildTaskPayload(form) {
// 每日任务现在和 banner/弹窗共用公共 APP 跳转 JSONaction_type/action_param 继续回填短字段,保证旧 H5 或旧 App 只读短字段时仍能打开。
const appJumpEnabled = form.actionType !== "none";
const actionPayloadJSON = appJumpEnabled ? normalizedJSONObject(form.appJumpParam || defaultAppJumpParam()) : "{}";
const actionType = appJumpEnabled ? appJumpTypeFromParam(actionPayloadJSON) : "none";
return {
category: form.category.trim(),
action_param: appJumpEnabled ? actionParamFromAppJump(actionPayloadJSON, form.actionParam) : "",
action_payload_json: actionPayloadJSON,
action_type: actionType,
// 人群、维度过滤、图标键已从表单移除:任务默认全量用户可见,不做事件维度限制,图标只使用上传 URL。
audience_type: "all",
category: hiddenTaskCategory(form),
description: form.description.trim(),
dimension_filter_json: "{}",
effective_from_ms: datetimeLocalToMs(form.effectiveFrom),
effective_to_ms: datetimeLocalToMs(form.effectiveTo),
icon_key: "",
icon_url: form.iconUrl.trim(),
metric_type: form.metricType.trim(),
reward_coin_amount: Number(form.rewardCoinAmount),
sort_order: Number(form.sortOrder || 0),
@ -188,6 +217,98 @@ function buildTaskPayload(form) {
};
}
export function defaultAppJumpParam() {
return buildAppJumpParam({ target: "wallet" });
}
function taskAppJumpParam(actionType, actionParam, actionPayloadJSON) {
const parsed = parseObjectJSON(actionPayloadJSON);
if (Object.keys(parsed).length > 0 && String(parsed.type || "").trim()) {
return JSON.stringify(parsed);
}
const scalarParam = String(actionParam || "").trim();
switch (String(actionType || "").trim()) {
case "wallet":
return buildAppJumpParam({ target: "wallet" });
case "room_random":
return buildAppJumpParam({ target: "room_random" });
case "room_random_game":
return buildAppJumpParam({ gameId: scalarParam, target: "room_random_game" });
case "gift_panel":
return buildAppJumpParam({ target: "room_random_window", window: "gift_panel" });
case "gift_panel_specific":
return buildAppJumpParam({ giftId: scalarParam, target: "room_random_window", window: "gift_panel" });
case "explore_game":
return buildAppJumpParam({ gameCode: scalarParam, target: "explore_game" });
case "room":
case "room_window":
case "room_random_window":
case "room_game":
return buildAppJumpParam({ target: actionType });
default:
return "";
}
}
export function appJumpTypeFromParam(actionPayloadJSON) {
const payload = parseObjectJSON(actionPayloadJSON);
return String(payload.type || "").trim() || "wallet";
}
function actionParamFromAppJump(actionPayloadJSON, fallback = "") {
const payload = parseObjectJSON(actionPayloadJSON);
const target = String(payload.type || "").trim();
if (target === "explore_game") {
return String(payload.game_code || payload.gameCode || fallback || "").trim();
}
if (target === "room_game" || target === "room_random_game") {
return String(payload.game_id || payload.gameId || fallback || "").trim();
}
if (target === "room" || target === "room_window") {
return String(payload.room_id || payload.roomId || fallback || "").trim();
}
if (target === "room_window" || target === "room_random_window") {
if (String(payload.window || "").trim() === "game_window") {
return String(payload.game_id || payload.gameId || fallback || "").trim();
}
return String(payload.gift_id || payload.giftId || fallback || "").trim();
}
return String(fallback || "").trim();
}
function hiddenTaskCategory(form) {
const value = String(form.category || form.metricType || form.title || "task").trim();
return value.slice(0, 32) || "task";
}
function normalizedJSONObject(value) {
const trimmed = String(value || "").trim();
if (!trimmed) {
return "{}";
}
try {
const parsed = JSON.parse(trimmed);
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") {
return "{}";
}
return JSON.stringify(parsed);
} catch {
return trimmed;
}
}
function parseObjectJSON(value) {
const normalized = normalizedJSONObject(value);
try {
const parsed = JSON.parse(normalized);
return parsed && !Array.isArray(parsed) && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
function resetSetter(setter, setPage) {
return (value) => {
setter(value);

View File

@ -21,8 +21,13 @@ import {
AdminRowActions,
} from "@/shared/ui/AdminListLayout.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { AppJumpConfigField } from "@/shared/ui/AppJumpConfigField.jsx";
import { UploadField } from "@/shared/ui/UploadField.jsx";
import { formatMillis } from "@/shared/utils/time.js";
import {
taskActionLabels,
taskActionOptions,
taskMetricOptions,
taskStatusLabels,
taskStatusOptions,
taskTypeOptions,
@ -46,7 +51,20 @@ const baseColumns = [
render: (task) => (
<div className={styles.stack}>
<span>{task.metricType}</span>
<span className={styles.meta}>{task.category}</span>
<span className={styles.meta}>
{formatNumber(task.targetValue)} {taskUnitLabel(task.targetUnit)}
</span>
</div>
),
},
{
key: "display",
label: "属性 / 跳转",
width: "minmax(220px, 1fr)",
render: (task) => (
<div className={styles.stack}>
<span>{taskActionLabel(task.actionType)}</span>
<span className={styles.meta}>{task.actionParam || task.actionPayloadJson || "-"}</span>
</div>
),
},
@ -122,14 +140,7 @@ export function DailyTaskListPage() {
};
}
if (column.key === "metric") {
return {
...column,
filter: createTextColumnFilter({
placeholder: "搜索分类",
value: page.category,
onChange: page.changeCategory,
}),
};
return column;
}
if (column.key === "status") {
return {
@ -164,7 +175,7 @@ export function DailyTaskListPage() {
onChange={page.changeTaskType}
/>
<AdminFilterResetButton
disabled={!page.query && !page.status && !page.taskType && !page.category}
disabled={!page.query && !page.status && !page.taskType}
onClick={page.resetFilters}
/>
</>
@ -182,7 +193,7 @@ export function DailyTaskListPage() {
<DataTable
columns={columns}
items={items}
minWidth="1340px"
minWidth="1560px"
pagination={
total > 0
? {
@ -214,6 +225,7 @@ export function DailyTaskListPage() {
function TaskIdentity({ task }) {
return (
<div className={styles.identity}>
<TaskIconPreview task={task} />
<span className={styles.typeBadge}>{taskTypeLabel(task.taskType)}</span>
<div className={styles.identityText}>
<span className={styles.name}>{task.title || task.taskId}</span>
@ -223,6 +235,15 @@ function TaskIdentity({ task }) {
);
}
function TaskIconPreview({ task }) {
const label = String(task.iconKey || "任务").slice(0, 2);
return (
<span className={styles.taskIcon} title={task.iconKey || task.iconUrl || "任务图标"}>
{task.iconUrl ? <img alt="" src={task.iconUrl} /> : label}
</span>
);
}
function TaskStatusSwitch({ page, task }) {
const checked = task.status === "active";
const disabled =
@ -299,13 +320,6 @@ function TaskFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
value={form.title}
onChange={(event) => page.setForm({ ...form, title: event.target.value })}
/>
<TextField
disabled={disabled}
label="任务分类"
required
value={form.category}
onChange={(event) => page.setForm({ ...form, category: event.target.value })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="目标与奖励">
@ -314,9 +328,16 @@ function TaskFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
disabled={disabled}
label="指标类型"
required
select
value={form.metricType}
onChange={(event) => page.setForm({ ...form, metricType: event.target.value })}
/>
>
{taskMetricOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
disabled={disabled}
label="目标单位"
@ -356,6 +377,54 @@ function TaskFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="展示与跳转">
<div className={styles.jumpConfigGrid}>
<div className={styles.jumpConfigSide}>
<TextField
disabled={disabled}
label="跳转类型"
required
select
value={form.actionType === "none" ? "none" : "app"}
onChange={(event) => page.changeFormActionType(event.target.value)}
>
{taskActionOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<UploadField
className={styles.iconUpload}
density="compact"
disabled={disabled || !page.abilities.canUpload}
label="任务图标"
value={form.iconUrl}
onChange={(iconUrl) => page.setForm({ ...form, iconUrl })}
/>
</div>
{form.actionType === "none" ? null : (
<AppJumpConfigField
allowCustom={false}
allowH5={false}
appParamLabel="APP 公共跳转 JSON"
className={styles.flutterJumpField}
disabled={disabled}
paramValue={form.appJumpParam}
required
typeLabel="跳转协议"
typeValue="app"
onChange={({ param }) =>
page.setForm({
...form,
actionType: page.appJumpTypeFromParam(param),
appJumpParam: param,
})
}
/>
)}
</div>
</AdminFormSection>
<AdminFormSection title="有效期">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<TextField
@ -398,6 +467,10 @@ function taskStatusLabel(value) {
return taskStatusLabels[value] || value || "-";
}
function taskActionLabel(value) {
return taskActionLabels[value] || value || "-";
}
function taskSwitchUncheckedLabel(status, mode) {
if (status === "archived") {
return "归档";

View File

@ -8,6 +8,7 @@ export function useDailyTaskAbilities() {
canCreate: can(PERMISSIONS.dailyTaskCreate),
canStatus: can(PERMISSIONS.dailyTaskStatus),
canUpdate: can(PERMISSIONS.dailyTaskUpdate),
canUpload: can(PERMISSIONS.uploadCreate),
canView: can(PERMISSIONS.dailyTaskView),
};
}

View File

@ -1,11 +1,30 @@
import { z } from "zod";
import { validatePublicAppJumpParam } from "@/shared/app-jump/appJumpConfig.js";
const taskActionTypes = [
"none",
"wallet",
"room",
"room_random",
"room_window",
"room_random_window",
"room_game",
"room_random_game",
"explore_game",
"gift_panel",
"gift_panel_specific",
] as const;
export const dailyTaskFormSchema = z
.object({
category: z.string().trim().min(1, "请输入任务分类").max(32, "任务分类不能超过 32 个字符"),
appJumpParam: z.string().trim().max(2048, "APP 跳转参数不能超过 2048 个字符").optional(),
actionParam: z.string().trim().max(512, "跳转参数不能超过 512 个字符").optional(),
actionType: z.enum(taskActionTypes),
category: z.string().trim().max(32, "任务分类不能超过 32 个字符").optional(),
description: z.string().trim().max(512, "描述不能超过 512 个字符"),
effectiveFrom: z.string().optional(),
effectiveTo: z.string().optional(),
iconUrl: z.string().trim().max(512, "图标 URL 不能超过 512 个字符"),
metricType: z.string().trim().min(1, "请输入指标类型").max(64, "指标类型不能超过 64 个字符"),
rewardCoinAmount: z.union([z.string(), z.number()]),
sortOrder: z.union([z.string(), z.number()]).optional(),
@ -40,6 +59,23 @@ export const dailyTaskFormSchema = z
if (effectiveFromMs > 0 && effectiveToMs > 0 && effectiveToMs <= effectiveFromMs) {
context.addIssue({ code: "custom", message: "结束时间必须晚于开始时间", path: ["effectiveTo"] });
}
if (value.actionType !== "none") {
const validation = validatePublicAppJumpParam(value.appJumpParam || "");
if (!validation.valid) {
context.addIssue({
code: "custom",
message: validation.message,
path: ["appJumpParam"],
});
}
}
if (!isJSONObject(value.appJumpParam)) {
context.addIssue({
code: "custom",
message: "APP 跳转参数必须是对象格式",
path: ["appJumpParam"],
});
}
});
export type DailyTaskForm = z.infer<typeof dailyTaskFormSchema>;
@ -52,3 +88,16 @@ function datetimeLocalToMs(value: unknown) {
const parsed = new Date(trimmed).getTime();
return Number.isFinite(parsed) ? parsed : -1;
}
function isJSONObject(value: unknown) {
const trimmed = String(value || "").trim();
if (!trimmed) {
return true;
}
try {
const parsed = JSON.parse(trimmed);
return Boolean(parsed) && !Array.isArray(parsed) && typeof parsed === "object";
} catch {
return false;
}
}

View File

@ -0,0 +1,38 @@
import { expect, test } from "vitest";
import { parseForm } from "@/shared/forms/validation";
import { buildTaskPayload, defaultAppJumpParam } from "./hooks/useDailyTasksPage.js";
import { dailyTaskFormSchema } from "./schema";
test("daily task payload stores public app jump params for explore game", () => {
const form = parseForm(dailyTaskFormSchema, {
actionType: "explore_game",
appJumpParam: '{"type":"explore_game","game_code":"game_dice"}',
description: "",
effectiveFrom: "",
effectiveTo: "",
iconUrl: "https://media.haiyihy.com/admin/images/task-game.png",
metricType: "game_dice_play_count",
rewardCoinAmount: "20",
sortOrder: "3",
status: "active",
targetUnit: "count",
targetValue: "1",
taskType: "daily",
title: "玩 Dice Battle 1 局",
});
const payload = buildTaskPayload(form);
expect(payload.category).toBe("game_dice_play_count");
expect(payload.audience_type).toBe("all");
expect(payload.dimension_filter_json).toBe("{}");
expect(payload.icon_key).toBe("");
expect(payload.icon_url).toBe("https://media.haiyihy.com/admin/images/task-game.png");
expect(payload.action_type).toBe("explore_game");
expect(payload.action_param).toBe("game_dice");
expect(payload.action_payload_json).toBe('{"type":"explore_game","game_code":"game_dice"}');
});
test("daily task action type selection provides public app jump template", () => {
expect(defaultAppJumpParam()).toBe('{"type":"wallet"}');
});

View File

@ -8,6 +8,9 @@ export interface FirstRechargeRewardTierDto {
tierName: string;
minCoinAmount: number;
maxCoinAmount: number;
usdMinorAmount: number;
googleProductId: string;
discountPercent: number;
resourceGroupId: number;
status: string;
sortOrder: number;
@ -32,6 +35,9 @@ export interface FirstRechargeRewardConfigPayload {
tier_name: string;
min_coin_amount: number;
max_coin_amount: number;
usd_minor_amount: number;
google_product_id: string;
discount_percent: number;
resource_group_id: number;
status: string;
sort_order: number;
@ -41,6 +47,8 @@ export interface FirstRechargeRewardConfigPayload {
export interface FirstRechargeRewardClaimUserDto {
userId?: number;
displayUserId?: string;
prettyDisplayUserId?: string;
prettyId?: string;
username?: string;
avatar?: string;
}
@ -58,6 +66,9 @@ export interface FirstRechargeRewardClaimDto {
rechargeCoinAmount?: number;
rechargeSequence?: number;
rechargeType?: string;
tierUsdMinorAmount?: number;
googleProductId?: string;
rechargeUsdMinor?: number;
status?: string;
walletCommandId?: string;
walletGrantId?: string;
@ -122,6 +133,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),
@ -141,6 +155,8 @@ function normalizeClaim(item: RawClaim): FirstRechargeRewardClaimDto {
user: {
userId: numberValue(rawUser.userId ?? rawUser.user_id),
displayUserId: stringValue(rawUser.displayUserId ?? rawUser.display_user_id),
prettyDisplayUserId: stringValue(rawUser.prettyDisplayUserId ?? rawUser.pretty_display_user_id),
prettyId: stringValue(rawUser.prettyId ?? rawUser.pretty_id),
username: stringValue(rawUser.username),
avatar: stringValue(rawUser.avatar),
},
@ -150,6 +166,9 @@ function normalizeClaim(item: RawClaim): FirstRechargeRewardClaimDto {
rechargeCoinAmount: numberValue(item.rechargeCoinAmount ?? item.recharge_coin_amount),
rechargeSequence: numberValue(item.rechargeSequence ?? item.recharge_sequence),
rechargeType: stringValue(item.rechargeType ?? item.recharge_type),
tierUsdMinorAmount: numberValue(item.tierUsdMinorAmount ?? item.tier_usd_minor_amount),
googleProductId: stringValue(item.googleProductId ?? item.google_product_id),
rechargeUsdMinor: numberValue(item.rechargeUsdMinor ?? item.recharge_usd_minor),
status: stringValue(item.status),
walletCommandId: stringValue(item.walletCommandId ?? item.wallet_command_id),
walletGrantId: stringValue(item.walletGrantId ?? item.wallet_grant_id),

View File

@ -1,20 +1,13 @@
import AddOutlined from "@mui/icons-material/AddOutlined";
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import SaveOutlined from "@mui/icons-material/SaveOutlined";
import Drawer from "@mui/material/Drawer";
import IconButton from "@mui/material/IconButton";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { RewardTierEditorTable } from "@/shared/ui/RewardTierEditorTable.jsx";
import { ResourceGroupSelectField } from "@/shared/ui/ResourceGroupSelectDrawer.jsx";
import styles from "@/features/first-recharge-reward/first-recharge-reward.module.css";
const statusOptions = [
["active", "启用"],
["inactive", "停用"],
];
export function FirstRechargeRewardConfigDrawer({
abilities,
configLoading,
@ -35,10 +28,11 @@ export function FirstRechargeRewardConfigDrawer({
...(current.tiers || []),
{
tierId: 0,
tierCode: "",
tierCode: `first_recharge_${Date.now()}_${(current.tiers || []).length + 1}`,
tierName: "",
minCoinAmount: "",
maxCoinAmount: "",
usdAmount: "",
googleProductId: "",
discountPercent: "70",
resourceGroupId: "",
status: "active",
sortOrder: String((current.tiers || []).length),
@ -63,110 +57,126 @@ export function FirstRechargeRewardConfigDrawer({
return (
<Drawer anchor="right" open={open} onClose={configSaving ? undefined : onClose}>
<form className="form-drawer" onSubmit={onSubmit}>
<h2>首冲奖励配置</h2>
<section className="form-drawer__section">
<div className="form-drawer__section-title">发放状态</div>
<AdminSwitch
checked={form.enabled}
checkedLabel="开启"
disabled={disabled}
label="首冲奖励状态"
uncheckedLabel="关闭"
onChange={(event) => setForm((current) => ({ ...current, enabled: event.target.checked }))}
/>
</section>
<section className="form-drawer__section">
<div className={styles.drawerSectionTitle}>
<span>奖励档位</span>
<Button
<form className="form-drawer form-drawer--activity-config" onSubmit={onSubmit}>
<h2>首充礼包配置</h2>
<div className="form-drawer__content">
<section className="form-drawer__section">
<div className="form-drawer__section-title">发放状态</div>
<AdminSwitch
checked={form.enabled}
checkedLabel="开启"
disabled={disabled}
startIcon={<AddOutlined fontSize="small" />}
type="button"
onClick={addTier}
>
添加档位
</Button>
</div>
<div className={styles.tierEditorList}>
{(form.tiers || []).map((tier, index) => (
<div className={styles.tierEditor} key={`${tier.tierId || "new"}-${index}`}>
<div className={styles.tierEditorHeader}>
<span>档位 {index + 1}</span>
<IconButton
aria-label="删除档位"
disabled={disabled}
size="small"
onClick={() => removeTier(index)}
>
<DeleteOutlineOutlined fontSize="small" />
</IconButton>
</div>
<div className="form-drawer__grid">
<TextField
disabled={disabled}
label="档位编码"
value={tier.tierCode}
onChange={(event) => updateTier(index, { tierCode: event.target.value })}
/>
<TextField
disabled={disabled}
label="档位名称"
value={tier.tierName}
onChange={(event) => updateTier(index, { tierName: event.target.value })}
/>
<TextField
disabled={disabled}
inputProps={{ min: 1 }}
label="最小金币"
type="number"
value={tier.minCoinAmount}
onChange={(event) => updateTier(index, { minCoinAmount: event.target.value })}
/>
<TextField
disabled={disabled}
inputProps={{ min: 0 }}
label="最大金币"
placeholder="0 表示无上限"
type="number"
value={tier.maxCoinAmount}
onChange={(event) => updateTier(index, { maxCoinAmount: event.target.value })}
/>
<ResourceGroupSelectField
disabled={disabled}
drawerTitle="选择首冲奖励资源组"
groups={resourceGroups}
label="奖励资源组"
value={tier.resourceGroupId}
onChange={(value) => updateTier(index, { resourceGroupId: value })}
/>
<TextField
select
disabled={disabled}
label="状态"
value={tier.status}
onChange={(event) => updateTier(index, { status: event.target.value })}
>
{statusOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
disabled={disabled}
inputProps={{ min: 0 }}
label="排序"
type="number"
value={tier.sortOrder}
onChange={(event) => updateTier(index, { sortOrder: event.target.value })}
/>
</div>
</div>
))}
{!form.tiers?.length ? <div className={styles.emptyState}>暂无档位</div> : null}
</div>
</section>
label="首充礼包状态"
uncheckedLabel="关闭"
onChange={(event) => setForm((current) => ({ ...current, enabled: event.target.checked }))}
/>
</section>
<section className="form-drawer__section">
<div className={styles.drawerSectionTitle}>
<span>奖励档位</span>
<Button
disabled={disabled}
startIcon={<AddOutlined fontSize="small" />}
type="button"
onClick={addTier}
>
添加档位
</Button>
</div>
<RewardTierEditorTable
columns={[
{
key: "tier",
label: "档位",
render: (_tier, index) => (
<span className="reward-tier-editor-table__index">档位 {index + 1}</span>
),
},
{
key: "usdAmount",
label: "USD",
render: (tier, index) => (
<TextField
disabled={disabled}
inputProps={{ "aria-label": "美元档位", min: 0.01, step: 0.01 }}
placeholder="0.99"
type="number"
value={tier.usdAmount}
onChange={(event) => updateTier(index, { usdAmount: event.target.value })}
/>
),
},
{
key: "googleProductId",
label: "Google 商品 ID",
render: (tier, index) => (
<TextField
disabled={disabled}
inputProps={{ "aria-label": "Google 商品 ID" }}
placeholder="first_recharge_google_099"
value={tier.googleProductId}
onChange={(event) =>
updateTier(index, { googleProductId: event.target.value })
}
/>
),
},
{
key: "resourceGroupId",
label: "奖励资源组",
render: (tier, index) => (
<ResourceGroupSelectField
disabled={disabled}
drawerTitle="选择首充礼包资源组"
groups={resourceGroups}
label="奖励资源组"
value={tier.resourceGroupId}
onChange={(value) => updateTier(index, { resourceGroupId: value })}
/>
),
},
{
key: "discountPercent",
label: "优惠%",
render: (tier, index) => (
<TextField
disabled={disabled}
inputProps={{ "aria-label": "优惠百分比", min: 0, max: 100, step: 1 }}
type="number"
value={tier.discountPercent}
onChange={(event) =>
updateTier(index, { discountPercent: event.target.value })
}
/>
),
},
{
key: "status",
label: "状态",
render: (tier, index) => (
<AdminSwitch
checked={tier.status !== "inactive"}
checkedLabel="启用"
disabled={disabled}
label={`档位 ${index + 1} 状态`}
uncheckedLabel="停用"
onChange={(event) =>
updateTier(index, {
status: event.target.checked ? "active" : "inactive",
})
}
/>
),
},
]}
disabled={disabled}
emptyText="暂无档位"
gridTemplateColumns="72px minmax(110px, 0.6fr) minmax(230px, 1.2fr) minmax(260px, 1.4fr) 92px 120px 52px"
rows={form.tiers || []}
onRemoveTier={removeTier}
/>
</section>
</div>
<div className="form-drawer__actions">
<Button disabled={configSaving} type="button" onClick={onClose}>
取消

View File

@ -36,7 +36,7 @@ export function useFirstRechargeRewardPage() {
loading: claimsLoading,
reload: reloadClaims,
} = usePaginatedQuery({
errorMessage: "加载首冲奖励记录失败",
errorMessage: "加载首充礼包记录失败",
fetcher: listFirstRechargeRewardClaims,
filters,
page,
@ -55,7 +55,7 @@ export function useFirstRechargeRewardPage() {
setForm(formFromConfig(config));
setResourceGroups(groups.items || []);
} catch (err) {
showToast(err.message || "加载首冲奖励配置失败", "error");
showToast(err.message || "加载首充礼包配置失败", "error");
} finally {
setConfigLoading(false);
}
@ -96,10 +96,10 @@ export function useFirstRechargeRewardPage() {
setConfig(saved);
setForm(formFromConfig(saved));
setConfigDrawerOpen(false);
showToast("首冲奖励配置已保存", "success");
showToast("首充礼包配置已保存", "success");
await reloadClaims();
} catch (err) {
showToast(err.message || "保存首冲奖励配置失败", "error");
showToast(err.message || "保存首充礼包配置失败", "error");
} finally {
setConfigSaving(false);
}
@ -138,8 +138,9 @@ function formFromConfig(config) {
tierId: tier.tierId || 0,
tierCode: tier.tierCode || "",
tierName: tier.tierName || "",
minCoinAmount: tier.minCoinAmount ? String(tier.minCoinAmount) : "",
maxCoinAmount: tier.maxCoinAmount ? String(tier.maxCoinAmount) : "",
usdAmount: tier.usdMinorAmount ? (tier.usdMinorAmount / 100).toFixed(2) : "",
googleProductId: tier.googleProductId || "",
discountPercent: String(tier.discountPercent || 0),
resourceGroupId: tier.resourceGroupId ? String(tier.resourceGroupId) : "",
status: tier.status || "active",
sortOrder: String(tier.sortOrder || 0),
@ -148,31 +149,40 @@ function formFromConfig(config) {
}
function payloadFromForm(form) {
const seenGoogleProductIds = new Set();
const tiers = (form.tiers || []).map((tier, index) => {
const minCoinAmount = Number(tier.minCoinAmount || 0);
const maxCoinAmount = Number(tier.maxCoinAmount || 0);
const usdMinorAmount = Math.round(Number(tier.usdAmount || 0) * 100);
const googleProductId = String(tier.googleProductId || "").trim();
const discountPercent = Number(tier.discountPercent || 0);
const resourceGroupId = Number(tier.resourceGroupId || 0);
if (!tier.tierCode.trim()) {
throw new Error("档位编码不能为空");
if (usdMinorAmount <= 0) {
throw new Error("美元档位必须大于 0");
}
if (!tier.tierName.trim()) {
throw new Error("档位名称不能为空");
if (!googleProductId) {
throw new Error("Google 商品 ID 不能为空");
}
if (minCoinAmount <= 0) {
throw new Error("最小金币必须大于 0");
const normalizedGoogleProductId = googleProductId.toLowerCase();
if (seenGoogleProductIds.has(normalizedGoogleProductId)) {
throw new Error("Google 商品 ID 不能重复");
}
if (maxCoinAmount > 0 && maxCoinAmount < minCoinAmount) {
throw new Error("最大金币不能小于最小金币");
seenGoogleProductIds.add(normalizedGoogleProductId);
if (discountPercent < 0 || discountPercent > 100) {
throw new Error("优惠百分比必须在 0 到 100 之间");
}
if (resourceGroupId <= 0) {
throw new Error("请选择资源组");
}
const tierCode = String(tier.tierCode || "").trim() || `first_recharge_${index + 1}_${usdMinorAmount}`;
const tierName = String(tier.tierName || "").trim() || `$${(usdMinorAmount / 100).toFixed(2)}`;
return {
tier_id: Number(tier.tierId || 0),
tier_code: tier.tierCode.trim(),
tier_name: tier.tierName.trim(),
min_coin_amount: minCoinAmount,
max_coin_amount: maxCoinAmount,
tier_code: tierCode,
tier_name: tierName,
min_coin_amount: 0,
max_coin_amount: 0,
usd_minor_amount: usdMinorAmount,
google_product_id: googleProductId,
discount_percent: discountPercent,
resource_group_id: resourceGroupId,
status: tier.status === "inactive" ? "inactive" : "active",
sort_order: Number(tier.sortOrder || index),

View File

@ -1,9 +1,9 @@
import Avatar from "@mui/material/Avatar";
import { FirstRechargeRewardConfigDrawer } from "@/features/first-recharge-reward/components/FirstRechargeRewardConfigDrawer.jsx";
import { FirstRechargeRewardConfigSummary } from "@/features/first-recharge-reward/components/FirstRechargeRewardConfigSummary.jsx";
import { useFirstRechargeRewardPage } from "@/features/first-recharge-reward/hooks/useFirstRechargeRewardPage.js";
import styles from "@/features/first-recharge-reward/first-recharge-reward.module.css";
import { AdminListBody, AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
@ -24,12 +24,14 @@ const columnsBase = [
},
{
key: "tier",
label: "命中档位",
width: "minmax(180px, 0.8fr)",
label: "商品档位",
width: "minmax(260px, 1fr)",
render: (claim) => (
<div className={styles.stack}>
<span>{claim.tierCode || "-"}</span>
<span className={styles.meta}>资源组 #{claim.resourceGroupId || "-"}</span>
<span>{claim.googleProductId || claim.tierCode || "-"}</span>
<span className={styles.meta}>
{formatUsdMinor(claim.tierUsdMinorAmount || claim.rechargeUsdMinor)}
</span>
</div>
),
},
@ -40,7 +42,7 @@ const columnsBase = [
render: (claim) => (
<div className={styles.stack}>
<span>{formatNumber(claim.rechargeCoinAmount)} 金币</span>
<span className={styles.meta}>{claim.rechargeSequence || "-"} </span>
<span className={styles.meta}>资源组 #{claim.resourceGroupId || "-"}</span>
</div>
),
},
@ -150,21 +152,7 @@ export function FirstRechargeRewardPage() {
function ClaimUser({ claim }) {
const user = claim.user || {};
return (
<div className={styles.identity}>
<Avatar
alt={user.username || String(claim.userId)}
src={user.avatar || ""}
sx={{ width: 36, height: 36 }}
/>
<div className={styles.stack}>
<span className={styles.name}>{user.username || `用户 ${claim.userId}`}</span>
<span className={styles.meta}>
{user.displayUserId ? `短号 ${user.displayUserId}` : `ID ${claim.userId}`}
</span>
</div>
</div>
);
return <AdminUserIdentity openInAppUserDetail user={{ ...user, userId: user.userId || claim.userId }} />;
}
function claimStatusLabel(status) {
@ -174,3 +162,11 @@ function claimStatusLabel(status) {
function formatNumber(value) {
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
}
function formatUsdMinor(value) {
const amount = Number(value || 0);
if (!Number.isFinite(amount) || amount <= 0) {
return "$-";
}
return `$${(amount / 100).toFixed(2)}`;
}

View File

@ -2,7 +2,7 @@ import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
export const firstRechargeRewardRoutes = [
{
label: "首冲奖励",
label: "首充礼包",
loader: () => import("./pages/FirstRechargeRewardPage.jsx").then((module) => module.FirstRechargeRewardPage),
menuCode: MENU_CODES.firstRechargeReward,
pageKey: "first-recharge-reward",

View File

@ -74,6 +74,136 @@ export interface GameSyncResult {
items: GameCatalogDto[];
}
export interface DiceStakeOptionDto {
stakeCoin: number;
enabled: boolean;
sortOrder: number;
}
export interface DiceConfigDto {
appCode?: string;
gameId: string;
status: string;
stakeOptions: DiceStakeOptionDto[];
feeBps: number;
poolBps: number;
minPlayers: number;
maxPlayers: number;
robotEnabled: boolean;
robotMatchWaitMs: number;
poolBalanceCoin: number;
createdAtMs?: number;
updatedAtMs?: number;
}
export interface DiceRobotDto {
appCode?: string;
gameId: string;
userId: string;
userIdNumber?: number;
shortId?: string;
nickname?: string;
avatar?: string;
status: string;
createdByAdminId?: number;
createdAtMs?: number;
updatedAtMs?: number;
}
export interface DiceRobotPage {
items: DiceRobotDto[];
nextCursor?: string;
pageSize?: number;
serverTimeMs?: number;
}
export interface RoomRpsStakeGiftDto {
giftId: string;
giftIdNumber?: number;
giftName?: string;
giftIconUrl?: string;
giftPriceCoin?: number;
enabled: boolean;
sortOrder: number;
}
export interface RoomRpsConfigDto {
appCode?: string;
gameId: string;
status: string;
challengeTimeoutMs: number;
revealCountdownMs: number;
stakeGifts: RoomRpsStakeGiftDto[];
createdAtMs?: number;
updatedAtMs?: number;
}
export interface RoomRpsPlayerDto {
userId: string;
userIdNumber?: number;
gesture: string;
result: string;
balanceAfter?: number;
joinedAtMs?: number;
}
export interface RoomRpsChallengeDto {
appCode?: string;
challengeId: string;
roomId: string;
regionId?: number;
status: string;
stakeGiftId: string;
stakeGiftIdNumber?: number;
stakeCoin: number;
initiator: RoomRpsPlayerDto;
challenger: RoomRpsPlayerDto;
winnerUserId: string;
winnerUserIdNumber?: number;
settlementStatus: string;
failureReason?: string;
timeoutAtMs?: number;
revealAtMs?: number;
createdAtMs?: number;
matchedAtMs?: number;
settledAtMs?: number;
updatedAtMs?: number;
}
export interface RoomRpsChallengePage {
items: RoomRpsChallengeDto[];
nextCursor?: string;
pageSize?: number;
serverTimeMs?: number;
}
export type DiceConfigPayload = Omit<DiceConfigDto, "appCode" | "createdAtMs" | "updatedAtMs" | "poolBalanceCoin">;
export interface RoomRpsConfigPayload {
status: string;
challengeTimeoutMs: number;
revealCountdownMs: number;
stakeGifts: Array<{
giftId: number;
enabled: boolean;
sortOrder: number;
}>;
}
export interface DicePoolAdjustPayload {
amountCoin: number;
direction: "in" | "out";
reason: string;
}
export interface DiceGenerateRobotsPayload {
count: number;
nicknameLanguage?: "english" | "arabic";
avatarUrls?: string[];
country?: string;
gender?: string;
}
export type GamePlatformPayload = Omit<
GamePlatformDto,
"appCode" | "createdAtMs" | "updatedAtMs" | "callbackSecretSet"
@ -87,6 +217,17 @@ export interface GameCatalogQuery {
cursor?: string;
}
export interface RoomRpsChallengeQuery {
roomId?: string;
status?: string;
initiatorUserId?: string;
challengerUserId?: string;
startTimeMs?: number;
endTimeMs?: number;
pageSize?: number;
cursor?: string;
}
export function listGamePlatforms(status = ""): Promise<GamePlatformPage> {
const endpoint = API_ENDPOINTS.listPlatforms;
return apiRequest<GamePlatformPage>(apiEndpointPath(API_OPERATIONS.listPlatforms), {
@ -166,6 +307,132 @@ export function syncGamePlatformCatalog(platformCode: string, payload: GameSyncP
}));
}
export function listSelfGames(): Promise<{ items: DiceConfigDto[]; serverTimeMs?: number }> {
const endpoint = API_ENDPOINTS.listSelfGames;
return apiRequest<{ items: DiceConfigDto[]; serverTimeMs?: number }>(
apiEndpointPath(API_OPERATIONS.listSelfGames),
{
method: endpoint.method,
},
).then((page) => ({
...page,
items: (page.items || []).map(normalizeDiceConfig),
}));
}
export function updateDiceConfig(gameId: string, payload: DiceConfigPayload): Promise<DiceConfigDto> {
const endpoint = API_ENDPOINTS.updateDiceConfig;
return apiRequest<DiceConfigDto, DiceConfigPayload>(
apiEndpointPath(API_OPERATIONS.updateDiceConfig, { game_id: gameId }),
{ body: payload, method: endpoint.method },
).then(normalizeDiceConfig);
}
export function adjustDicePool(
gameId: string,
payload: DicePoolAdjustPayload,
): Promise<{ config: DiceConfigDto; adjustment: unknown }> {
const endpoint = API_ENDPOINTS.adjustDicePool;
return apiRequest<{ config: DiceConfigDto; adjustment: unknown }, DicePoolAdjustPayload>(
apiEndpointPath(API_OPERATIONS.adjustDicePool, { game_id: gameId }),
{ body: payload, method: endpoint.method },
).then((result) => ({ ...result, config: normalizeDiceConfig(result.config || {}) }));
}
export function listDiceRobots(query: { gameId?: string; status?: string; pageSize?: number; cursor?: string } = {}) {
const endpoint = API_ENDPOINTS.listDiceRobots;
return apiRequest<DiceRobotPage>(apiEndpointPath(API_OPERATIONS.listDiceRobots), {
method: endpoint.method,
query: query as QueryParams,
}).then((page) => ({
...page,
items: (page.items || []).map(normalizeDiceRobot),
}));
}
export function generateDiceRobots(payload: DiceGenerateRobotsPayload) {
const endpoint = API_ENDPOINTS.generateDiceRobots;
return apiRequest<{ created: number; items: DiceRobotDto[] }, DiceGenerateRobotsPayload>(
apiEndpointPath(API_OPERATIONS.generateDiceRobots),
{ body: payload, method: endpoint.method },
).then((result) => ({ ...result, items: (result.items || []).map(normalizeDiceRobot) }));
}
export function setDiceRobotStatus(userId: string, status: string, gameId = "dice") {
const endpoint = API_ENDPOINTS.setDiceRobotStatus;
return apiRequest<DiceRobotDto, { status: string }>(
apiEndpointPath(API_OPERATIONS.setDiceRobotStatus, { user_id: userId }),
{ body: { status }, method: endpoint.method, query: { gameId } },
).then(normalizeDiceRobot);
}
export function deleteDiceRobot(userId: string, gameId = "dice"): Promise<{ deleted: boolean }> {
const endpoint = API_ENDPOINTS.deleteDiceRobot;
return apiRequest<{ deleted: boolean }>(apiEndpointPath(API_OPERATIONS.deleteDiceRobot, { user_id: userId }), {
method: endpoint.method,
query: { gameId },
});
}
export function getRoomRpsConfig(): Promise<{ config: RoomRpsConfigDto; serverTimeMs?: number }> {
const endpoint = API_ENDPOINTS.getRoomRpsConfig;
return apiRequest<{ config: RoomRpsConfigDto; serverTimeMs?: number }>(
apiEndpointPath(API_OPERATIONS.getRoomRpsConfig),
{ method: endpoint.method },
).then((result) => ({ ...result, config: normalizeRoomRpsConfig(result.config || {}) }));
}
export function updateRoomRpsConfig(
payload: RoomRpsConfigPayload,
): Promise<{ config: RoomRpsConfigDto; serverTimeMs?: number }> {
const endpoint = API_ENDPOINTS.updateRoomRpsConfig;
return apiRequest<{ config: RoomRpsConfigDto; serverTimeMs?: number }, RoomRpsConfigPayload>(
apiEndpointPath(API_OPERATIONS.updateRoomRpsConfig),
{ body: payload, method: endpoint.method },
).then((result) => ({ ...result, config: normalizeRoomRpsConfig(result.config || {}) }));
}
export function listRoomRpsChallenges(query: RoomRpsChallengeQuery = {}): Promise<RoomRpsChallengePage> {
const endpoint = API_ENDPOINTS.listRoomRpsChallenges;
return apiRequest<RoomRpsChallengePage>(apiEndpointPath(API_OPERATIONS.listRoomRpsChallenges), {
method: endpoint.method,
query: query as QueryParams,
}).then((page) => ({
...page,
items: (page.items || []).map(normalizeRoomRpsChallenge),
}));
}
export function getRoomRpsChallenge(
challengeId: string,
): Promise<{ challenge: RoomRpsChallengeDto; serverTimeMs?: number }> {
const endpoint = API_ENDPOINTS.getRoomRpsChallenge;
return apiRequest<{ challenge: RoomRpsChallengeDto; serverTimeMs?: number }>(
apiEndpointPath(API_OPERATIONS.getRoomRpsChallenge, { challenge_id: challengeId }),
{ method: endpoint.method },
).then((result) => ({ ...result, challenge: normalizeRoomRpsChallenge(result.challenge || {}) }));
}
export function retryRoomRpsSettlement(
challengeId: string,
): Promise<{ challenge: RoomRpsChallengeDto; serverTimeMs?: number }> {
const endpoint = API_ENDPOINTS.retryRoomRpsSettlement;
return apiRequest<{ challenge: RoomRpsChallengeDto; serverTimeMs?: number }>(
apiEndpointPath(API_OPERATIONS.retryRoomRpsSettlement, { challenge_id: challengeId }),
{ method: endpoint.method },
).then((result) => ({ ...result, challenge: normalizeRoomRpsChallenge(result.challenge || {}) }));
}
export function expireRoomRpsChallenge(
challengeId: string,
): Promise<{ challenge: RoomRpsChallengeDto; serverTimeMs?: number }> {
const endpoint = API_ENDPOINTS.expireRoomRpsChallenge;
return apiRequest<{ challenge: RoomRpsChallengeDto; serverTimeMs?: number }>(
apiEndpointPath(API_OPERATIONS.expireRoomRpsChallenge, { challenge_id: challengeId }),
{ method: endpoint.method },
).then((result) => ({ ...result, challenge: normalizeRoomRpsChallenge(result.challenge || {}) }));
}
function normalizePlatform(platform: Partial<GamePlatformDto>): GamePlatformDto {
return {
appCode: stringValue(platform.appCode),
@ -209,6 +476,107 @@ function normalizeCatalog(game: Partial<GameCatalogDto>): GameCatalogDto {
};
}
function normalizeDiceConfig(config: Partial<DiceConfigDto>): DiceConfigDto {
return {
appCode: stringValue(config.appCode),
gameId: stringValue(config.gameId || "dice"),
status: stringValue(config.status || "active"),
stakeOptions: Array.isArray(config.stakeOptions)
? config.stakeOptions.map((item) => ({
stakeCoin: numberValue(item.stakeCoin),
enabled: item.enabled !== false,
sortOrder: numberValue(item.sortOrder),
}))
: [],
feeBps: numberValue(config.feeBps),
poolBps: numberValue(config.poolBps),
minPlayers: numberValue(config.minPlayers || 2),
maxPlayers: numberValue(config.maxPlayers || 2),
robotEnabled: config.robotEnabled !== false,
robotMatchWaitMs: numberValue(config.robotMatchWaitMs || 3000),
poolBalanceCoin: numberValue(config.poolBalanceCoin),
createdAtMs: numberValue(config.createdAtMs),
updatedAtMs: numberValue(config.updatedAtMs),
};
}
function normalizeDiceRobot(robot: Partial<DiceRobotDto>): DiceRobotDto {
return {
appCode: stringValue(robot.appCode),
gameId: stringValue(robot.gameId || "dice"),
userId: stringValue(robot.userId),
userIdNumber: numberValue(robot.userIdNumber),
shortId: stringValue(robot.shortId),
nickname: stringValue(robot.nickname),
avatar: stringValue(robot.avatar),
status: stringValue(robot.status || "active"),
createdByAdminId: numberValue(robot.createdByAdminId),
createdAtMs: numberValue(robot.createdAtMs),
updatedAtMs: numberValue(robot.updatedAtMs),
};
}
function normalizeRoomRpsConfig(config: Partial<RoomRpsConfigDto>): RoomRpsConfigDto {
return {
appCode: stringValue(config.appCode),
gameId: stringValue(config.gameId || "room_rps"),
status: stringValue(config.status || "active"),
challengeTimeoutMs: numberValue(config.challengeTimeoutMs || 600000),
revealCountdownMs: numberValue(config.revealCountdownMs || 3000),
stakeGifts: Array.isArray(config.stakeGifts) ? config.stakeGifts.map(normalizeRoomRpsStakeGift) : [],
createdAtMs: numberValue(config.createdAtMs),
updatedAtMs: numberValue(config.updatedAtMs),
};
}
function normalizeRoomRpsStakeGift(gift: Partial<RoomRpsStakeGiftDto>): RoomRpsStakeGiftDto {
return {
giftId: stringValue(gift.giftId),
giftIdNumber: numberValue(gift.giftIdNumber),
giftName: stringValue(gift.giftName),
giftIconUrl: stringValue(gift.giftIconUrl),
giftPriceCoin: numberValue(gift.giftPriceCoin),
enabled: gift.enabled !== false,
sortOrder: numberValue(gift.sortOrder),
};
}
function normalizeRoomRpsChallenge(challenge: Partial<RoomRpsChallengeDto>): RoomRpsChallengeDto {
return {
appCode: stringValue(challenge.appCode),
challengeId: stringValue(challenge.challengeId),
roomId: stringValue(challenge.roomId),
regionId: numberValue(challenge.regionId),
status: stringValue(challenge.status || "pending"),
stakeGiftId: stringValue(challenge.stakeGiftId),
stakeGiftIdNumber: numberValue(challenge.stakeGiftIdNumber),
stakeCoin: numberValue(challenge.stakeCoin),
initiator: normalizeRoomRpsPlayer(challenge.initiator || {}),
challenger: normalizeRoomRpsPlayer(challenge.challenger || {}),
winnerUserId: stringValue(challenge.winnerUserId),
winnerUserIdNumber: numberValue(challenge.winnerUserIdNumber),
settlementStatus: stringValue(challenge.settlementStatus),
failureReason: stringValue(challenge.failureReason),
timeoutAtMs: numberValue(challenge.timeoutAtMs),
revealAtMs: numberValue(challenge.revealAtMs),
createdAtMs: numberValue(challenge.createdAtMs),
matchedAtMs: numberValue(challenge.matchedAtMs),
settledAtMs: numberValue(challenge.settledAtMs),
updatedAtMs: numberValue(challenge.updatedAtMs),
};
}
function normalizeRoomRpsPlayer(player: Partial<RoomRpsPlayerDto>): RoomRpsPlayerDto {
return {
userId: stringValue(player.userId),
userIdNumber: numberValue(player.userIdNumber),
gesture: stringValue(player.gesture),
result: stringValue(player.result),
balanceAfter: numberValue(player.balanceAfter),
joinedAtMs: numberValue(player.joinedAtMs),
};
}
function stringValue(value: unknown) {
return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
}

View File

@ -15,6 +15,17 @@
object-fit: cover;
}
.robotAvatar {
width: 40px;
height: 40px;
flex: 0 0 auto;
border: 1px solid var(--border);
background: var(--primary-soft);
color: var(--primary);
font-size: 15px;
font-weight: 700;
}
.identityText,
.stack {
display: flex;
@ -53,10 +64,29 @@
width: 100%;
}
.giftTierList {
display: flex;
flex-direction: column;
gap: 12px;
}
.giftTierRow {
display: grid;
grid-template-columns: minmax(180px, 1fr) minmax(110px, 0.5fr) minmax(96px, auto);
gap: 12px;
align-items: center;
}
.fullField {
grid-column: 1 / -1;
}
@media (max-width: 720px) {
.giftTierRow {
grid-template-columns: minmax(0, 1fr);
}
}
.platformList {
display: flex;
flex-direction: column;

View File

@ -51,13 +51,16 @@ const defaultPlatformForm = {
const defaultBridgeScriptForm = {
bridgeScriptUrl: "",
bridgeScriptVersion: "",
bridgeScriptSha256: "",
};
const defaultBridgeHashState = {
loading: false,
error: "",
};
const writableBridgeAdapterTypes = new Set([
"yomi_v4",
"leadercc_v1",
"zeeone_v1",
"baishun_v1",
"vivagames_v1",
"reyou_v1",
]);
const emptyCatalog = { items: [], pageSize: 50 };
const emptyPlatforms = { items: [] };
@ -81,7 +84,6 @@ export function useGamesPage() {
const [gameForm, setGameForm] = useState(defaultGameForm);
const [platformForm, setPlatformForm] = useState(defaultPlatformForm);
const [bridgeScriptForm, setBridgeScriptForm] = useState(defaultBridgeScriptForm);
const [bridgeHashState, setBridgeHashState] = useState(defaultBridgeHashState);
const [loadingAction, setLoadingAction] = useState("");
const [syncPlatform, setSyncPlatform] = useState(null);
const [syncCandidates, setSyncCandidates] = useState([]);
@ -217,7 +219,6 @@ export function useGamesPage() {
const openBridgeScript = () => {
setBridgeScriptForm(bridgeScriptFormFromPlatforms(platformData.items || []));
setBridgeHashState(defaultBridgeHashState);
setActiveAction("bridge-script");
};
@ -235,78 +236,12 @@ export function useGamesPage() {
}
if (activeAction === "bridge-script") {
setBridgeScriptForm(defaultBridgeScriptForm);
setBridgeHashState(defaultBridgeHashState);
}
setActiveAction("");
setEditingGameId("");
setEditingPlatformCode("");
};
const refreshBridgeScriptHash = useCallback(async () => {
const url = bridgeScriptForm.bridgeScriptUrl.trim();
if (activeAction !== "bridge-script" || url === "") {
return "";
}
setBridgeHashState({ loading: true, error: "" });
try {
const hash = await calculateBridgeScriptSha256(url);
setBridgeScriptForm((current) => {
if (current.bridgeScriptUrl.trim() !== url) {
return current;
}
return { ...current, bridgeScriptSha256: hash };
});
setBridgeHashState(defaultBridgeHashState);
return hash;
} catch (err) {
const message = err.message || "生成 SHA256 失败";
setBridgeHashState({ loading: false, error: message });
showToast(message, "error");
return "";
}
}, [activeAction, bridgeScriptForm.bridgeScriptUrl, showToast]);
useEffect(() => {
if (activeAction !== "bridge-script") {
return undefined;
}
const url = bridgeScriptForm.bridgeScriptUrl.trim();
const version = bridgeScriptForm.bridgeScriptVersion.trim();
if (!url || !version) {
setBridgeScriptForm((current) =>
current.bridgeScriptSha256 ? { ...current, bridgeScriptSha256: "" } : current,
);
setBridgeHashState(defaultBridgeHashState);
return undefined;
}
let cancelled = false;
const timer = window.setTimeout(async () => {
setBridgeHashState({ loading: true, error: "" });
try {
const hash = await calculateBridgeScriptSha256(url);
if (cancelled) {
return;
}
setBridgeScriptForm((current) => {
if (current.bridgeScriptUrl.trim() !== url || current.bridgeScriptVersion.trim() !== version) {
return current;
}
return { ...current, bridgeScriptSha256: hash };
});
setBridgeHashState(defaultBridgeHashState);
} catch (err) {
if (cancelled) {
return;
}
setBridgeHashState({ loading: false, error: err.message || "生成 SHA256 失败" });
}
}, 500);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [activeAction, bridgeScriptForm.bridgeScriptUrl, bridgeScriptForm.bridgeScriptVersion]);
const submitGame = async (event) => {
event.preventDefault();
const payload = gamePayload(gameForm);
@ -361,24 +296,26 @@ export function useGamesPage() {
showToast("请填写 bridge_script_url 和 bridge_script_version", "error");
return;
}
let bridgeScriptSha256 = bridgeScriptForm.bridgeScriptSha256.trim();
if (!bridgeScriptSha256) {
bridgeScriptSha256 = await refreshBridgeScriptHash();
}
if (!bridgeScriptSha256) {
const writablePlatforms = platforms.filter(isWritableBridgePlatform);
const skippedPlatforms = platforms.filter((platform) => !isWritableBridgePlatform(platform));
if (writablePlatforms.length === 0) {
showToast("没有可写入的有效游戏平台", "error");
return;
}
setLoadingAction("bridge-script");
try {
await Promise.all(
platforms.map((platform) => {
writablePlatforms.map((platform) => {
const config = parseJsonObject(platform.adapterConfigJson);
config.bridge_script_url = bridgeScriptUrl;
config.bridge_script_version = bridgeScriptVersion;
config.bridge_script_sha256 = bridgeScriptSha256;
delete config.bridgeScriptUrl;
delete config.bridgeScriptVersion;
delete config.bridge_script_sha256;
delete config.bridgeScriptSha256;
delete config.bridge_script_hash;
delete config.script_sha256;
delete config.scriptSha256;
return upsertGamePlatform(
platformUpsertPayload(platform, JSON.stringify(config, null, 2)),
platform.platformCode,
@ -387,7 +324,14 @@ export function useGamesPage() {
);
await reloadPlatforms();
await reloadCatalog();
showToast(`JS 桥接配置已同步 ${platforms.length} 个平台`, "success");
const skippedMessage =
skippedPlatforms.length > 0
? `,跳过 ${skippedPlatforms.length} 个无效适配器平台:${skippedPlatforms
.map((platform) => platform.platformCode || platform.platformName || platform.adapterType)
.slice(0, 3)
.join(", ")}`
: "";
showToast(`JS 桥接配置已同步 ${writablePlatforms.length} 个平台${skippedMessage}`, "success");
closeAction();
} catch (err) {
showToast(err.message || "保存 JS 桥接配置失败", "error");
@ -518,7 +462,6 @@ export function useGamesPage() {
abilities,
activeAction,
allSyncGamesSelected,
bridgeHashState,
bridgeScriptForm,
changeGameStatus,
deleteGame,
@ -550,7 +493,6 @@ export function useGamesPage() {
platformForm,
platformOptions,
loadNextPage,
refreshBridgeScriptHash,
reload: reloadCatalog,
resetFilters,
resolveGameURL,
@ -592,16 +534,13 @@ function bridgeScriptFormFromPlatform(platform) {
return {
bridgeScriptUrl: readConfigString(config.bridge_script_url ?? config.bridgeScriptUrl),
bridgeScriptVersion: readConfigString(config.bridge_script_version ?? config.bridgeScriptVersion),
bridgeScriptSha256: readConfigString(
config.bridge_script_sha256 ?? config.bridgeScriptSha256 ?? config.bridge_script_hash,
),
};
}
function bridgeScriptFormFromPlatforms(platforms) {
for (const platform of platforms) {
const form = bridgeScriptFormFromPlatform(platform);
if (form.bridgeScriptUrl || form.bridgeScriptVersion || form.bridgeScriptSha256) {
if (form.bridgeScriptUrl || form.bridgeScriptVersion) {
return form;
}
}
@ -615,6 +554,14 @@ function readConfigString(value) {
return String(value).trim();
}
function isWritableBridgePlatform(platform) {
return writableBridgeAdapterTypes.has(
String(platform?.adapterType || "")
.trim()
.toLowerCase(),
);
}
function platformUpsertPayload(platform, adapterConfigJson) {
return {
platformCode: platform.platformCode,
@ -629,21 +576,6 @@ function platformUpsertPayload(platform, adapterConfigJson) {
};
}
async function calculateBridgeScriptSha256(url) {
if (!window.crypto?.subtle) {
throw new Error("当前浏览器不支持 SHA256 计算");
}
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) {
throw new Error(`拉取 JS 失败HTTP ${response.status}`);
}
const buffer = await response.arrayBuffer();
const digest = await window.crypto.subtle.digest("SHA-256", buffer);
return Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
async function saveGameUrl(platforms, game, gameUrl) {
const platform = platforms.find((item) => item.platformCode === game.platformCode);
if (!platform) {

View File

@ -435,15 +435,8 @@ function GameFormDialog({ form, loading, mode, open, page, onClose, onSubmit })
}
function BridgeScriptDialog({ form, loading, open, page, onClose, onSubmit }) {
const hashState = page.bridgeHashState || {};
const disabled = !page.abilities.canUpdate;
const submitDisabled =
disabled ||
loading ||
hashState.loading ||
!form.bridgeScriptUrl.trim() ||
!form.bridgeScriptVersion.trim() ||
!form.bridgeScriptSha256.trim();
const submitDisabled = disabled || loading || !form.bridgeScriptUrl.trim() || !form.bridgeScriptVersion.trim();
return (
<AdminFormDialog
disabled={disabled}
@ -455,17 +448,7 @@ function BridgeScriptDialog({ form, loading, open, page, onClose, onSubmit }) {
onClose={onClose}
onSubmit={onSubmit}
>
<AdminFormSection
title="通用桥接脚本"
actions={
<Button
disabled={disabled || hashState.loading || !form.bridgeScriptUrl.trim()}
onClick={page.refreshBridgeScriptHash}
>
{hashState.loading ? "生成中..." : "重新生成 SHA256"}
</Button>
}
>
<AdminFormSection title="通用桥接脚本">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<TextField
label="bridge_script_version"
@ -484,17 +467,6 @@ function BridgeScriptDialog({ form, loading, open, page, onClose, onSubmit }) {
value={form.bridgeScriptUrl}
onChange={(event) => page.setBridgeScriptForm({ ...form, bridgeScriptUrl: event.target.value })}
/>
<TextField
className={styles.fullField}
error={Boolean(hashState.error)}
helperText={hashState.error || (hashState.loading ? "正在拉取 JS 并计算 SHA256" : "")}
label="bridge_script_sha256"
required
value={form.bridgeScriptSha256}
onChange={(event) =>
page.setBridgeScriptForm({ ...form, bridgeScriptSha256: event.target.value })
}
/>
</AdminFormFieldGrid>
</AdminFormSection>
</AdminFormDialog>

View File

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

View File

@ -0,0 +1,62 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { AppProviders } from "@/app/providers.jsx";
import { GameRobotPage } from "@/features/games/pages/GameRobotPage.jsx";
vi.mock("@/app/auth/AuthProvider.jsx", () => ({
AuthProvider: ({ children }) => children,
useAuth: () => ({ can: () => true }),
}));
beforeEach(() => {
vi.stubGlobal(
"fetch",
vi.fn(async () => jsonResponse({ items: [] })),
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
test("renders game robot page without crashing", async () => {
render(
<AppProviders>
<MemoryRouter>
<GameRobotPage />
</MemoryRouter>
</AppProviders>,
);
await waitFor(() => expect(screen.getByText("机器人用户")).toBeInTheDocument());
});
test("shows nickname language selector when generating robots", async () => {
const user = userEvent.setup();
render(
<AppProviders>
<MemoryRouter>
<GameRobotPage />
</MemoryRouter>
</AppProviders>,
);
await waitFor(() => expect(screen.getByText("机器人用户")).toBeInTheDocument());
await user.click(screen.getByRole("button", { name: "批量创建" }));
expect(screen.getByLabelText("昵称语言")).toBeInTheDocument();
await user.click(screen.getByRole("combobox", { name: "昵称语言" }));
expect(screen.getByRole("option", { name: "阿拉伯语名称" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "英语名称" })).toBeInTheDocument();
});
function jsonResponse(body) {
return {
headers: new Headers({ "content-type": "application/json" }),
ok: true,
status: 200,
text: async () => JSON.stringify(body),
};
}

View File

@ -0,0 +1,357 @@
import BlockOutlined from "@mui/icons-material/BlockOutlined";
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
import ReplayOutlined from "@mui/icons-material/ReplayOutlined";
import VisibilityOutlined from "@mui/icons-material/VisibilityOutlined";
import TextField from "@mui/material/TextField";
import { useCallback, useEffect, useState } from "react";
import { PERMISSIONS } from "@/app/permissions";
import { useAuth } from "@/app/auth/AuthProvider.jsx";
import {
expireRoomRpsChallenge,
getRoomRpsChallenge,
listRoomRpsChallenges,
retryRoomRpsSettlement,
} from "@/features/games/api";
import styles from "@/features/games/games.module.css";
import {
AdminActionIconButton,
AdminFilterResetButton,
AdminFilterSelect,
AdminListBody,
AdminListPage,
AdminListToolbar,
AdminRowActions,
} from "@/shared/ui/AdminListLayout.jsx";
import {
AdminFormDialog,
AdminFormFieldGrid,
AdminFormReadOnlyField,
AdminFormSection,
} from "@/shared/ui/AdminFormDialog.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { PageSkeleton } from "@/shared/ui/PageSkeleton.jsx";
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { formatMillis } from "@/shared/utils/time.js";
const statusOptions = [
["", "全部状态"],
["pending", "待应战"],
["matched", "已应战"],
["revealing", "揭晓中"],
["finished", "已完成"],
["timeout", "已超时"],
["settlement_failed", "结算失败"],
["cancelled", "已取消"],
];
const emptyFilters = {
challengerUserId: "",
initiatorUserId: "",
roomId: "",
status: "",
};
export function RoomRpsChallengePage() {
const { can } = useAuth();
const { showToast } = useToast();
const canUpdate = can(PERMISSIONS.gameUpdate);
const [items, setItems] = useState([]);
const [filters, setFilters] = useState(emptyFilters);
const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" });
const [cursor, setCursor] = useState("");
const [nextCursor, setNextCursor] = useState("");
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState("");
const [detail, setDetail] = useState(null);
const [detailOpen, setDetailOpen] = useState(false);
const load = useCallback(
async (nextCursorValue = "") => {
setLoading(true);
try {
const result = await listRoomRpsChallenges({
challengerUserId: filters.challengerUserId || undefined,
cursor: nextCursorValue,
endTimeMs: timeRange.endMs || undefined,
initiatorUserId: filters.initiatorUserId || undefined,
pageSize: 50,
roomId: filters.roomId || undefined,
startTimeMs: timeRange.startMs || undefined,
status: filters.status || undefined,
});
setItems(result.items || []);
setCursor(nextCursorValue);
setNextCursor(result.nextCursor || "");
} catch (err) {
showToast(err.message || "加载房内猜拳订单失败", "error");
} finally {
setLoading(false);
}
},
[filters, showToast, timeRange],
);
useEffect(() => {
load("");
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const columns = [
{
key: "challenge",
label: "挑战单",
width: "minmax(220px, 1.3fr)",
render: (item) => (
<div className={styles.identityText}>
<span className={styles.name}>{item.challengeId}</span>
<span className={styles.meta}>{item.roomId || "-"}</span>
</div>
),
},
{
key: "status",
label: "状态",
width: "120px",
render: (item) => statusText(item.status),
},
{
key: "stake",
label: "下注",
width: "minmax(150px, .9fr)",
render: (item) => `${item.stakeGiftId || "-"} / ${formatNumber(item.stakeCoin)}`,
},
{
key: "users",
label: "双方",
width: "minmax(220px, 1.2fr)",
render: (item) => `${playerText(item.initiator)} / ${playerText(item.challenger)}`,
},
{
key: "settlement",
label: "结算",
width: "minmax(160px, .9fr)",
render: (item) => item.settlementStatus || "-",
},
{
key: "created",
label: "创建时间",
width: "minmax(170px, 1fr)",
render: (item) => (item.createdAtMs ? formatMillis(item.createdAtMs) : "-"),
},
{
key: "actions",
label: "操作",
width: "132px",
render: (item) => (
<AdminRowActions>
<AdminActionIconButton label="详情" onClick={() => openDetail(item.challengeId)}>
<VisibilityOutlined fontSize="small" />
</AdminActionIconButton>
<AdminActionIconButton
disabled={
!canUpdate || item.status !== "settlement_failed" || actionLoading === item.challengeId
}
label="重试结算"
onClick={() => retrySettlement(item.challengeId)}
>
<ReplayOutlined fontSize="small" />
</AdminActionIconButton>
<AdminActionIconButton
disabled={!canUpdate || item.status !== "pending" || actionLoading === item.challengeId}
label="手动过期"
onClick={() => expireChallenge(item.challengeId)}
>
<BlockOutlined fontSize="small" />
</AdminActionIconButton>
</AdminRowActions>
),
},
];
const updateFilter = (patch) => setFilters((current) => ({ ...current, ...patch }));
const resetFilters = () => {
setFilters(emptyFilters);
setTimeRange({ endMs: "", startMs: "" });
};
const openDetail = async (challengeId) => {
setActionLoading(challengeId);
try {
const result = await getRoomRpsChallenge(challengeId);
setDetail(result.challenge);
setDetailOpen(true);
} catch (err) {
showToast(err.message || "获取房内猜拳订单详情失败", "error");
} finally {
setActionLoading("");
}
};
const retrySettlement = async (challengeId) => {
setActionLoading(challengeId);
try {
const result = await retryRoomRpsSettlement(challengeId);
patchChallenge(result.challenge);
showToast("结算重试已提交", "success");
} catch (err) {
showToast(err.message || "重试结算失败", "error");
} finally {
setActionLoading("");
}
};
const expireChallenge = async (challengeId) => {
setActionLoading(challengeId);
try {
const result = await expireRoomRpsChallenge(challengeId);
patchChallenge(result.challenge);
showToast("挑战单已过期", "success");
} catch (err) {
showToast(err.message || "手动过期失败", "error");
} finally {
setActionLoading("");
}
};
const patchChallenge = (challenge) => {
setItems((current) => current.map((item) => (item.challengeId === challenge.challengeId ? challenge : item)));
if (detail?.challengeId === challenge.challengeId) {
setDetail(challenge);
}
};
if (loading) {
return <PageSkeleton />;
}
return (
<AdminListPage>
<AdminListToolbar
actions={
<>
<AdminActionIconButton label="查询" onClick={() => load("")}>
<RefreshOutlined fontSize="small" />
</AdminActionIconButton>
<AdminFilterResetButton onClick={resetFilters} />
</>
}
filters={
<>
<TextField
label="房间 ID"
size="small"
value={filters.roomId}
onChange={(event) => updateFilter({ roomId: event.target.value })}
/>
<AdminFilterSelect
options={statusOptions}
value={filters.status}
onChange={(value) => updateFilter({ status: value })}
/>
<TextField
label="发起人"
size="small"
value={filters.initiatorUserId}
onChange={(event) => updateFilter({ initiatorUserId: event.target.value })}
/>
<TextField
label="应战人"
size="small"
value={filters.challengerUserId}
onChange={(event) => updateFilter({ challengerUserId: event.target.value })}
/>
<TimeRangeFilter value={timeRange} onChange={setTimeRange} />
</>
}
/>
<AdminListBody>
<DataTable columns={columns} items={items} minWidth="1120px" rowKey={(item) => item.challengeId} />
</AdminListBody>
<AdminListToolbar
actions={
nextCursor || cursor ? (
<>
<AdminActionIconButton disabled={!cursor} label="上一页" onClick={() => load("")}>
<ReplayOutlined fontSize="small" />
</AdminActionIconButton>
<AdminActionIconButton
disabled={!nextCursor}
label="下一页"
onClick={() => load(nextCursor)}
>
<RefreshOutlined fontSize="small" />
</AdminActionIconButton>
</>
) : null
}
/>
<RoomRpsChallengeDetail challenge={detail} open={detailOpen} onClose={() => setDetailOpen(false)} />
</AdminListPage>
);
}
function RoomRpsChallengeDetail({ challenge, open, onClose }) {
const closeBySubmit = (event) => {
event.preventDefault();
onClose();
};
return (
<AdminFormDialog
open={open}
submitLabel="关闭"
title="房内猜拳订单详情"
onClose={onClose}
onSubmit={closeBySubmit}
>
<AdminFormSection title="订单">
<AdminFormFieldGrid>
<AdminFormReadOnlyField label="挑战单 ID" value={challenge?.challengeId} />
<AdminFormReadOnlyField label="房间 ID" value={challenge?.roomId} />
<AdminFormReadOnlyField label="状态" value={statusText(challenge?.status)} />
<AdminFormReadOnlyField label="结算状态" value={challenge?.settlementStatus} />
<AdminFormReadOnlyField label="失败原因" value={challenge?.failureReason} />
<AdminFormReadOnlyField label="胜者" value={challenge?.winnerUserId} />
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="玩家">
<AdminFormFieldGrid>
<AdminFormReadOnlyField label="发起人" value={detailPlayerText(challenge?.initiator)} />
<AdminFormReadOnlyField label="应战人" value={detailPlayerText(challenge?.challenger)} />
<AdminFormReadOnlyField label="礼物 ID" value={challenge?.stakeGiftId} />
<AdminFormReadOnlyField label="金币" value={formatNumber(challenge?.stakeCoin)} />
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="时间">
<AdminFormFieldGrid>
<AdminFormReadOnlyField label="创建" value={timeText(challenge?.createdAtMs)} />
<AdminFormReadOnlyField label="超时" value={timeText(challenge?.timeoutAtMs)} />
<AdminFormReadOnlyField label="揭晓" value={timeText(challenge?.revealAtMs)} />
<AdminFormReadOnlyField label="结算" value={timeText(challenge?.settledAtMs)} />
</AdminFormFieldGrid>
</AdminFormSection>
</AdminFormDialog>
);
}
function playerText(player) {
if (!player?.userId) return "-";
return `${player.userId}:${player.gesture || "-"}`;
}
function detailPlayerText(player) {
if (!player?.userId) return "-";
return `${player.userId} / ${player.gesture || "-"} / ${player.result || "-"}`;
}
function statusText(status) {
return Object.fromEntries(statusOptions)[status] || status || "-";
}
function timeText(ms) {
return ms ? formatMillis(ms) : "-";
}
function formatNumber(value) {
return Number(value || 0).toLocaleString("en-US");
}

View File

@ -0,0 +1,325 @@
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { useCallback, useEffect, useMemo, useState } from "react";
import { PERMISSIONS } from "@/app/permissions";
import { useAuth } from "@/app/auth/AuthProvider.jsx";
import { getRoomRpsConfig, updateRoomRpsConfig } from "@/features/games/api";
import { listGifts } from "@/features/resources/api";
import { GiftSelectField } from "@/features/resources/components/GiftSelectDrawer.jsx";
import styles from "@/features/games/games.module.css";
import {
AdminActionIconButton,
AdminListBody,
AdminListPage,
AdminListToolbar,
AdminRowActions,
} from "@/shared/ui/AdminListLayout.jsx";
import {
AdminFormDialog,
AdminFormFieldGrid,
AdminFormSection,
AdminFormSwitchField,
} from "@/shared/ui/AdminFormDialog.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { PageSkeleton } from "@/shared/ui/PageSkeleton.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { formatMillis } from "@/shared/utils/time.js";
const defaultStakeGifts = [1, 2, 3, 4].map((sortOrder) => ({
enabled: true,
giftId: "",
sortOrder,
}));
const defaultForm = {
status: "active",
challengeTimeoutMs: 600000,
revealCountdownMs: 3000,
stakeGifts: defaultStakeGifts,
};
export function RoomRpsConfigPage() {
const { can } = useAuth();
const { showToast } = useToast();
const canUpdate = can(PERMISSIONS.gameUpdate);
const [config, setConfig] = useState(null);
const [form, setForm] = useState(defaultForm);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [editing, setEditing] = useState(false);
const [gifts, setGifts] = useState([]);
const [giftsLoading, setGiftsLoading] = useState(true);
const load = useCallback(async () => {
setLoading(true);
try {
const result = await getRoomRpsConfig();
setConfig(result.config);
} catch (err) {
showToast(err.message || "加载房内猜拳配置失败", "error");
} finally {
setLoading(false);
}
}, [showToast]);
const loadGifts = useCallback(async () => {
setGiftsLoading(true);
try {
const result = await listGifts({ page_size: 200, status: "active" });
setGifts(result.items || []);
} catch (err) {
showToast(err.message || "加载礼物列表失败", "error");
} finally {
setGiftsLoading(false);
}
}, [showToast]);
useEffect(() => {
load();
loadGifts();
}, [load, loadGifts]);
const refresh = useCallback(() => {
load();
loadGifts();
}, [load, loadGifts]);
const rows = useMemo(() => (config ? [config] : []), [config]);
const columns = useMemo(
() => [
{
key: "game",
label: "玩法",
width: "minmax(180px, 1fr)",
render: (item) => (
<div className={styles.identityText}>
<span className={styles.name}>房内猜拳</span>
<span className={styles.meta}>{item.gameId || "room_rps"}</span>
</div>
),
},
{
key: "status",
label: "状态",
width: "120px",
render: (item) => statusText(item.status),
},
{
key: "timeout",
label: "时长",
width: "minmax(180px, 1fr)",
render: (item) =>
`${formatSeconds(item.challengeTimeoutMs)} / ${formatSeconds(item.revealCountdownMs)}`,
},
{
key: "gifts",
label: "礼物档位",
width: "minmax(260px, 1.5fr)",
render: (item) =>
(item.stakeGifts || [])
.map(
(gift) =>
`${gift.sortOrder}:${gift.giftName || gift.giftId}${gift.enabled ? "" : "(停用)"}`,
)
.join(" / "),
},
{
key: "updated",
label: "更新时间",
width: "minmax(170px, 1fr)",
render: (item) => (item.updatedAtMs ? formatMillis(item.updatedAtMs) : "-"),
},
{
key: "actions",
label: "操作",
width: "88px",
render: (item) => (
<AdminRowActions>
<AdminActionIconButton disabled={!canUpdate} label="编辑" onClick={() => openEditor(item)}>
<SettingsOutlined fontSize="small" />
</AdminActionIconButton>
</AdminRowActions>
),
},
],
[canUpdate],
);
const openEditor = (item) => {
setForm(configToForm(item));
setEditing(true);
};
const submit = async (event) => {
event.preventDefault();
const payload = formToPayload(form);
if (payload.stakeGifts.length !== 4 || payload.stakeGifts.some((gift) => !gift.giftId)) {
showToast("房内猜拳必须配置 4 个礼物档位", "error");
return;
}
setSaving(true);
try {
const result = await updateRoomRpsConfig(payload);
setConfig(result.config);
setEditing(false);
showToast("房内猜拳配置已保存", "success");
} catch (err) {
showToast(err.message || "保存房内猜拳配置失败", "error");
} finally {
setSaving(false);
}
};
if (loading) {
return <PageSkeleton />;
}
return (
<AdminListPage>
<AdminListToolbar
actions={
<AdminActionIconButton label="刷新" onClick={refresh}>
<RefreshOutlined fontSize="small" />
</AdminActionIconButton>
}
/>
<AdminListBody>
<DataTable
columns={columns}
items={rows}
minWidth="980px"
rowKey={(item) => item.gameId || "room_rps"}
/>
</AdminListBody>
<RoomRpsConfigDialog
form={form}
gifts={gifts}
giftsLoading={giftsLoading}
loading={saving}
open={editing}
setForm={setForm}
onClose={() => setEditing(false)}
onSubmit={submit}
/>
</AdminListPage>
);
}
function RoomRpsConfigDialog({ form, gifts, giftsLoading, loading, open, setForm, onClose, onSubmit }) {
return (
<AdminFormDialog
loading={loading}
open={open}
submitLabel="保存配置"
title="房内猜拳配置"
onClose={onClose}
onSubmit={onSubmit}
>
<AdminFormSection title="基础">
<AdminFormFieldGrid>
<TextField
label="状态"
select
value={form.status}
onChange={(event) => setForm({ ...form, status: event.target.value })}
>
<MenuItem value="active">启用</MenuItem>
<MenuItem value="disabled">停用</MenuItem>
</TextField>
<TextField
label="挑战超时 ms"
type="number"
value={form.challengeTimeoutMs}
onChange={(event) => setForm({ ...form, challengeTimeoutMs: event.target.value })}
/>
<TextField
label="揭晓倒计时 ms"
type="number"
value={form.revealCountdownMs}
onChange={(event) => setForm({ ...form, revealCountdownMs: event.target.value })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="礼物档位">
<div className={styles.giftTierList}>
{form.stakeGifts.map((gift, index) => (
<div className={styles.giftTierRow} key={gift.sortOrder || index}>
<GiftSelectField
drawerTitle={`选择第 ${index + 1} 档礼物`}
gifts={gifts}
label={`${index + 1} 档礼物`}
loading={giftsLoading}
placeholder="点击选择礼物"
value={gift.giftId}
onChange={(value) => setGiftTier(setForm, form, index, { giftId: value })}
/>
<TextField
label="排序"
type="number"
value={gift.sortOrder}
onChange={(event) =>
setGiftTier(setForm, form, index, { sortOrder: event.target.value })
}
/>
<AdminFormSwitchField
checked={gift.enabled}
label="启用"
onChange={(checked) => setGiftTier(setForm, form, index, { enabled: checked })}
/>
</div>
))}
</div>
</AdminFormSection>
</AdminFormDialog>
);
}
function setGiftTier(setForm, form, index, patch) {
const stakeGifts = form.stakeGifts.map((gift, giftIndex) => (giftIndex === index ? { ...gift, ...patch } : gift));
setForm({ ...form, stakeGifts });
}
function configToForm(config) {
const stakeGifts = [...(config.stakeGifts || [])]
.sort((left, right) => Number(left.sortOrder || 0) - Number(right.sortOrder || 0))
.slice(0, 4)
.map((gift, index) => ({
enabled: gift.enabled !== false,
giftId: gift.giftId || "",
sortOrder: gift.sortOrder || index + 1,
}));
while (stakeGifts.length < 4) {
stakeGifts.push({ ...defaultStakeGifts[stakeGifts.length] });
}
return {
status: config.status || "active",
challengeTimeoutMs: config.challengeTimeoutMs || 600000,
revealCountdownMs: config.revealCountdownMs || 3000,
stakeGifts,
};
}
function formToPayload(form) {
return {
status: form.status || "active",
challengeTimeoutMs: Number(form.challengeTimeoutMs || 600000),
revealCountdownMs: Number(form.revealCountdownMs || 3000),
stakeGifts: form.stakeGifts.map((gift, index) => ({
enabled: gift.enabled !== false,
giftId: Number(gift.giftId || 0),
sortOrder: Number(gift.sortOrder || index + 1),
})),
};
}
function statusText(status) {
return status === "disabled" ? "停用" : "启用";
}
function formatSeconds(ms) {
const value = Number(ms || 0);
if (!value) return "-";
return `${Math.round(value / 1000)}s`;
}

View File

@ -0,0 +1,127 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { AppProviders } from "@/app/providers.jsx";
import { RoomRpsConfigPage } from "./RoomRpsConfigPage.jsx";
vi.mock("@/app/auth/AuthProvider.jsx", () => ({
AuthProvider: ({ children }) => children,
useAuth: () => ({ can: () => true }),
}));
vi.mock("@/shared/ui/SideDrawer.jsx", () => ({
SideDrawer({ actions, children, drawerProps, onClose, open, title }) {
if (!open) {
return null;
}
return (
<aside aria-label={title} data-z-index={drawerProps?.sx?.zIndex} role="dialog">
<button type="button" onClick={onClose}>
关闭
</button>
{children}
{actions}
</aside>
);
},
}));
const patchBodies = [];
beforeEach(() => {
patchBodies.length = 0;
vi.stubGlobal("fetch", vi.fn(mockFetch));
});
afterEach(() => {
vi.unstubAllGlobals();
});
test("room rps gift tier uses shared gift drawer selection before saving", async () => {
const user = userEvent.setup();
render(
<AppProviders>
<MemoryRouter>
<RoomRpsConfigPage />
</MemoryRouter>
</AppProviders>,
);
expect(await screen.findByText("房内猜拳")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "编辑" }));
expect(await screen.findByRole("dialog", { name: "房内猜拳配置" })).toBeInTheDocument();
await user.click(screen.getByDisplayValue("Rock Rose"));
expect(screen.getByRole("dialog", { name: "选择第 1 档礼物" })).toHaveAttribute("data-z-index", "1600");
await user.click(screen.getByText("Lucky Star"));
expect(screen.getByDisplayValue("Lucky Star")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "保存配置" }));
await waitFor(() => expect(patchBodies).toHaveLength(1));
expect(patchBodies[0].stakeGifts[0]).toMatchObject({
enabled: true,
giftId: 10005,
sortOrder: 1,
});
});
async function mockFetch(input, init = {}) {
const url = String(input);
const method = init.method || "GET";
if (url.includes("/v1/admin/game/room-rps/config") && method === "GET") {
return jsonResponse({
config: {
challengeTimeoutMs: 600000,
gameId: "room_rps",
revealCountdownMs: 3000,
stakeGifts: [
{ enabled: true, giftId: "10001", giftName: "Rock Rose", sortOrder: 1 },
{ enabled: true, giftId: "10002", giftName: "Paper Bell", sortOrder: 2 },
{ enabled: true, giftId: "10003", giftName: "Scissor Crown", sortOrder: 3 },
{ enabled: true, giftId: "10004", giftName: "Victory Cup", sortOrder: 4 },
],
status: "active",
},
});
}
if (url.includes("/v1/admin/gifts")) {
return jsonResponse({
items: [
{ coinPrice: 10, giftId: "10001", giftTypeCode: "normal", name: "Rock Rose", resourceId: 11 },
{ coinPrice: 20, giftId: "10005", giftTypeCode: "normal", name: "Lucky Star", resourceId: 12 },
],
page: 1,
pageSize: 200,
total: 2,
});
}
if (url.includes("/v1/admin/game/room-rps/config") && method === "PATCH") {
const body = JSON.parse(String(init.body || "{}"));
patchBodies.push(body);
return jsonResponse({
config: {
...body,
gameId: "room_rps",
stakeGifts: body.stakeGifts.map((gift) => ({
...gift,
giftId: String(gift.giftId),
})),
},
});
}
return jsonResponse(null);
}
function jsonResponse(data) {
return new Response(JSON.stringify({ code: 0, data }), { status: 200 });
}

View File

@ -0,0 +1,354 @@
import AddCircleOutlineOutlined from "@mui/icons-material/AddCircleOutlineOutlined";
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { useCallback, useEffect, useMemo, useState } from "react";
import { PERMISSIONS } from "@/app/permissions";
import { useAuth } from "@/app/auth/AuthProvider.jsx";
import { adjustDicePool, listSelfGames, updateDiceConfig } from "@/features/games/api";
import styles from "@/features/games/games.module.css";
import {
AdminActionIconButton,
AdminListBody,
AdminListPage,
AdminListToolbar,
AdminRowActions,
} from "@/shared/ui/AdminListLayout.jsx";
import { AdminFormDialog, AdminFormFieldGrid, AdminFormSection, AdminFormSwitchField } from "@/shared/ui/AdminFormDialog.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { PageSkeleton } from "@/shared/ui/PageSkeleton.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { formatMillis } from "@/shared/utils/time.js";
const defaultConfigForm = {
status: "active",
stakeOptionsText: "100,500,1000,8000",
feeBps: 500,
poolBps: 100,
minPlayers: 2,
maxPlayers: 2,
robotEnabled: true,
robotMatchWaitMs: 3000,
};
const defaultPoolForm = {
amountCoin: "",
direction: "in",
reason: "admin_adjust",
};
export function SelfGamePage() {
const { can } = useAuth();
const { showToast } = useToast();
const canUpdate = can(PERMISSIONS.gameUpdate);
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
const [loadingAction, setLoadingAction] = useState("");
const [activeAction, setActiveAction] = useState("");
const [activeGame, setActiveGame] = useState(null);
const [configForm, setConfigForm] = useState(defaultConfigForm);
const [poolForm, setPoolForm] = useState(defaultPoolForm);
const load = useCallback(async () => {
setLoading(true);
try {
const result = await listSelfGames();
setItems(result.items || []);
} catch (err) {
showToast(err.message || "加载自研游戏失败", "error");
} finally {
setLoading(false);
}
}, [showToast]);
useEffect(() => {
load();
}, [load]);
const columns = useMemo(
() => [
{
key: "game",
label: "游戏",
width: "minmax(180px, 1.2fr)",
render: (game) => (
<div className={styles.identity}>
<span className={styles.name}>{game.gameId === "dice" ? "骰子" : game.gameId}</span>
<span className={styles.meta}>{game.gameId}</span>
</div>
),
},
{
key: "stakes",
label: "下注档位",
width: "minmax(220px, 1.4fr)",
render: (game) => game.stakeOptions.map((item) => formatNumber(item.stakeCoin)).join(" / "),
},
{
key: "fee",
label: "费率",
width: "minmax(140px, .8fr)",
render: (game) => `${bpsPercent(game.feeBps)} / 入池 ${bpsPercent(game.poolBps)}`,
},
{
key: "pool",
label: "奖池",
width: "minmax(120px, .8fr)",
render: (game) => formatNumber(game.poolBalanceCoin),
},
{
key: "robot",
label: "机器人",
width: "minmax(150px, .8fr)",
render: (game) => `${game.robotEnabled ? "启用" : "关闭"} · ${game.robotMatchWaitMs}ms`,
},
{
key: "updated",
label: "更新时间",
width: "minmax(170px, 1fr)",
render: (game) => (game.updatedAtMs ? formatMillis(game.updatedAtMs) : "-"),
},
{
key: "actions",
label: "操作",
width: "128px",
render: (game) => (
<AdminRowActions>
<AdminActionIconButton disabled={!canUpdate} label="配置骰子" onClick={() => openConfig(game)}>
<SettingsOutlined fontSize="small" />
</AdminActionIconButton>
<AdminActionIconButton disabled={!canUpdate} label="调整奖池" onClick={() => openPool(game)}>
<AddCircleOutlineOutlined fontSize="small" />
</AdminActionIconButton>
</AdminRowActions>
),
},
],
[canUpdate],
);
const openConfig = (game) => {
setActiveGame(game);
setConfigForm(configToForm(game));
setActiveAction("config");
};
const openPool = (game) => {
setActiveGame(game);
setPoolForm(defaultPoolForm);
setActiveAction("pool");
};
const closeDialog = () => {
setActiveAction("");
setActiveGame(null);
};
const submitConfig = async (event) => {
event.preventDefault();
if (!activeGame) return;
setLoadingAction("config");
try {
await updateDiceConfig(activeGame.gameId, configPayload(configForm, activeGame.gameId));
await load();
showToast("骰子配置已保存", "success");
closeDialog();
} catch (err) {
showToast(err.message || "保存骰子配置失败", "error");
} finally {
setLoadingAction("");
}
};
const submitPool = async (event) => {
event.preventDefault();
if (!activeGame) return;
setLoadingAction("pool");
try {
await adjustDicePool(activeGame.gameId, {
amountCoin: Number(poolForm.amountCoin || 0),
direction: poolForm.direction,
reason: poolForm.reason,
});
await load();
showToast("奖池已调整", "success");
closeDialog();
} catch (err) {
showToast(err.message || "调整奖池失败", "error");
} finally {
setLoadingAction("");
}
};
if (loading) {
return <PageSkeleton />;
}
return (
<AdminListPage>
<AdminListToolbar
actions={
<AdminActionIconButton label="刷新" onClick={load}>
<RefreshOutlined fontSize="small" />
</AdminActionIconButton>
}
/>
<AdminListBody>
<DataTable columns={columns} items={items} minWidth="980px" rowKey={(game) => game.gameId} />
</AdminListBody>
<ConfigDialog
form={configForm}
loading={loadingAction === "config"}
open={activeAction === "config"}
setForm={setConfigForm}
onClose={closeDialog}
onSubmit={submitConfig}
/>
<PoolDialog
form={poolForm}
loading={loadingAction === "pool"}
open={activeAction === "pool"}
setForm={setPoolForm}
onClose={closeDialog}
onSubmit={submitPool}
/>
</AdminListPage>
);
}
function ConfigDialog({ form, loading, open, setForm, onClose, onSubmit }) {
return (
<AdminFormDialog loading={loading} open={open} submitLabel="保存配置" title="骰子配置" onClose={onClose} onSubmit={onSubmit}>
<AdminFormSection title="下注与费率">
<AdminFormFieldGrid>
<TextField
label="下注档位"
value={form.stakeOptionsText}
onChange={(event) => setForm({ ...form, stakeOptionsText: event.target.value })}
/>
<TextField
label="平台抽水 bps"
type="number"
value={form.feeBps}
onChange={(event) => setForm({ ...form, feeBps: event.target.value })}
/>
<TextField
label="入池 bps"
type="number"
value={form.poolBps}
onChange={(event) => setForm({ ...form, poolBps: event.target.value })}
/>
<TextField
label="状态"
select
value={form.status}
onChange={(event) => setForm({ ...form, status: event.target.value })}
>
<MenuItem value="active">启用</MenuItem>
<MenuItem value="disabled">停用</MenuItem>
</TextField>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="匹配">
<AdminFormFieldGrid>
<TextField
label="最少人数"
type="number"
value={form.minPlayers}
onChange={(event) => setForm({ ...form, minPlayers: event.target.value })}
/>
<TextField
label="最多人数"
type="number"
value={form.maxPlayers}
onChange={(event) => setForm({ ...form, maxPlayers: event.target.value })}
/>
<TextField
label="机器人等待 ms"
type="number"
value={form.robotMatchWaitMs}
onChange={(event) => setForm({ ...form, robotMatchWaitMs: event.target.value })}
/>
<AdminFormSwitchField
checked={form.robotEnabled}
label="机器人补位"
onChange={(event) => setForm({ ...form, robotEnabled: event.target.checked })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
</AdminFormDialog>
);
}
function PoolDialog({ form, loading, open, setForm, onClose, onSubmit }) {
return (
<AdminFormDialog loading={loading} open={open} submitLabel="确认调整" title="调整奖池" onClose={onClose} onSubmit={onSubmit}>
<AdminFormFieldGrid>
<TextField
label="方向"
select
value={form.direction}
onChange={(event) => setForm({ ...form, direction: event.target.value })}
>
<MenuItem value="in">增加</MenuItem>
<MenuItem value="out">减少</MenuItem>
</TextField>
<TextField
label="金币"
type="number"
value={form.amountCoin}
onChange={(event) => setForm({ ...form, amountCoin: event.target.value })}
/>
<TextField
label="原因"
value={form.reason}
onChange={(event) => setForm({ ...form, reason: event.target.value })}
/>
</AdminFormFieldGrid>
</AdminFormDialog>
);
}
function configToForm(game) {
return {
status: game.status || "active",
stakeOptionsText: (game.stakeOptions || []).map((item) => item.stakeCoin).join(","),
feeBps: game.feeBps,
poolBps: game.poolBps,
minPlayers: game.minPlayers,
maxPlayers: game.maxPlayers,
robotEnabled: game.robotEnabled,
robotMatchWaitMs: game.robotMatchWaitMs,
};
}
function configPayload(form, gameId) {
return {
gameId,
status: form.status,
stakeOptions: parseStakeOptions(form.stakeOptionsText),
feeBps: Number(form.feeBps || 0),
poolBps: Number(form.poolBps || 0),
minPlayers: Number(form.minPlayers || 2),
maxPlayers: Number(form.maxPlayers || 2),
robotEnabled: Boolean(form.robotEnabled),
robotMatchWaitMs: Number(form.robotMatchWaitMs || 3000),
};
}
function parseStakeOptions(text) {
return String(text || "")
.split(/[,\s]+/)
.map((value) => Number(value))
.filter((value) => Number.isFinite(value) && value > 0)
.map((stakeCoin, index) => ({ stakeCoin, enabled: true, sortOrder: (index + 1) * 10 }));
}
function bpsPercent(value) {
return `${(Number(value || 0) / 100).toFixed(2)}%`;
}
function formatNumber(value) {
return new Intl.NumberFormat("en-US").format(Number(value || 0));
}

View File

@ -9,4 +9,36 @@ export const gameRoutes = [
path: "/games",
permission: PERMISSIONS.gameView,
},
{
label: "自研游戏",
loader: () => import("./pages/SelfGamePage.jsx").then((module) => module.SelfGamePage),
menuCode: MENU_CODES.selfGames,
pageKey: "self-games",
path: "/games/self-games",
permission: PERMISSIONS.gameView,
},
{
label: "全站机器人",
loader: () => import("./pages/GameRobotPage.jsx").then((module) => module.GameRobotPage),
menuCode: MENU_CODES.gameRobots,
pageKey: "game-robots",
path: "/games/robots",
permission: PERMISSIONS.gameView,
},
{
label: "房内猜拳配置",
loader: () => import("./pages/RoomRpsConfigPage.jsx").then((module) => module.RoomRpsConfigPage),
menuCode: MENU_CODES.roomRpsConfig,
pageKey: "room-rps-config",
path: "/games/room-rps/config",
permission: PERMISSIONS.gameView,
},
{
label: "房内猜拳订单",
loader: () => import("./pages/RoomRpsChallengePage.jsx").then((module) => module.RoomRpsChallengePage),
menuCode: MENU_CODES.roomRpsChallenges,
pageKey: "room-rps-challenges",
path: "/games/room-rps/challenges",
permission: PERMISSIONS.gameView,
},
];

View File

@ -210,6 +210,8 @@ export function deleteTeamSalaryPolicy(policyType: string, policyId: EntityId):
export interface HostSalarySettlementUserDto {
userId: string;
displayUserId?: string;
prettyDisplayUserId?: string;
prettyId?: string;
username?: string;
avatar?: string;
}
@ -415,6 +417,8 @@ function normalizeSettlementUser(raw: Record<string, unknown>): HostSalarySettle
return {
userId: stringValue(raw.userId ?? raw.user_id),
displayUserId: stringValue(raw.displayUserId ?? raw.display_user_id),
prettyDisplayUserId: stringValue(raw.prettyDisplayUserId ?? raw.pretty_display_user_id),
prettyId: stringValue(raw.prettyId ?? raw.pretty_id),
username: stringValue(raw.username),
avatar: stringValue(raw.avatar),
};

View File

@ -1,5 +1,4 @@
import PlayArrowOutlined from "@mui/icons-material/PlayArrowOutlined";
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
import Checkbox from "@mui/material/Checkbox";
import Tab from "@mui/material/Tab";
import Tabs from "@mui/material/Tabs";
@ -18,6 +17,7 @@ import {
AdminListPage,
AdminListToolbar,
} from "@/shared/ui/AdminListLayout.jsx";
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
@ -49,20 +49,14 @@ const policyTypeOptions = [
["admin", "Admin"],
];
const policyTypeAllOptions = [
["", "全部角色"],
...policyTypeOptions,
];
const policyTypeAllOptions = [["", "全部角色"], ...policyTypeOptions];
const triggerOptions = [
["automatic", "自动政策"],
["manual", "手动政策"],
];
const triggerAllOptions = [
["", "全部触发"],
...triggerOptions,
];
const triggerAllOptions = [["", "全部触发"], ...triggerOptions];
export function HostSalarySettlementPage() {
const [activeTab, setActiveTab] = useState("team-pending");
@ -184,13 +178,7 @@ function HostRecordsPanel() {
return column;
});
const hasFilters =
agencyOwnerUserId ||
cycleKey ||
settlementType ||
status ||
timeRange.startMs ||
timeRange.endMs ||
userId;
agencyOwnerUserId || cycleKey || settlementType || status || timeRange.startMs || timeRange.endMs || userId;
return (
<>
@ -360,7 +348,8 @@ function TeamPendingPanel() {
...teamPendingColumns,
];
// hasFiltersBD + +
const hasFilters = policyType !== "bd" || triggerMode !== "automatic" || cycleKey !== defaultCycleKey() || countryCode || regionId;
const hasFilters =
policyType !== "bd" || triggerMode !== "automatic" || cycleKey !== defaultCycleKey() || countryCode || regionId;
return (
<>
@ -377,11 +366,36 @@ function TeamPendingPanel() {
}
filters={
<div className={styles.toolbarFilters}>
<AdminFilterSelect label="角色" options={policyTypeOptions} value={policyType} onChange={changeFilter(setPolicyType)} />
<AdminFilterSelect label="政策触发" options={triggerOptions} value={triggerMode} onChange={changeFilter(setTriggerMode)} />
<InlineFilter label="周期" placeholder="YYYY-MM" value={cycleKey} onChange={changeFilter(setCycleKey)} />
<InlineFilter label="国家" placeholder="国家代码" value={countryCode} onChange={changeFilter(setCountryCode)} />
<InlineFilter label="区域" placeholder="区域 ID" value={regionId} onChange={changeFilter(setRegionId)} />
<AdminFilterSelect
label="角色"
options={policyTypeOptions}
value={policyType}
onChange={changeFilter(setPolicyType)}
/>
<AdminFilterSelect
label="政策触发"
options={triggerOptions}
value={triggerMode}
onChange={changeFilter(setTriggerMode)}
/>
<InlineFilter
label="周期"
placeholder="YYYY-MM"
value={cycleKey}
onChange={changeFilter(setCycleKey)}
/>
<InlineFilter
label="国家"
placeholder="国家代码"
value={countryCode}
onChange={changeFilter(setCountryCode)}
/>
<InlineFilter
label="区域"
placeholder="区域 ID"
value={regionId}
onChange={changeFilter(setRegionId)}
/>
<AdminFilterResetButton disabled={!hasFilters} onClick={resetFilters} />
</div>
}
@ -502,10 +516,31 @@ function TeamRecordsPanel() {
<AdminListToolbar
filters={
<div className={styles.toolbarFilters}>
<TimeRangeFilter value={timeRange} onChange={(value) => { setTimeRange(value); setPage(1); }} />
<AdminFilterSelect label="角色" options={policyTypeAllOptions} value={policyType} onChange={changeFilter(setPolicyType)} />
<AdminFilterSelect label="触发" options={triggerAllOptions} value={triggerMode} onChange={changeFilter(setTriggerMode)} />
<InlineFilter label="国家" placeholder="国家代码" value={countryCode} onChange={changeFilter(setCountryCode)} />
<TimeRangeFilter
value={timeRange}
onChange={(value) => {
setTimeRange(value);
setPage(1);
}}
/>
<AdminFilterSelect
label="角色"
options={policyTypeAllOptions}
value={policyType}
onChange={changeFilter(setPolicyType)}
/>
<AdminFilterSelect
label="触发"
options={triggerAllOptions}
value={triggerMode}
onChange={changeFilter(setTriggerMode)}
/>
<InlineFilter
label="国家"
placeholder="国家代码"
value={countryCode}
onChange={changeFilter(setCountryCode)}
/>
<AdminFilterResetButton disabled={!hasFilters} onClick={resetFilters} />
</div>
}
@ -591,7 +626,9 @@ const teamPendingColumns = [
width: "minmax(180px, 0.8fr)",
render: (item) => (
<div className={styles.stack}>
<span>{roleLabel(item.policyType)} · {item.cycleKey || "-"}</span>
<span>
{roleLabel(item.policyType)} · {item.cycleKey || "-"}
</span>
<span className={styles.meta}>{item.regionName || `区域 ${item.regionId || "-"}`}</span>
</div>
),
@ -652,7 +689,9 @@ const teamRecordColumns = [
render: (item) => (
<div className={styles.stack}>
<span>{item.cycleKey || "-"}</span>
<span className={styles.meta}>{roleLabel(item.policyType)} · {triggerLabel(item.triggerMode)}</span>
<span className={styles.meta}>
{roleLabel(item.policyType)} · {triggerLabel(item.triggerMode)}
</span>
</div>
),
},
@ -704,17 +743,7 @@ function UserCell({ fallbackId, user }) {
if (!idText) {
return "-";
}
return (
<div className={styles.identity}>
<span className={styles.avatar}>
{user?.avatar ? <img alt="" src={user.avatar} /> : <PersonOutlineOutlined fontSize="small" />}
</span>
<div className={styles.identityText}>
<span className={styles.primaryText}>{user?.username || "-"}</span>
<span className={styles.meta}>{idText}</span>
</div>
</div>
);
return <AdminUserIdentity openInAppUserDetail user={{ ...(user || {}), userId: user?.userId || fallbackId }} />;
}
// CycleCell Host/Agency
@ -820,7 +849,9 @@ function settlementTypeLabel(value) {
daily: "日结",
half_month: "半月结算",
month_end: "月底清算",
}[value] || value || "-"
}[value] ||
value ||
"-"
);
}
@ -831,7 +862,9 @@ function statusLabel(value) {
failed: "失败",
skipped: "跳过",
succeeded: "成功",
}[value] || value || "-"
}[value] ||
value ||
"-"
);
}

View File

@ -1,25 +1,17 @@
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import styles from "@/features/host-org/host-org.module.css";
export function HostOrgPerson({ avatar, displayUserId, fallbackId, username }) {
const name = username || "-";
const meta = displayUserId || fallbackId || "-";
export function HostOrgPerson({ avatar, displayUserId, fallbackId, prettyDisplayUserId, prettyId, username }) {
return (
<div className={styles.sellerIdentity}>
{avatar ? (
<img alt="" className={styles.sellerAvatar} src={avatar} />
) : (
<span className={styles.sellerAvatar}>
{String(name || meta)
.slice(0, 1)
.toUpperCase()}
</span>
)}
<span className={styles.sellerText}>
<span className={styles.sellerName}>{name}</span>
<span className={styles.sellerMeta}>{meta}</span>
</span>
</div>
<AdminUserIdentity
avatar={avatar}
displayUserId={displayUserId}
name={username}
openInAppUserDetail
prettyDisplayUserId={prettyDisplayUserId}
prettyId={prettyId}
userId={fallbackId}
/>
);
}

View File

@ -126,7 +126,7 @@ export function useHostAgenciesPage() {
commandId: makeCommandId("agency-close"),
reason: "close agency from list switch",
});
patchAgencyRow(agency.agencyId, data || { status: "closed", joinEnabled: false });
patchAgencyRow(agency.agencyId, agencyActionPatch(data, { status: "closed", joinEnabled: false }));
return data;
});
};
@ -141,7 +141,7 @@ export function useHostAgenciesPage() {
joinEnabled,
reason: "set agency join enabled from list switch",
});
patchAgencyRow(agency.agencyId, data || { joinEnabled });
patchAgencyRow(agency.agencyId, agencyActionPatch(data, { joinEnabled }));
return data;
});
};
@ -221,3 +221,13 @@ export function useHostAgenciesPage() {
function makeCommandId(prefix) {
return `${prefix}-${Date.now()}`;
}
function agencyActionPatch(data, fallback = {}) {
const patch = { ...fallback };
for (const key of ["status", "joinEnabled", "updatedAtMs"]) {
if (data?.[key] !== undefined) {
patch[key] = data[key];
}
}
return patch;
}

View File

@ -7,6 +7,7 @@ import TextField from "@mui/material/TextField";
import { formatMillis } from "@/shared/utils/time.js";
import { DataState } from "@/shared/ui/DataState.jsx";
import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx";
@ -211,25 +212,7 @@ export function HostBdLeadersPage() {
}
function PersonIdentity({ item }) {
const name = item.username || "-";
const meta = item.displayUserId || item.userId || "-";
return (
<div className={styles.sellerIdentity}>
{item.avatar ? (
<img alt="" className={styles.sellerAvatar} src={item.avatar} />
) : (
<span className={styles.sellerAvatar}>
{String(name || meta)
.slice(0, 1)
.toUpperCase()}
</span>
)}
<span className={styles.sellerText}>
<span className={styles.sellerName}>{name}</span>
<span className={styles.sellerMeta}>{meta}</span>
</span>
</div>
);
return <AdminUserIdentity openInAppUserDetail user={item} />;
}
function CreatorIdentity({ item }) {

View File

@ -19,6 +19,7 @@ import {
import { formatMillis } from "@/shared/utils/time.js";
import { DataState } from "@/shared/ui/DataState.jsx";
import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { InlineEditableCell } from "@/shared/ui/InlineEditableCell.jsx";
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
import { coinSellerStatusFilters } from "@/features/host-org/constants.js";
@ -157,10 +158,7 @@ export function HostCoinSellersPage() {
return (
<section className={styles.root}>
<div className={styles.contentPanel}>
<HostOrgToolbar
actions={toolbarActions}
leadingActions={leadingActions}
/>
<HostOrgToolbar actions={toolbarActions} leadingActions={leadingActions} />
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<div className={styles.listBlock}>
@ -314,7 +312,10 @@ export function HostCoinSellersPage() {
onClose={page.closeAction}
>
{page.selectedSeller ? (
<CoinSellerLedgerTable lockedSeller={page.selectedSeller} sellerUserId={page.selectedSeller.userId} />
<CoinSellerLedgerTable
lockedSeller={page.selectedSeller}
sellerUserId={page.selectedSeller.userId}
/>
) : null}
</SideDrawer>
@ -377,9 +378,7 @@ export function HostCoinSellersPage() {
label="coin per USD"
required
value={tier.coinPerUsd}
onChange={(event) =>
page.updateRateTier(index, { coinPerUsd: event.target.value })
}
onChange={(event) => page.updateRateTier(index, { coinPerUsd: event.target.value })}
/>
<div className={styles.rateTierSwitch}>
<AdminSwitch
@ -387,7 +386,9 @@ export function HostCoinSellersPage() {
checkedLabel="启用"
label="状态"
uncheckedLabel="停用"
onChange={(event) => page.updateRateTier(index, { enabled: event.target.checked })}
onChange={(event) =>
page.updateRateTier(index, { enabled: event.target.checked })
}
/>
</div>
<AdminActionIconButton
@ -407,22 +408,7 @@ export function HostCoinSellersPage() {
}
function SellerIdentity({ item }) {
const name = item.username || "-";
const meta = item.displayUserId || item.userId;
return (
<div className={styles.sellerIdentity}>
{item.avatar ? (
<img alt="" className={styles.sellerAvatar} src={item.avatar} />
) : (
<span className={styles.sellerAvatar}>{name.slice(0, 1).toUpperCase()}</span>
)}
<span className={styles.sellerText}>
<span className={styles.sellerName}>{name}</span>
<span className={styles.sellerMeta}>{meta}</span>
</span>
</div>
);
return <AdminUserIdentity openInAppUserDetail user={item} />;
}
function SellerStatusSwitch({ item, page }) {
@ -460,7 +446,11 @@ function SellerActions({ item, page }) {
return (
<AdminRowActions>
{page.abilities.canLedger ? (
<AdminActionIconButton label="币商流水" sx={coinLedgerActionSx} onClick={() => page.openSellerLedger(item)}>
<AdminActionIconButton
label="币商流水"
sx={coinLedgerActionSx}
onClick={() => page.openSellerLedger(item)}
>
<ReceiptLongOutlined fontSize="small" />
</AdminActionIconButton>
) : null}

View File

@ -0,0 +1,186 @@
import { apiRequest } from "@/shared/api/request";
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
import type { ApiPage, PageQuery } from "@/shared/api/types";
export interface InviteActivityRewardTierDto {
tierId: number;
rewardType: string;
tierCode: string;
tierName: string;
thresholdCoinAmount: number;
thresholdValidInviteCount: number;
rewardCoinAmount: number;
status: string;
sortOrder: number;
createdAtMs?: number;
updatedAtMs?: number;
}
export interface InviteActivityRewardConfigDto {
appCode?: string;
enabled: boolean;
perInviteInviterRewardCoinAmount: number;
perInviteInviteeRewardCoinAmount: number;
tiers: InviteActivityRewardTierDto[];
updatedByAdminId?: number;
createdAtMs?: number;
updatedAtMs?: number;
}
export interface InviteActivityRewardConfigPayload {
enabled: boolean;
per_invite_inviter_reward_coin_amount: number;
per_invite_invitee_reward_coin_amount: number;
tiers: Array<{
tier_id?: number;
reward_type: string;
tier_code: string;
tier_name: string;
threshold_coin_amount: number;
threshold_valid_invite_count: number;
reward_coin_amount: number;
status: string;
sort_order: number;
}>;
}
export interface InviteActivityRewardClaimUserDto {
userId?: number;
displayUserId?: string;
username?: string;
avatar?: string;
}
export interface InviteActivityRewardClaimDto {
claimId: string;
appCode?: string;
cycleKey?: string;
userId: number;
user?: InviteActivityRewardClaimUserDto;
rewardType?: string;
commandId?: string;
tierId?: number;
tierCode?: string;
tierName?: string;
thresholdCoinAmount?: number;
thresholdValidInviteCount?: number;
reachedValue?: number;
rewardCoinAmount?: number;
status?: string;
walletCommandId?: string;
walletTransactionId?: string;
failureReason?: string;
grantedAtMs?: number;
createdAtMs?: number;
updatedAtMs?: number;
}
type RawConfig = InviteActivityRewardConfigDto & Record<string, unknown>;
type RawTier = InviteActivityRewardTierDto & Record<string, unknown>;
type RawClaim = InviteActivityRewardClaimDto & Record<string, unknown>;
export function getInviteActivityRewardConfig(): Promise<InviteActivityRewardConfigDto> {
const endpoint = API_ENDPOINTS.getInviteActivityRewardConfig;
return apiRequest<RawConfig>(apiEndpointPath(API_OPERATIONS.getInviteActivityRewardConfig), {
method: endpoint.method,
}).then(normalizeConfig);
}
export function updateInviteActivityRewardConfig(
payload: InviteActivityRewardConfigPayload,
): Promise<InviteActivityRewardConfigDto> {
const endpoint = API_ENDPOINTS.updateInviteActivityRewardConfig;
return apiRequest<RawConfig, InviteActivityRewardConfigPayload>(
apiEndpointPath(API_OPERATIONS.updateInviteActivityRewardConfig),
{
body: payload,
method: endpoint.method,
},
).then(normalizeConfig);
}
export function listInviteActivityRewardClaims(query: PageQuery = {}): Promise<ApiPage<InviteActivityRewardClaimDto>> {
const endpoint = API_ENDPOINTS.listInviteActivityRewardClaims;
return apiRequest<ApiPage<RawClaim>>(apiEndpointPath(API_OPERATIONS.listInviteActivityRewardClaims), {
method: endpoint.method,
query,
}).then((page) => ({
...page,
items: (page.items || []).map(normalizeClaim),
}));
}
function normalizeConfig(item: RawConfig): InviteActivityRewardConfigDto {
const rawTiers = Array.isArray(item.tiers) ? (item.tiers as RawTier[]) : [];
return {
appCode: stringValue(item.appCode ?? item.app_code),
enabled: Boolean(item.enabled),
perInviteInviterRewardCoinAmount: numberValue(
item.perInviteInviterRewardCoinAmount ?? item.per_invite_inviter_reward_coin_amount,
),
perInviteInviteeRewardCoinAmount: numberValue(
item.perInviteInviteeRewardCoinAmount ?? item.per_invite_invitee_reward_coin_amount,
),
tiers: rawTiers.map(normalizeTier),
updatedByAdminId: numberValue(item.updatedByAdminId ?? item.updated_by_admin_id),
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
};
}
function normalizeTier(item: RawTier): InviteActivityRewardTierDto {
return {
tierId: numberValue(item.tierId ?? item.tier_id),
rewardType: stringValue(item.rewardType ?? item.reward_type),
tierCode: stringValue(item.tierCode ?? item.tier_code),
tierName: stringValue(item.tierName ?? item.tier_name),
thresholdCoinAmount: numberValue(item.thresholdCoinAmount ?? item.threshold_coin_amount),
thresholdValidInviteCount: numberValue(item.thresholdValidInviteCount ?? item.threshold_valid_invite_count),
rewardCoinAmount: numberValue(item.rewardCoinAmount ?? item.reward_coin_amount),
status: stringValue(item.status) || "active",
sortOrder: numberValue(item.sortOrder ?? item.sort_order),
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
};
}
function normalizeClaim(item: RawClaim): InviteActivityRewardClaimDto {
const rawUser = (item.user || {}) as Record<string, unknown>;
return {
claimId: stringValue(item.claimId ?? item.claim_id),
appCode: stringValue(item.appCode ?? item.app_code),
cycleKey: stringValue(item.cycleKey ?? item.cycle_key),
userId: numberValue(item.userId ?? item.user_id),
user: {
userId: numberValue(rawUser.userId ?? rawUser.user_id),
displayUserId: stringValue(rawUser.displayUserId ?? rawUser.display_user_id),
username: stringValue(rawUser.username),
avatar: stringValue(rawUser.avatar),
},
rewardType: stringValue(item.rewardType ?? item.reward_type),
commandId: stringValue(item.commandId ?? item.command_id),
tierId: numberValue(item.tierId ?? item.tier_id),
tierCode: stringValue(item.tierCode ?? item.tier_code),
tierName: stringValue(item.tierName ?? item.tier_name),
thresholdCoinAmount: numberValue(item.thresholdCoinAmount ?? item.threshold_coin_amount),
thresholdValidInviteCount: numberValue(item.thresholdValidInviteCount ?? item.threshold_valid_invite_count),
reachedValue: numberValue(item.reachedValue ?? item.reached_value),
rewardCoinAmount: numberValue(item.rewardCoinAmount ?? item.reward_coin_amount),
status: stringValue(item.status),
walletCommandId: stringValue(item.walletCommandId ?? item.wallet_command_id),
walletTransactionId: stringValue(item.walletTransactionId ?? item.wallet_transaction_id),
failureReason: stringValue(item.failureReason ?? item.failure_reason),
grantedAtMs: numberValue(item.grantedAtMs ?? item.granted_at_ms),
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
};
}
function stringValue(value: unknown) {
return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
}
function numberValue(value: unknown) {
const number = Number(value || 0);
return Number.isFinite(number) ? number : 0;
}

View File

@ -0,0 +1,237 @@
import AddOutlined from "@mui/icons-material/AddOutlined";
import SaveOutlined from "@mui/icons-material/SaveOutlined";
import Drawer from "@mui/material/Drawer";
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 styles from "@/features/invite-activity-reward/invite-activity-reward.module.css";
const rewardTypeOptions = [
["recharge", "累计充值"],
["valid_invite", "有效人数"],
];
export function InviteActivityRewardConfigDrawer({
abilities,
configLoading,
configSaving,
form,
onClose,
onSubmit,
open,
setForm,
}) {
const disabled = !abilities.canUpdate || configLoading || configSaving;
const addTier = () => {
setForm((current) => ({
...current,
tiers: [
...(current.tiers || []),
{
tierId: 0,
rewardType: "recharge",
tierCode: `invite_reward_${Date.now()}_${(current.tiers || []).length + 1}`,
tierName: "",
thresholdCoinAmount: "",
thresholdValidInviteCount: "",
rewardCoinAmount: "",
status: "active",
sortOrder: String((current.tiers || []).length),
},
],
}));
};
const updateTier = (index, patch) => {
setForm((current) => ({
...current,
tiers: (current.tiers || []).map((tier, tierIndex) => (tierIndex === index ? { ...tier, ...patch } : tier)),
}));
};
const removeTier = (index) => {
setForm((current) => ({
...current,
tiers: (current.tiers || []).filter((_, tierIndex) => tierIndex !== index),
}));
};
return (
<Drawer anchor="right" open={open} onClose={configSaving ? undefined : onClose}>
<form className="form-drawer form-drawer--activity-config" onSubmit={onSubmit}>
<h2>邀请活动配置</h2>
<div className="form-drawer__content">
<section className="form-drawer__section">
<div className="form-drawer__section-title">活动状态</div>
<AdminSwitch
checked={form.enabled}
checkedLabel="开启"
disabled={disabled}
label="邀请活动状态"
uncheckedLabel="关闭"
onChange={(event) => setForm((current) => ({ ...current, enabled: event.target.checked }))}
/>
</section>
<section className="form-drawer__section">
<div className="form-drawer__section-title">每邀请一人奖励</div>
<div className={styles.inlineFields}>
<TextField
disabled={disabled}
inputProps={{ min: 0, step: 1 }}
label="邀请人获得金币"
placeholder="0"
type="number"
value={form.perInviteInviterRewardCoinAmount || ""}
onChange={(event) =>
setForm((current) => ({
...current,
perInviteInviterRewardCoinAmount: event.target.value,
}))
}
/>
<TextField
disabled={disabled}
inputProps={{ min: 0, step: 1 }}
label="被邀请人获得金币"
placeholder="0"
type="number"
value={form.perInviteInviteeRewardCoinAmount || ""}
onChange={(event) =>
setForm((current) => ({
...current,
perInviteInviteeRewardCoinAmount: event.target.value,
}))
}
/>
</div>
</section>
<section className="form-drawer__section">
<div className={styles.drawerSectionTitle}>
<span>奖励档位</span>
<Button
disabled={disabled}
startIcon={<AddOutlined fontSize="small" />}
type="button"
onClick={addTier}
>
添加档位
</Button>
</div>
<RewardTierEditorTable
columns={[
{
key: "tier",
label: "档位",
render: (_tier, index) => (
<span className="reward-tier-editor-table__index">档位 {index + 1}</span>
),
},
{
key: "rewardType",
label: "类型",
render: (tier, index) => (
<TextField
select
disabled={disabled}
value={tier.rewardType || "recharge"}
onChange={(event) => updateTier(index, { rewardType: event.target.value })}
>
{rewardTypeOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
),
},
{
key: "threshold",
label: "门槛",
render: (tier, index) => {
const isRecharge = (tier.rewardType || "recharge") === "recharge";
return (
<TextField
disabled={disabled}
inputProps={{ min: 1, step: 1 }}
placeholder={isRecharge ? "80000" : "1"}
type="number"
value={
isRecharge
? tier.thresholdCoinAmount
: tier.thresholdValidInviteCount
}
onChange={(event) =>
updateTier(
index,
isRecharge
? { thresholdCoinAmount: event.target.value }
: { thresholdValidInviteCount: event.target.value },
)
}
/>
);
},
},
{
key: "rewardCoinAmount",
label: "奖励金币",
render: (tier, index) => (
<TextField
disabled={disabled}
inputProps={{ min: 1, step: 1 }}
placeholder="1000"
type="number"
value={tier.rewardCoinAmount}
onChange={(event) =>
updateTier(index, { rewardCoinAmount: event.target.value })
}
/>
),
},
{
key: "status",
label: "状态",
render: (tier, index) => (
<AdminSwitch
checked={tier.status !== "inactive"}
checkedLabel="启用"
disabled={disabled}
label={`档位 ${index + 1} 状态`}
uncheckedLabel="停用"
onChange={(event) =>
updateTier(index, {
status: event.target.checked ? "active" : "inactive",
})
}
/>
),
},
]}
disabled={disabled}
emptyText="暂无档位"
gridTemplateColumns="72px minmax(130px, 0.7fr) minmax(150px, 0.8fr) minmax(150px, 0.8fr) 120px 52px"
rows={form.tiers || []}
onRemoveTier={removeTier}
/>
</section>
</div>
<div className="form-drawer__actions">
<Button disabled={configSaving} type="button" onClick={onClose}>
取消
</Button>
<Button
disabled={disabled}
startIcon={<SaveOutlined fontSize="small" />}
type="submit"
variant="primary"
>
保存
</Button>
</div>
</form>
</Drawer>
);
}

View File

@ -0,0 +1,87 @@
import EditOutlined from "@mui/icons-material/EditOutlined";
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
import { Button } from "@/shared/ui/Button.jsx";
import styles from "@/features/invite-activity-reward/invite-activity-reward.module.css";
export function InviteActivityRewardConfigSummary({ canUpdate, config, configLoading, onEdit, onRefresh }) {
const tiers = config?.tiers || [];
const activeTiers = tiers.filter((tier) => tier.status === "active");
const rechargeTiers = activeTiers.filter((tier) => tier.rewardType === "recharge");
const validInviteTiers = activeTiers.filter((tier) => tier.rewardType === "valid_invite");
return (
<div className={styles.summaryPanel}>
<div className={styles.summaryItems}>
<SummaryItem label="状态">
<span
className={[
styles.statusBadge,
config?.enabled ? styles.statusActive : styles.statusInactive,
].join(" ")}
>
{config?.enabled ? "启用" : "停用"}
</span>
</SummaryItem>
<SummaryItem label="累计充值档位">
{thresholdSummary(rechargeTiers, "thresholdCoinAmount", "金币")}
</SummaryItem>
<SummaryItem label="有效人数档位">
{thresholdSummary(validInviteTiers, "thresholdValidInviteCount", "人")}
</SummaryItem>
<SummaryItem label="邀请人即时奖励">
{coinSummary(config?.perInviteInviterRewardCoinAmount)}
</SummaryItem>
<SummaryItem label="被邀请人即时奖励">
{coinSummary(config?.perInviteInviteeRewardCoinAmount)}
</SummaryItem>
<SummaryItem label="奖励">{thresholdSummary(activeTiers, "rewardCoinAmount", "金币")}</SummaryItem>
</div>
<div className={styles.summaryActions}>
<Button disabled={configLoading} startIcon={<RefreshOutlined fontSize="small" />} onClick={onRefresh}>
刷新
</Button>
{canUpdate ? (
<Button
disabled={configLoading}
startIcon={<EditOutlined fontSize="small" />}
variant="primary"
onClick={onEdit}
>
编辑配置
</Button>
) : null}
</div>
</div>
);
}
function SummaryItem({ children, label }) {
return (
<div className={styles.summaryItem}>
<span className={styles.summaryLabel}>{label}</span>
<span className={styles.summaryValue}>{children}</span>
</div>
);
}
function thresholdSummary(tiers, field, unit) {
if (!tiers.length) {
return "-";
}
const values = tiers.map((tier) => Number(tier[field] || 0)).filter((value) => value > 0);
if (!values.length) {
return "-";
}
const min = Math.min(...values);
const max = Math.max(...values);
return min === max ? `${formatNumber(min)} ${unit}` : `${formatNumber(min)} - ${formatNumber(max)} ${unit}`;
}
function coinSummary(value) {
const amount = Number(value || 0);
return amount > 0 ? `${formatNumber(amount)} 金币` : "-";
}
function formatNumber(value) {
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
}

View File

@ -0,0 +1,224 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
getInviteActivityRewardConfig,
listInviteActivityRewardClaims,
updateInviteActivityRewardConfig,
} from "@/features/invite-activity-reward/api";
import { useInviteActivityRewardAbilities } from "@/features/invite-activity-reward/permissions.js";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
const pageSize = 50;
const emptyClaims = { items: [], page: 1, pageSize, total: 0 };
const emptyForm = {
enabled: true,
perInviteInviterRewardCoinAmount: "",
perInviteInviteeRewardCoinAmount: "",
tiers: [],
};
export function useInviteActivityRewardPage() {
const abilities = useInviteActivityRewardAbilities();
const { showToast } = useToast();
const [config, setConfig] = useState(null);
const [configDrawerOpen, setConfigDrawerOpen] = useState(false);
const [form, setForm] = useState(emptyForm);
const [configLoading, setConfigLoading] = useState(false);
const [configSaving, setConfigSaving] = useState(false);
const [query, setQuery] = useState("");
const [cycleKey, setCycleKey] = useState("");
const [rewardType, setRewardType] = useState("");
const [status, setStatus] = useState("");
const [page, setPage] = useState(1);
const filters = useMemo(
() => ({ keyword: query, cycle_key: cycleKey, reward_type: rewardType, status }),
[cycleKey, query, rewardType, status],
);
const {
data: claims = emptyClaims,
error: claimsError,
loading: claimsLoading,
reload: reloadClaims,
} = usePaginatedQuery({
errorMessage: "加载邀请活动领取记录失败",
fetcher: listInviteActivityRewardClaims,
filters,
page,
pageSize,
queryKey: ["invite-activity-reward-claims", filters, page],
});
const reloadConfig = useCallback(async () => {
setConfigLoading(true);
try {
const loaded = await getInviteActivityRewardConfig();
setConfig(loaded);
setForm(formFromConfig(loaded));
} catch (err) {
showToast(err.message || "加载邀请活动配置失败", "error");
} finally {
setConfigLoading(false);
}
}, [showToast]);
useEffect(() => {
void reloadConfig();
}, [reloadConfig]);
const openConfigDrawer = useCallback(() => {
setForm(config ? formFromConfig(config) : emptyForm);
setConfigDrawerOpen(true);
}, [config]);
const closeConfigDrawer = useCallback(() => {
setConfigDrawerOpen(false);
setForm(config ? formFromConfig(config) : emptyForm);
}, [config]);
const changeQuery = (value) => {
setQuery(value);
setPage(1);
};
const changeCycleKey = (value) => {
setCycleKey(value);
setPage(1);
};
const changeRewardType = (value) => {
setRewardType(value);
setPage(1);
};
const changeStatus = (value) => {
setStatus(value);
setPage(1);
};
const submitConfig = async (event) => {
event.preventDefault();
if (!abilities.canUpdate) {
return;
}
setConfigSaving(true);
try {
const saved = await updateInviteActivityRewardConfig(payloadFromForm(form));
setConfig(saved);
setForm(formFromConfig(saved));
setConfigDrawerOpen(false);
showToast("邀请活动配置已保存", "success");
await reloadClaims();
} catch (err) {
showToast(err.message || "保存邀请活动配置失败", "error");
} finally {
setConfigSaving(false);
}
};
return {
abilities,
changeCycleKey,
changeQuery,
changeRewardType,
changeStatus,
claims,
claimsError,
claimsLoading,
closeConfigDrawer,
config,
configDrawerOpen,
configLoading,
configSaving,
cycleKey,
form,
openConfigDrawer,
page,
query,
reloadClaims,
reloadConfig,
rewardType,
setForm,
setPage,
status,
submitConfig,
};
}
function formFromConfig(config) {
return {
enabled: Boolean(config.enabled),
perInviteInviterRewardCoinAmount: config.perInviteInviterRewardCoinAmount
? String(config.perInviteInviterRewardCoinAmount)
: "",
perInviteInviteeRewardCoinAmount: config.perInviteInviteeRewardCoinAmount
? String(config.perInviteInviteeRewardCoinAmount)
: "",
tiers: (config.tiers || []).map((tier) => ({
tierId: tier.tierId || 0,
rewardType: tier.rewardType || "recharge",
tierCode: tier.tierCode || "",
tierName: tier.tierName || "",
thresholdCoinAmount: tier.thresholdCoinAmount ? String(tier.thresholdCoinAmount) : "",
thresholdValidInviteCount: tier.thresholdValidInviteCount ? String(tier.thresholdValidInviteCount) : "",
rewardCoinAmount: tier.rewardCoinAmount ? String(tier.rewardCoinAmount) : "",
status: tier.status || "active",
sortOrder: String(tier.sortOrder || 0),
})),
};
}
function payloadFromForm(form) {
const perInviteInviterRewardCoinAmount = Number(form.perInviteInviterRewardCoinAmount || 0);
const perInviteInviteeRewardCoinAmount = Number(form.perInviteInviteeRewardCoinAmount || 0);
if (perInviteInviterRewardCoinAmount < 0 || perInviteInviteeRewardCoinAmount < 0) {
throw new Error("每邀请一人奖励金币不能小于 0");
}
const tiers = (form.tiers || []).map((tier, index) => {
const rewardType = tier.rewardType === "valid_invite" ? "valid_invite" : "recharge";
const thresholdCoinAmount = rewardType === "recharge" ? Number(tier.thresholdCoinAmount || 0) : 0;
const thresholdValidInviteCount =
rewardType === "valid_invite" ? Number(tier.thresholdValidInviteCount || 0) : 0;
const rewardCoinAmount = Number(tier.rewardCoinAmount || 0);
if (rewardType === "recharge" && thresholdCoinAmount <= 0) {
throw new Error("累计充值金币门槛必须大于 0");
}
if (rewardType === "valid_invite" && thresholdValidInviteCount <= 0) {
throw new Error("有效人数门槛必须大于 0");
}
if (rewardCoinAmount <= 0) {
throw new Error("奖励金币必须大于 0");
}
const tierCode = String(tier.tierCode || "").trim() || `invite_${rewardType}_${Date.now()}_${index + 1}`;
const tierName =
String(tier.tierName || "").trim() ||
(rewardType === "recharge"
? `累计充值 ${formatNumber(thresholdCoinAmount)} 金币`
: `有效邀请 ${formatNumber(thresholdValidInviteCount)}`);
return {
tier_id: Number(tier.tierId || 0),
reward_type: rewardType,
tier_code: tierCode,
tier_name: tierName,
threshold_coin_amount: thresholdCoinAmount,
threshold_valid_invite_count: thresholdValidInviteCount,
reward_coin_amount: rewardCoinAmount,
status: tier.status === "inactive" ? "inactive" : "active",
sort_order: index,
};
});
if (
form.enabled &&
tiers.filter((tier) => tier.status === "active").length === 0 &&
perInviteInviterRewardCoinAmount <= 0 &&
perInviteInviteeRewardCoinAmount <= 0
) {
throw new Error("开启后至少需要一个启用档位或每邀请一人奖励");
}
return {
enabled: Boolean(form.enabled),
per_invite_inviter_reward_coin_amount: perInviteInviterRewardCoinAmount,
per_invite_invitee_reward_coin_amount: perInviteInviteeRewardCoinAmount,
tiers,
};
}
function formatNumber(value) {
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
}

View File

@ -0,0 +1,119 @@
.summaryPanel {
display: flex;
flex: 0 0 auto;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: var(--space-5);
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--border);
}
.summaryActions,
.summaryItems {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-3);
}
.summaryItems {
flex: 1;
gap: var(--space-7);
}
.summaryItem {
display: inline-flex;
min-width: 0;
align-items: center;
gap: var(--space-2);
}
.summaryLabel {
flex: 0 0 auto;
color: var(--text-tertiary);
font-size: var(--admin-font-size);
font-weight: 650;
}
.summaryValue {
display: inline-flex;
min-width: 0;
align-items: center;
overflow: hidden;
color: var(--text-primary);
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.statusBadge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 52px;
height: 26px;
padding: 0 var(--space-2);
border-radius: 999px;
font-size: 12px;
font-weight: 750;
}
.statusActive {
background: var(--success-surface);
color: var(--success);
}
.statusInactive {
background: var(--fill-secondary);
color: var(--text-tertiary);
}
.stack {
display: grid;
min-width: 0;
gap: 2px;
}
.meta {
overflow: hidden;
color: var(--text-tertiary);
font-size: var(--admin-font-size);
text-overflow: ellipsis;
white-space: nowrap;
}
.drawerSectionTitle {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
color: var(--text-secondary);
font-weight: 700;
}
.inlineFields {
display: grid;
grid-template-columns: repeat(2, minmax(180px, 1fr));
gap: var(--space-3);
}
@media (max-width: 900px) {
.summaryPanel {
align-items: flex-start;
flex-direction: column;
}
.summaryItems {
width: 100%;
align-items: flex-start;
flex-direction: column;
gap: var(--space-3);
}
}
@media (max-width: 640px) {
.inlineFields {
grid-template-columns: 1fr;
}
}

View File

@ -0,0 +1,223 @@
import { InviteActivityRewardConfigDrawer } from "@/features/invite-activity-reward/components/InviteActivityRewardConfigDrawer.jsx";
import { InviteActivityRewardConfigSummary } from "@/features/invite-activity-reward/components/InviteActivityRewardConfigSummary.jsx";
import { useInviteActivityRewardPage } from "@/features/invite-activity-reward/hooks/useInviteActivityRewardPage.js";
import styles from "@/features/invite-activity-reward/invite-activity-reward.module.css";
import { AdminListBody, AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
const claimStatusOptions = [
["pending", "发放中"],
["granted", "已发放"],
["failed", "发放失败"],
];
const rewardTypeOptions = [
["recharge", "累计充值"],
["valid_invite", "有效人数"],
["invite_inviter", "邀请人即时"],
["invite_invitee", "被邀请人即时"],
];
const columnsBase = [
{
key: "user",
label: "用户信息",
width: "minmax(260px, 1.1fr)",
render: (claim) => <ClaimUser claim={claim} />,
},
{
key: "cycle",
label: "周期",
width: "minmax(140px, 0.6fr)",
render: (claim) => claim.cycleKey || "-",
},
{
key: "rewardType",
label: "奖励类型",
width: "minmax(130px, 0.6fr)",
render: (claim) => rewardTypeLabel(claim.rewardType),
},
{
key: "tier",
label: "档位",
width: "minmax(210px, 0.9fr)",
render: (claim) => (
<div className={styles.stack}>
<span>{claim.tierName || claim.tierCode || "-"}</span>
<span className={styles.meta}>{thresholdLabel(claim)}</span>
</div>
),
},
{
key: "reached",
label: "达成值",
width: "minmax(150px, 0.65fr)",
render: (claim) => reachedLabel(claim),
},
{
key: "reward",
label: "奖励金币",
width: "minmax(130px, 0.6fr)",
render: (claim) => formatNumber(claim.rewardCoinAmount),
},
{
key: "status",
label: "状态",
width: "minmax(120px, 0.55fr)",
render: (claim) => claimStatusLabel(claim.status),
},
{
key: "createdAt",
label: "创建时间",
width: "minmax(180px, 0.85fr)",
render: (claim) => formatMillis(claim.createdAtMs),
},
{
key: "claimId",
label: "记录",
width: "minmax(280px, 1fr)",
render: (claim) => (
<div className={styles.stack}>
<span>{claim.claimId}</span>
<span className={styles.meta}>{claim.walletTransactionId || claim.walletCommandId || "-"}</span>
</div>
),
},
{
key: "failure",
label: "失败原因",
width: "minmax(180px, 0.85fr)",
render: (claim) => claim.failureReason || "-",
},
];
export function InviteActivityRewardPage() {
const page = useInviteActivityRewardPage();
const total = page.claims.total || 0;
const columns = columnsBase.map((column) => {
if (column.key === "user") {
return {
...column,
filter: createTextColumnFilter({
placeholder: "搜索用户 ID、短号、名称",
value: page.query,
onChange: page.changeQuery,
}),
};
}
if (column.key === "cycle") {
return {
...column,
filter: createTextColumnFilter({
placeholder: "周期,如 2026-06",
value: page.cycleKey,
onChange: page.changeCycleKey,
}),
};
}
if (column.key === "rewardType") {
return {
...column,
filter: createOptionsColumnFilter({
options: [["", "全部类型"], ...rewardTypeOptions],
placeholder: "奖励类型",
value: page.rewardType,
onChange: page.changeRewardType,
}),
};
}
if (column.key === "status") {
return {
...column,
filter: createOptionsColumnFilter({
options: [["", "全部状态"], ...claimStatusOptions],
placeholder: "搜索状态",
value: page.status,
onChange: page.changeStatus,
}),
};
}
return column;
});
return (
<AdminListPage>
<InviteActivityRewardConfigSummary
canUpdate={page.abilities.canUpdate}
config={page.config}
configLoading={page.configLoading}
onEdit={page.openConfigDrawer}
onRefresh={page.reloadConfig}
/>
<DataState error={page.claimsError} loading={page.claimsLoading} onRetry={page.reloadClaims}>
<AdminListBody>
<DataTable
columns={columns}
items={page.claims.items || []}
minWidth="1580px"
pagination={
total > 0
? {
page: page.page,
pageSize: page.claims.pageSize || 50,
total,
onPageChange: page.setPage,
}
: undefined
}
rowKey={(claim) => claim.claimId}
/>
</AdminListBody>
</DataState>
<InviteActivityRewardConfigDrawer
abilities={page.abilities}
configLoading={page.configLoading}
configSaving={page.configSaving}
form={page.form}
open={page.configDrawerOpen}
setForm={page.setForm}
onClose={page.closeConfigDrawer}
onSubmit={page.submitConfig}
/>
</AdminListPage>
);
}
function ClaimUser({ claim }) {
const user = claim.user || {};
return <AdminUserIdentity openInAppUserDetail user={{ ...user, userId: user.userId || claim.userId }} />;
}
function rewardTypeLabel(type) {
return rewardTypeOptions.find(([value]) => value === type)?.[1] || type || "-";
}
function claimStatusLabel(status) {
return claimStatusOptions.find(([value]) => value === status)?.[1] || status || "-";
}
function thresholdLabel(claim) {
if (claim.rewardType === "valid_invite") {
return `${formatNumber(claim.thresholdValidInviteCount)}`;
}
if (claim.rewardType === "invite_inviter" || claim.rewardType === "invite_invitee") {
return claim.tierId ? `关联用户 ${claim.tierId}` : "-";
}
return `${formatNumber(claim.thresholdCoinAmount)} 金币`;
}
function reachedLabel(claim) {
if (claim.rewardType === "invite_inviter" || claim.rewardType === "invite_invitee") {
return "1 人";
}
const unit = claim.rewardType === "valid_invite" ? "人" : "金币";
return `${formatNumber(claim.reachedValue)} ${unit}`;
}
function formatNumber(value) {
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
}

View File

@ -0,0 +1,11 @@
import { useAuth } from "@/app/auth/AuthProvider.jsx";
import { PERMISSIONS } from "@/app/permissions";
export function useInviteActivityRewardAbilities() {
const { can } = useAuth();
return {
canUpdate: can(PERMISSIONS.inviteActivityRewardUpdate),
canView: can(PERMISSIONS.inviteActivityRewardView),
};
}

View File

@ -0,0 +1,12 @@
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
export const inviteActivityRewardRoutes = [
{
label: "邀请活动",
loader: () => import("./pages/InviteActivityRewardPage.jsx").then((module) => module.InviteActivityRewardPage),
menuCode: MENU_CODES.inviteActivityReward,
pageKey: "invite-activity-reward",
path: "/activities/invite-reward",
permission: PERMISSIONS.inviteActivityRewardView,
},
];

View File

@ -1,18 +1,14 @@
import FileDownloadOutlined from "@mui/icons-material/FileDownloadOutlined";
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
import { useMemo, useState } from "react";
import { exportCoinSellerLedger, listCoinSellerLedger } from "@/features/operations/api";
import styles from "@/features/operations/operations.module.css";
import { downloadCsv } from "@/shared/api/download";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import {
AdminFilterResetButton,
AdminListBody,
AdminListToolbar,
} from "@/shared/ui/AdminListLayout.jsx";
import { AdminFilterResetButton, AdminListBody, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
@ -182,14 +178,15 @@ export function CoinSellerLedgerTable({ lockedSeller = null, sellerUserId = "" }
filters={
<>
<TimeRangeFilter value={timeRange} onChange={changeTimeRange} />
<AdminFilterResetButton
disabled={!hasActiveFilters}
onClick={resetFilters}
/>
<AdminFilterResetButton disabled={!hasActiveFilters} onClick={resetFilters} />
</>
}
actions={
<Button disabled={exporting} startIcon={<FileDownloadOutlined fontSize="small" />} onClick={downloadLedger}>
<Button
disabled={exporting}
startIcon={<FileDownloadOutlined fontSize="small" />}
onClick={downloadLedger}
>
{exporting ? "导出中" : "导出"}
</Button>
}
@ -235,18 +232,7 @@ function OperatorCell({ entry }) {
}
function UserCell({ user = {} }) {
const name = user.username || "-";
return (
<div className={styles.identity}>
<span className={styles.avatar}>
{user.avatar ? <img alt="" src={user.avatar} /> : <PersonOutlineOutlined fontSize="small" />}
</span>
<span className={styles.identityText}>
<span className={styles.primaryText}>{name}</span>
<span className={styles.meta}>{userIdText(user)}</span>
</span>
</div>
);
return <AdminUserIdentity openInAppUserDetail user={user} />;
}
function AmountValue({ entry }) {
@ -261,16 +247,9 @@ function AmountValue({ entry }) {
}
function ledgerTypeLabel(entry) {
return ledgerTypeLabels[entry.ledgerType] || bizTypeLabels[entry.bizType] || entry.ledgerType || entry.bizType || "-";
}
function userIdText(user = {}) {
const displayUserId = String(user.displayUserId || "").trim();
const userId = String(user.userId || "").trim();
if (displayUserId && userId && displayUserId !== userId) {
return `${displayUserId} / ${userId}`;
}
return displayUserId || userId || "-";
return (
ledgerTypeLabels[entry.ledgerType] || bizTypeLabels[entry.bizType] || entry.ledgerType || entry.bizType || "-"
);
}
function formatNumber(value) {

View File

@ -1,5 +1,4 @@
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
import SearchOutlined from "@mui/icons-material/SearchOutlined";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
@ -24,6 +23,7 @@ import {
import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { createTextColumnFilter } from "@/shared/ui/tableFilters.js";
@ -320,33 +320,11 @@ export function CoinAdjustmentPage() {
function AdjustmentUserCell({ item }) {
const user = item.user || {};
const name = user.username || "-";
return (
<div className={styles.identity}>
<span className={styles.avatar}>
{user.avatar ? <img alt="" src={user.avatar} /> : <PersonOutlineOutlined fontSize="small" />}
</span>
<span className={styles.identityText}>
<span className={styles.primaryText}>{name}</span>
<span className={styles.meta}>{userIdText(user, item.userId)}</span>
</span>
</div>
);
return <AdminUserIdentity openInAppUserDetail user={{ ...user, userId: user.userId || item.userId }} />;
}
function TargetUserPreview({ user }) {
return (
<div className={styles.targetPreview}>
<span className={styles.avatar}>
{user.avatar ? <img alt="" src={user.avatar} /> : <PersonOutlineOutlined fontSize="small" />}
</span>
<span className={styles.identityText}>
<span className={styles.primaryText}>{user.username || "-"}</span>
<span className={styles.meta}>{userIdText(user, user.userId)}</span>
</span>
</div>
);
return <AdminUserIdentity openInAppUserDetail className={styles.targetPreview} user={user} />;
}
function AdjustmentAmount({ item }) {
@ -388,11 +366,6 @@ function buildAdjustmentPayload(form, targetUser) {
};
}
function userIdText(user, fallbackUserId) {
const ids = [user.displayUserId, user.userId || fallbackUserId].filter(Boolean);
return ids.length ? ids.join(" / ") : "-";
}
function operatorName(item) {
const operator = item.operator || {};
return operator.name || operator.username || item.operatorUserId || "-";

View File

@ -1,4 +1,3 @@
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
import { useMemo, useState } from "react";
import { listCoinLedger } from "@/features/operations/api";
import {
@ -7,6 +6,7 @@ import {
AdminListPage,
AdminListToolbar,
} from "@/shared/ui/AdminListLayout.jsx";
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
@ -232,16 +232,12 @@ function CoinLedgerDetailDrawer({ entry, onClose, open }) {
{entry ? (
<>
<header className={styles.detailHero}>
<span className={styles.detailAvatar}>
{user.avatar ? <img alt="" src={user.avatar} /> : <PersonOutlineOutlined fontSize="medium" />}
</span>
<div className={styles.detailHeroText}>
<div className={styles.detailTitleRow}>
<h3>{user.username || "-"}</h3>
<AmountValue entry={entry} />
</div>
<span className={styles.detailMeta}>{userIdText(entry)}</span>
</div>
<AdminUserIdentity
openInAppUserDetail
user={{ ...user, userId: user.userId || entry.userId }}
size="large"
/>
<AmountValue entry={entry} />
</header>
<DetailSection
@ -276,17 +272,7 @@ function CoinLedgerDetailDrawer({ entry, onClose, open }) {
function UserCell({ entry }) {
const user = entry.user || {};
return (
<div className={styles.identity}>
<span className={styles.avatar}>
{user.avatar ? <img alt="" src={user.avatar} /> : <PersonOutlineOutlined fontSize="small" />}
</span>
<div className={styles.identityText}>
<span className={styles.primaryText}>{user.username || "-"}</span>
<span className={styles.meta}>{userIdText(entry)}</span>
</div>
</div>
);
return <AdminUserIdentity openInAppUserDetail user={{ ...user, userId: user.userId || entry.userId }} />;
}
function AmountValue({ entry }) {
@ -366,7 +352,12 @@ function businessContextRows(entry, metadata) {
}
return compactRows([
metadataRow("关联用户", metadata, ["target_user_id", "targetUserId", "seller_user_id", "sellerUserId"], entry.counterpartyUserId),
metadataRow(
"关联用户",
metadata,
["target_user_id", "targetUserId", "seller_user_id", "sellerUserId"],
entry.counterpartyUserId,
),
metadataRow("房间 ID", metadata, ["room_id", "roomId"], entry.roomId),
metadataRow("任务 ID", metadata, ["task_id", "taskId"]),
metadataRow("任务类型", metadata, ["task_type", "taskType"]),
@ -440,16 +431,6 @@ function displayDetailValue(value) {
return String(value);
}
function userIdText(entry) {
const user = entry.user || {};
const longId = user.userId || entry.userId || "";
const shortId = user.displayUserId || "";
if (longId && shortId && longId !== shortId) {
return `${longId}(${shortId})`;
}
return longId || shortId || "-";
}
function hasDetailValue(value) {
return value === 0 || value === false || Boolean(value);
}

View File

@ -10,6 +10,7 @@ import {
AdminListPage,
AdminListToolbar,
} from "@/shared/ui/AdminListLayout.jsx";
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
@ -219,19 +220,7 @@ function TargetCell({ report }) {
);
}
const user = target.user || {};
return (
<div className={styles.identity}>
<span className={styles.avatar}>
{user.avatar ? <img alt="" src={user.avatar} /> : <PersonOutlineOutlined fontSize="small" />}
</span>
<span className={styles.identityText}>
<span className={styles.primaryText}>{user.username || "-"}</span>
<span className={styles.meta}>
{targetMeta(user.displayUserId || user.defaultDisplayUserId, user.userId || report.userId)}
</span>
</span>
</div>
);
return <AdminUserIdentity openInAppUserDetail user={{ ...user, userId: user.userId || report.userId }} />;
}
function EvidenceImages({ imageUrls = [] }) {

View File

@ -2,11 +2,14 @@ import { apiRequest } from "@/shared/api/request";
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
import type {
ApiPage,
ApiList,
EntityId,
PageQuery,
RechargeBillDto,
RechargeProductDto,
RechargeProductPayload,
ThirdPartyPaymentChannelDto,
ThirdPartyPaymentMethodDto,
} from "@/shared/api/types";
export function listRechargeBills(query: PageQuery = {}): Promise<ApiPage<RechargeBillDto>> {
@ -56,3 +59,41 @@ export function deleteRechargeProduct(productId: EntityId): Promise<unknown> {
method: endpoint.method,
});
}
export function listThirdPartyPaymentChannels(): Promise<ApiList<ThirdPartyPaymentChannelDto>> {
const endpoint = API_ENDPOINTS.listThirdPartyPaymentChannels;
return apiRequest<ApiList<ThirdPartyPaymentChannelDto>>(
apiEndpointPath(API_OPERATIONS.listThirdPartyPaymentChannels),
{
method: endpoint.method,
},
);
}
export function setThirdPartyPaymentMethodStatus(
methodId: EntityId,
payload: { enabled: boolean },
): Promise<ThirdPartyPaymentMethodDto> {
const endpoint = API_ENDPOINTS.setThirdPartyPaymentMethodStatus;
return apiRequest<ThirdPartyPaymentMethodDto, { enabled: boolean }>(
apiEndpointPath(API_OPERATIONS.setThirdPartyPaymentMethodStatus, { method_id: methodId }),
{
body: payload,
method: endpoint.method,
},
);
}
export function updateThirdPartyPaymentRate(
methodId: EntityId,
payload: { usdToCurrencyRate: string },
): Promise<ThirdPartyPaymentMethodDto> {
const endpoint = API_ENDPOINTS.updateThirdPartyPaymentRate;
return apiRequest<ThirdPartyPaymentMethodDto, { usdToCurrencyRate: string }>(
apiEndpointPath(API_OPERATIONS.updateThirdPartyPaymentRate, { method_id: methodId }),
{
body: payload,
method: endpoint.method,
},
);
}

View File

@ -15,12 +15,14 @@ import { useToast } from "@/shared/ui/ToastProvider.jsx";
const pageSize = 50;
// 新建默认走 H5 web 档位和普通用户档位;币商档位必须由运营显式选择,避免误配商品受众。
const emptyForm = () => ({
amountUsdt: "",
audienceType: "normal",
coinAmount: "",
description: "",
enabled: true,
platform: "android",
platform: "web",
productName: "",
regionIds: [],
});
@ -33,6 +35,7 @@ export function useRechargeProductsPage() {
const [keyword, setKeyword] = useState("");
const [status, setStatus] = useState("");
const [platform, setPlatform] = useState("");
const [audienceType, setAudienceType] = useState("");
const [regionId, setRegionId] = useState("");
const [page, setPage] = useState(1);
const [activeAction, setActiveAction] = useState("");
@ -40,14 +43,16 @@ export function useRechargeProductsPage() {
const [form, setFormState] = useState(emptyForm);
const [loadingAction, setLoadingAction] = useState("");
// audienceType 直接透传给 admin-server后台再转 wallet-service 过滤 normal / coin_seller 档位。
const filters = useMemo(
() => ({
audienceType,
keyword,
platform,
regionId,
status,
}),
[keyword, platform, regionId, status],
[audienceType, keyword, platform, regionId, status],
);
const result = usePaginatedQuery({
errorMessage: "加载内购配置失败",
@ -88,6 +93,7 @@ export function useRechargeProductsPage() {
setKeyword("");
setStatus("");
setPlatform("");
setAudienceType("");
setRegionId("");
};
@ -107,12 +113,36 @@ export function useRechargeProductsPage() {
if (editingItem) {
await updateRechargeProduct(editingItem.productId, payload);
showToast("内购配置已更新", "success");
closeDialog();
await result.reload();
} else {
await createRechargeProduct(payload);
const createdProduct = await createRechargeProduct(payload);
const nextFilters = visibleFiltersForCreatedProduct(createdProduct, payload);
const canReloadCurrentQuery =
page === 1 &&
keyword === nextFilters.keyword &&
status === nextFilters.status &&
platform === nextFilters.platform &&
audienceType === nextFilters.audienceType &&
regionId === nextFilters.regionId;
showToast("内购配置已新增", "success");
closeDialog();
// 新增币商档位时,当前列表可能还停在“普通用户”过滤条件;这里主动切到新商品所属的受众、
// 平台和上下架状态,并清空搜索/区域过滤,保证运营提交后能立刻看到刚创建的记录。
setKeyword(nextFilters.keyword);
setStatus(nextFilters.status);
setPlatform(nextFilters.platform);
setAudienceType(nextFilters.audienceType);
setRegionId(nextFilters.regionId);
setPage(1);
// 如果当前查询条件已经正好能展示新商品,直接刷新当前页;否则交给上面的过滤条件变更触发新查询。
if (canReloadCurrentQuery) {
await result.reload();
}
}
closeDialog();
await result.reload();
} catch (err) {
showToast(err.message || "操作失败", "error");
} finally {
@ -158,6 +188,7 @@ export function useRechargeProductsPage() {
return {
abilities,
activeAction,
audienceType,
closeDialog,
data: result.data,
editingItem,
@ -178,6 +209,7 @@ export function useRechargeProductsPage() {
removeProduct,
resetFilters,
setForm,
setAudienceType,
setKeyword,
setPage,
setPlatform,
@ -193,35 +225,60 @@ function normalizeForm(form) {
return {
...form,
amountUsdt: form.amountUsdt || "",
audienceType: form.audienceType || "normal",
coinAmount: form.coinAmount || "",
regionIds: form.regionIds || [],
};
}
function formFromProduct(item) {
// 后端老数据可能没有 audienceType前端按普通用户兜底保证旧档位不会被误认为币商档位。
return {
amountUsdt: item.amountUsdt || "",
audienceType: item.audienceType === "coin_seller" ? "coin_seller" : "normal",
coinAmount: item.coinAmount === 0 || item.coinAmount ? String(item.coinAmount) : "",
description: item.description || "",
enabled: item.enabled ?? item.status === "active",
platform: item.platform || "android",
platform: item.platform || "web",
productName: item.productName || "",
regionIds: (item.regionIds || []).map(String),
};
}
function productPayloadFromItem(item, enabled) {
// 行内上下架只改变 enabled其他字段必须按订单快照原样回传避免一次快捷开关丢失受众或平台。
return {
amountUsdt: item.amountUsdt || formatMicroAmount(item.amountUsdtMicro),
audienceType: item.audienceType === "coin_seller" ? "coin_seller" : "normal",
coinAmount: item.coinAmount,
description: item.description || "",
enabled,
platform: item.platform === "ios" ? "ios" : "android",
platform: item.platform === "android" || item.platform === "ios" ? item.platform : "web",
productName: item.productName || "",
regionIds: item.regionIds || [],
};
}
function visibleFiltersForCreatedProduct(product, payload) {
const enabled = product?.enabled ?? payload.enabled;
const audienceType =
product?.audienceType === "coin_seller" || payload.audienceType === "coin_seller" ? "coin_seller" : "normal";
return {
audienceType,
keyword: "",
platform: normalizeProductPlatform(product?.platform || payload.platform),
regionId: "",
status: product?.status === "disabled" || enabled === false ? "disabled" : "active",
};
}
function normalizeProductPlatform(value) {
if (value === "android" || value === "ios" || value === "web") {
return value;
}
return "web";
}
function formatMicroAmount(value) {
const numberValue = Number(value || 0);
if (!Number.isFinite(numberValue) || numberValue <= 0) {

View File

@ -0,0 +1,124 @@
import { useEffect, useMemo, useState } from "react";
import {
listThirdPartyPaymentChannels,
setThirdPartyPaymentMethodStatus,
updateThirdPartyPaymentRate,
} from "@/features/payment/api";
import { usePaymentAbilities } from "@/features/payment/permissions.js";
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
const emptyChannels = { items: [], total: 0 };
export function useThirdPartyPaymentPage() {
const abilities = usePaymentAbilities();
const { showToast } = useToast();
const [expandedChannel, setExpandedChannel] = useState("");
const [loadingAction, setLoadingAction] = useState("");
const [rateDrafts, setRateDrafts] = useState({});
// 三方支付页是配置中心入口列表必须包含已关闭方式H5 只展示后端判定可用的方式。
// 这里不在前端过滤关闭项,保证运营可以重新打开某个国家或某个支付方式。
const result = useAdminQuery(listThirdPartyPaymentChannels, {
errorMessage: "加载第三方支付失败",
initialData: emptyChannels,
queryKey: ["payment", "third-party-channels"],
});
// 后端按 providerCode 返回渠道;展开态只保存 code避免列表刷新后持有旧对象。
const channels = useMemo(() => result.data?.items || [], [result.data]);
const activeChannelCode = expandedChannel || channels[0]?.providerCode || "";
const activeChannel = useMemo(
() => channels.find((channel) => channel.providerCode === activeChannelCode) || channels[0] || null,
[activeChannelCode, channels],
);
// 默认打开第一条渠道,让运营进页面即可看到国家/方式明细;没有渠道时保持空态。
useEffect(() => {
if (!expandedChannel && channels[0]?.providerCode) {
setExpandedChannel(channels[0].providerCode);
}
}, [channels, expandedChannel]);
// 汇率输入是可编辑草稿,不能直接绑定服务端对象,否则用户输入中途刷新会覆盖正在编辑的值。
// 只在本地没有草稿时用接口值初始化,保存后再通过 reload 收敛到服务端事实。
useEffect(() => {
setRateDrafts((current) => {
const next = { ...current };
channels.forEach((channel) => {
(channel.methods || []).forEach((method) => {
const key = String(method.methodId);
if (next[key] === undefined) {
next[key] = method.usdToCurrencyRate || "";
}
});
});
return next;
});
}, [channels]);
const toggleChannel = (channelCode) => {
setExpandedChannel((current) => (current === channelCode ? "" : channelCode));
};
const setRateDraft = (methodId, value) => {
setRateDrafts((current) => ({ ...current, [String(methodId)]: value }));
};
const toggleMethod = async (method, active) => {
// 权限判断放在动作入口,避免只依赖按钮禁用;接口失败时保留原状态并提示。
if (!abilities.canUpdateThirdParty) {
return;
}
const actionKey = `status:${method.methodId}`;
setLoadingAction(actionKey);
try {
await setThirdPartyPaymentMethodStatus(method.methodId, { enabled: active });
await result.reload();
showToast(active ? "支付方式已打开" : "支付方式已关闭", "success");
} catch (err) {
showToast(err.message || "状态更新失败", "error");
} finally {
setLoadingAction("");
}
};
const saveRate = async (method) => {
// 汇率决定 H5 展示和下单本币金额,前端先挡掉空值和非正数,最终仍以后端校验为准。
if (!abilities.canUpdateThirdParty) {
return;
}
const rateToUsd = String(rateDrafts[String(method.methodId)] || "").trim();
if (!rateToUsd || Number(rateToUsd) <= 0) {
showToast("请填写大于 0 的汇率", "error");
return;
}
const actionKey = `rate:${method.methodId}`;
setLoadingAction(actionKey);
try {
await updateThirdPartyPaymentRate(method.methodId, { usdToCurrencyRate: rateToUsd });
await result.reload();
showToast("汇率已保存", "success");
} catch (err) {
showToast(err.message || "汇率保存失败", "error");
} finally {
setLoadingAction("");
}
};
return {
abilities,
activeChannel,
channels,
error: result.error,
expandedChannel,
loading: result.loading,
loadingAction,
rateDrafts,
reload: result.reload,
saveRate,
setRateDraft,
toggleChannel,
toggleMethod,
};
}

View File

@ -1,14 +1,10 @@
import ContentCopyOutlined from "@mui/icons-material/ContentCopyOutlined";
import DownloadOutlined from "@mui/icons-material/DownloadOutlined";
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
import Tooltip from "@mui/material/Tooltip";
import { useEffect, useMemo, useState } from "react";
import { listRechargeBills } from "@/features/payment/api";
import {
AdminListBody,
AdminListPage,
AdminListToolbar,
} from "@/shared/ui/AdminListLayout.jsx";
import { AdminListBody, AdminListPage, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx";
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
@ -70,10 +66,7 @@ const columns = [
label: "国家/区域",
width: "minmax(150px, 0.75fr)",
render: (bill, _index, context) => (
<Stack
primary={billCountryName(bill)}
secondary={regionName(bill.targetRegionId, context?.regionNames)}
/>
<Stack primary={billCountryName(bill)} secondary={regionName(bill.targetRegionId, context?.regionNames)} />
),
},
{
@ -315,19 +308,7 @@ function BillUser({ bill, type }) {
return "-";
}
const shortId = profile.displayUserId || profile.userId || "-";
const name = profile.username || "-";
return (
<div className={styles.identity}>
<span className={styles.avatar}>
{profile.avatar ? <img alt="" src={profile.avatar} /> : <PersonOutlineOutlined fontSize="small" />}
</span>
<span className={styles.identityText}>
<span className={styles.identityId}>{name}</span>
<span className={styles.identityName}>{shortId}</span>
</span>
</div>
);
return <AdminUserIdentity openInAppUserDetail user={profile} />;
}
function StatusBadge({ status }) {
@ -386,6 +367,8 @@ function normalizeBillUser(profile, fallbackId) {
countryDisplayName: source.countryDisplayName || source.country_display_name || "",
countryName: source.countryName || source.country_name || "",
displayUserId: source.displayUserId || source.display_user_id || "",
prettyDisplayUserId: source.prettyDisplayUserId || source.pretty_display_user_id || "",
prettyId: source.prettyId || source.pretty_id || "",
userId: String(source.userId || source.user_id || fallbackId || ""),
username: source.username || source.name || "",
};

View File

@ -36,10 +36,17 @@ const statusOptions = [
const platformOptions = [
["", "全部平台"],
["web", "Web"],
["android", "Android"],
["ios", "iOS"],
];
const audienceOptions = [
["", "全部用户类型"],
["normal", "普通用户"],
["coin_seller", "币商"],
];
export function RechargeProductConfigPage() {
const page = useRechargeProductsPage();
const items = page.data?.items || [];
@ -107,9 +114,21 @@ export function RechargeProductConfigPage() {
value={page.form.platform}
onChange={(event) => page.setForm({ platform: event.target.value })}
>
<MenuItem value="web">Web</MenuItem>
<MenuItem value="android">Android</MenuItem>
<MenuItem value="ios">iOS</MenuItem>
</TextField>
<TextField
disabled={!canSubmit}
label="用户类型"
required
select
value={page.form.audienceType}
onChange={(event) => page.setForm({ audienceType: event.target.value })}
>
<MenuItem value="normal">普通用户</MenuItem>
<MenuItem value="coin_seller">币商</MenuItem>
</TextField>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="价格配置">
@ -205,6 +224,18 @@ function productColumns(page, regionLabels) {
}),
width: "minmax(120px, 0.55fr)",
},
{
key: "audienceType",
label: "用户类型",
render: (item) => audienceLabel(item.audienceType),
filter: createOptionsColumnFilter({
options: audienceOptions,
placeholder: "搜索用户类型",
value: page.audienceType,
onChange: page.setAudienceType,
}),
width: "minmax(130px, 0.6fr)",
},
{
key: "regionIds",
label: "支持区域",
@ -309,6 +340,9 @@ function Stack({ primary, secondary }) {
}
function platformLabel(value) {
if (value === "web") {
return "Web";
}
if (value === "android") {
return "Android";
}
@ -318,6 +352,16 @@ function platformLabel(value) {
return value || "-";
}
function audienceLabel(value) {
if (value === "coin_seller") {
return "币商";
}
if (value === "normal") {
return "普通用户";
}
return value || "普通用户";
}
function regionLabelMap(options) {
return options.reduce((labels, option) => {
labels[String(option.value)] = option.name || option.label;

View File

@ -0,0 +1,174 @@
import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined";
import KeyboardArrowRightOutlined from "@mui/icons-material/KeyboardArrowRightOutlined";
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
import TextField from "@mui/material/TextField";
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { useThirdPartyPaymentPage } from "@/features/payment/hooks/useThirdPartyPaymentPage.js";
import { AdminListBody, AdminListPage, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import styles from "@/features/payment/payment.module.css";
export function ThirdPartyPaymentPage() {
const page = useThirdPartyPaymentPage();
const navigate = useNavigate();
// method
const groupedMethods = useMemo(() => groupMethods(page.activeChannel?.methods || []), [page.activeChannel]);
return (
<AdminListPage>
<AdminListToolbar
actions={
<Button
startIcon={<SettingsOutlined fontSize="small" />}
variant="primary"
onClick={() => navigate("/payment/recharge-products")}
>
三方支付内购配置
</Button>
}
/>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<AdminListBody>
<div className={styles.thirdPartyLayout}>
<div className={styles.channelList}>
{page.channels.map((channel) => (
<button
key={channel.providerCode}
className={[
styles.channelRow,
page.activeChannel?.providerCode === channel.providerCode
? styles.channelRowActive
: "",
]
.filter(Boolean)
.join(" ")}
type="button"
onClick={() => page.toggleChannel(channel.providerCode)}
>
<span className={styles.channelExpandIcon}>
{page.expandedChannel === channel.providerCode ? (
<KeyboardArrowDownOutlined fontSize="small" />
) : (
<KeyboardArrowRightOutlined fontSize="small" />
)}
</span>
<span className={styles.channelText}>
<span>{channel.providerName || channel.providerCode}</span>
<span>{channel.providerCode}</span>
</span>
<StatusPill active={channel.status === "active"} />
</button>
))}
</div>
<div className={styles.methodPanel}>
{page.activeChannel ? (
groupedMethods.map((group) => (
<section key={group.key} className={styles.methodGroup}>
<div className={styles.methodGroupHeader}>
<span>{group.title}</span>
<span>{group.methods.length} 个支付方式</span>
</div>
<div className={styles.methodTable}>
<div className={styles.methodHeader}>
<span>支付方式</span>
<span>类型</span>
<span>币种</span>
<span>USD 到本币汇率</span>
<span>状态</span>
</div>
{group.methods.map((method) => (
<div key={method.methodId} className={styles.methodRow}>
<span className={styles.methodName}>
<strong>{method.methodName || method.payWay || "-"}</strong>
<small>{method.payWay || "-"}</small>
</span>
<span>{method.payType || "-"}</span>
<span>{method.currencyCode || "-"}</span>
<span className={styles.rateEditor}>
<TextField
disabled={!page.abilities.canUpdateThirdParty}
size="small"
slotProps={{ htmlInput: { inputMode: "decimal" } }}
value={page.rateDrafts[String(method.methodId)] ?? ""}
onChange={(event) =>
page.setRateDraft(method.methodId, event.target.value)
}
/>
<Button
disabled={
!page.abilities.canUpdateThirdParty ||
page.loadingAction === `rate:${method.methodId}`
}
onClick={() => page.saveRate(method)}
>
保存
</Button>
</span>
<span>
<AdminSwitch
checked={method.status === "active"}
checkedLabel="开"
disabled={
!page.abilities.canUpdateThirdParty ||
page.loadingAction === `status:${method.methodId}`
}
inputProps={{
"aria-label": method.status === "active"
? "关闭支付方式"
: "打开支付方式",
}}
uncheckedLabel="关"
onChange={(event) =>
page.toggleMethod(method, event.target.checked)
}
/>
</span>
</div>
))}
</div>
</section>
))
) : (
<div className={styles.emptyMethods}>当前无第三方支付渠道</div>
)}
</div>
</div>
</AdminListBody>
</DataState>
</AdminListPage>
);
}
function StatusPill({ active }) {
return <span className={active ? styles.statusActive : styles.statusDisabled}>{active ? "已启用" : "已停用"}</span>;
}
function groupMethods(methods) {
// GLOBAL 便
const groups = new Map();
methods.forEach((method) => {
const countryCode = method.countryCode || "GLOBAL";
const countryName = countryCode === "GLOBAL" ? "全球方式" : method.countryName || countryCode;
if (!groups.has(countryCode)) {
groups.set(countryCode, {
key: countryCode,
methods: [],
title: `${countryName} / ${countryCode}`,
});
}
groups.get(countryCode).methods.push(method);
});
return Array.from(groups.values()).sort((left, right) => {
if (left.key === "GLOBAL") {
return -1;
}
if (right.key === "GLOBAL") {
return 1;
}
return left.title.localeCompare(right.title);
});
}

View File

@ -54,3 +54,220 @@
color: var(--text-tertiary);
font-size: var(--admin-font-size);
}
.thirdPartyLayout {
display: grid;
flex: 1;
min-height: 0;
overflow: hidden;
overscroll-behavior: contain;
scrollbar-gutter: stable;
gap: var(--space-4);
grid-template-columns: minmax(260px, 320px) minmax(0, 1fr);
}
.channelList,
.methodPanel {
min-width: 0;
}
.channelList {
display: flex;
min-height: 0;
flex-direction: column;
gap: var(--space-2);
overflow: auto;
padding: var(--space-4) 0 var(--space-4) var(--space-4);
}
.channelRow {
display: grid;
width: 100%;
min-height: 72px;
flex: 0 0 auto;
align-items: center;
padding: var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg-card);
color: var(--text-primary);
cursor: pointer;
gap: var(--space-2);
grid-template-columns: 24px minmax(0, 1fr) auto;
text-align: left;
}
.channelRow:hover,
.channelRowActive {
border-color: var(--primary-border);
background: var(--active-surface);
}
.channelExpandIcon {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
}
.channelText {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.channelText span:first-child {
overflow: hidden;
color: var(--text-primary);
font-weight: 720;
text-overflow: ellipsis;
white-space: nowrap;
}
.channelText span:last-child {
color: var(--text-tertiary);
font-size: var(--admin-font-size);
}
.methodPanel {
display: flex;
min-height: 0;
flex-direction: column;
gap: var(--space-4);
overflow: auto;
padding: var(--space-4) var(--space-4) var(--space-4) 0;
}
.methodGroup {
flex: 0 0 auto;
min-width: 0;
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow-x: auto;
overflow-y: hidden;
background: var(--bg-card);
}
.methodGroupHeader {
display: flex;
min-height: 48px;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: 0 var(--space-4);
border-bottom: 1px solid var(--border);
color: var(--text-primary);
font-weight: 720;
}
.methodGroupHeader span:last-child {
color: var(--text-tertiary);
font-size: var(--admin-font-size);
font-weight: 600;
}
.methodTable {
display: grid;
min-width: 760px;
}
.methodHeader,
.methodRow {
display: grid;
align-items: center;
gap: var(--space-3);
grid-template-columns: minmax(180px, 1.1fr) 120px 100px minmax(220px, 0.9fr) 104px;
}
.methodHeader {
min-height: 42px;
padding: 0 var(--space-4);
border-bottom: 1px solid var(--border);
background: var(--bg-subtle);
color: var(--text-tertiary);
font-size: var(--admin-font-size);
font-weight: 720;
}
.methodRow {
min-height: 64px;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border);
}
.methodRow:last-child {
border-bottom: 0;
}
.methodName {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.methodName strong,
.methodName small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.methodName small {
color: var(--text-tertiary);
font-size: var(--admin-font-size);
}
.rateEditor {
display: grid;
align-items: center;
gap: var(--space-2);
grid-template-columns: minmax(96px, 1fr) auto;
}
.statusActive,
.statusDisabled {
display: inline-flex;
min-width: 56px;
height: 26px;
align-items: center;
justify-content: center;
border-radius: var(--radius-pill);
font-size: 12px;
font-weight: 720;
}
.statusActive {
background: var(--success-surface);
color: var(--success);
}
.statusDisabled {
background: var(--bg-subtle);
color: var(--text-tertiary);
}
.emptyMethods {
display: flex;
min-height: 280px;
align-items: center;
justify-content: center;
border: 1px dashed var(--border);
border-radius: var(--radius-md);
color: var(--text-tertiary);
}
@media (max-width: 960px) {
.thirdPartyLayout {
grid-template-columns: 1fr;
}
.channelList {
padding: var(--space-4) var(--space-4) 0;
}
.methodPanel {
padding: 0 var(--space-4) var(--space-4);
}
}

View File

@ -10,5 +10,7 @@ export function usePaymentAbilities() {
canUpdateProduct: can(PERMISSIONS.paymentProductUpdate),
canViewBill: can(PERMISSIONS.paymentBillView),
canViewProduct: can(PERMISSIONS.paymentProductView),
canUpdateThirdParty: can(PERMISSIONS.paymentThirdPartyUpdate),
canViewThirdParty: can(PERMISSIONS.paymentThirdPartyView),
};
}

View File

@ -8,5 +8,22 @@ export const paymentRoutes = [
pageKey: "payment-bill-list",
path: "/payment/bills",
permission: PERMISSIONS.paymentBillView,
}
},
{
label: "第三方支付",
loader: () => import("./pages/ThirdPartyPaymentPage.jsx").then((module) => module.ThirdPartyPaymentPage),
menuCode: MENU_CODES.paymentThirdParty,
pageKey: "payment-third-party",
path: "/payment/third-party",
permission: PERMISSIONS.paymentThirdPartyView,
},
{
label: "三方支付内购配置",
loader: () =>
import("./pages/RechargeProductConfigPage.jsx").then((module) => module.RechargeProductConfigPage),
menuCode: MENU_CODES.paymentRechargeProducts,
pageKey: "payment-recharge-products",
path: "/payment/recharge-products",
permission: PERMISSIONS.paymentProductView,
},
];

View File

@ -8,10 +8,11 @@ const usdtAmountSchema = z
export const rechargeProductSchema = z.object({
amountUsdt: usdtAmountSchema,
audienceType: z.enum(["normal", "coin_seller"]),
coinAmount: z.coerce.number().int("获得金币数必须是整数").positive("获得金币数必须大于 0"),
description: z.string().trim().max(512, "描述不能超过 512 个字符").default(""),
enabled: z.boolean(),
platform: z.enum(["android", "ios"]),
platform: z.enum(["android", "ios", "web"]),
productName: z.string().trim().min(1, "请填写产品名称").max(128, "产品名称不能超过 128 个字符"),
regionIds: z.array(z.coerce.number().int().positive()).min(1, "请选择支持区域"),
});

View File

@ -25,6 +25,8 @@ export interface RegistrationRewardConfigPayload {
export interface RegistrationRewardClaimUserDto {
userId?: number;
displayUserId?: string;
prettyDisplayUserId?: string;
prettyId?: string;
username?: string;
avatar?: string;
}
@ -111,6 +113,8 @@ function normalizeClaim(item: RawClaim): RegistrationRewardClaimDto {
user: {
userId: numberValue(rawUser.userId ?? rawUser.user_id),
displayUserId: stringValue(rawUser.displayUserId ?? rawUser.display_user_id),
prettyDisplayUserId: stringValue(rawUser.prettyDisplayUserId ?? rawUser.pretty_display_user_id),
prettyId: stringValue(rawUser.prettyId ?? rawUser.pretty_id),
username: stringValue(rawUser.username),
avatar: stringValue(rawUser.avatar),
},

View File

@ -1,7 +1,7 @@
import Avatar from "@mui/material/Avatar";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { AdminListBody, AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
import { RegistrationRewardConfigDrawer } from "@/features/registration-reward/components/RegistrationRewardConfigDrawer.jsx";
@ -132,17 +132,7 @@ export function RegistrationRewardPage() {
function ClaimUser({ claim }) {
const user = claim.user || {};
const name = user.username || `用户 ${claim.userId}`;
const displayUserId = user.displayUserId || "";
return (
<div className={styles.identity}>
<Avatar alt={user.username || String(claim.userId)} src={user.avatar || ""} sx={{ width: 36, height: 36 }} />
<div className={styles.stack}>
<span className={styles.name}>{displayUserId ? `${name} (${displayUserId})` : name}</span>
{displayUserId ? null : <span className={styles.meta}>ID {claim.userId}</span>}
</div>
</div>
);
return <AdminUserIdentity openInAppUserDetail user={{ ...user, userId: user.userId || claim.userId }} />;
}
function RewardSummary({ claim }) {

View File

@ -18,11 +18,11 @@ test("registration reward list shows receive filter today count and compact row
expect(screen.getByText("今日已领")).toBeInTheDocument();
expect(screen.getByText("7 份")).toBeInTheDocument();
expect(screen.getAllByRole("button", { name: /领取时间/ }).some((node) => node.textContent.includes("领取时间"))).toBe(
true,
);
expect(screen.getByText("jayCiE (165121)")).toBeInTheDocument();
expect(screen.queryByText("短号 165121")).not.toBeInTheDocument();
expect(
screen.getAllByRole("button", { name: /领取时间/ }).some((node) => node.textContent.includes("领取时间")),
).toBe(true);
expect(screen.getByText("jayCiE")).toBeInTheDocument();
expect(screen.getByText("165121")).toBeInTheDocument();
expect(screen.getAllByText("1,000 金币").length).toBeGreaterThanOrEqual(1);
expect(screen.queryByText(/^注册奖励$/)).not.toBeInTheDocument();
expect(screen.getByText("rclaim_1780684209628_899e9717839e0109")).toBeInTheDocument();

View File

@ -9,6 +9,7 @@ import {
disableGift,
disableResource,
disableResourceGroup,
deleteGift,
enableResourceShopItem,
enableGift,
enableResource,
@ -26,6 +27,7 @@ import {
updateGift,
updateGiftTypes,
updateResource,
updateResourceMp4Layouts,
updateResourceGroup,
upsertResourceShopItems,
} from "./api";
@ -149,6 +151,7 @@ test("resource APIs use generated admin paths", async () => {
});
await enableGift("rose");
await disableGift("rose");
await deleteGift("rose");
await listResourceGrants({ page: 1, page_size: 10, target_user_id: 1001, status: "succeeded" });
await lookupResourceGrantTarget("163000");
await grantResource({
@ -180,10 +183,11 @@ test("resource APIs use generated admin paths", async () => {
const [updateGiftUrl, updateGiftInit] = vi.mocked(fetch).mock.calls[11];
const [enableGiftUrl, enableGiftInit] = vi.mocked(fetch).mock.calls[12];
const [disableGiftUrl, disableGiftInit] = vi.mocked(fetch).mock.calls[13];
const [grantListUrl, grantListInit] = vi.mocked(fetch).mock.calls[14];
const [grantTargetUrl, grantTargetInit] = vi.mocked(fetch).mock.calls[15];
const [grantResourceUrl, grantResourceInit] = vi.mocked(fetch).mock.calls[16];
const [grantGroupUrl, grantGroupInit] = vi.mocked(fetch).mock.calls[17];
const [deleteGiftUrl, deleteGiftInit] = vi.mocked(fetch).mock.calls[14];
const [grantListUrl, grantListInit] = vi.mocked(fetch).mock.calls[15];
const [grantTargetUrl, grantTargetInit] = vi.mocked(fetch).mock.calls[16];
const [grantResourceUrl, grantResourceInit] = vi.mocked(fetch).mock.calls[17];
const [grantGroupUrl, grantGroupInit] = vi.mocked(fetch).mock.calls[18];
expect(String(listUrl)).toContain("/api/v1/admin/resources?");
expect(String(listUrl)).toContain("resource_type=gift");
@ -238,6 +242,8 @@ test("resource APIs use generated admin paths", async () => {
expect(enableGiftInit?.method).toBe("POST");
expect(String(disableGiftUrl)).toContain("/api/v1/admin/gifts/rose/disable");
expect(disableGiftInit?.method).toBe("POST");
expect(String(deleteGiftUrl)).toContain("/api/v1/admin/gifts/rose");
expect(deleteGiftInit?.method).toBe("DELETE");
expect(String(grantListUrl)).toContain("/api/v1/admin/resource-grants?");
expect(String(grantListUrl)).toContain("target_user_id=1001");
expect(grantListInit?.method).toBe("GET");
@ -255,6 +261,28 @@ test("resource APIs use generated admin paths", async () => {
expect(JSON.parse(String(grantGroupInit?.body))).toMatchObject({ groupId: 22, targetUserId: 1001 });
});
test("resource mp4 layout batch API uses narrow update endpoint", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(JSON.stringify({ code: 0, data: [] }))),
);
await updateResourceMp4Layouts([
{
metadataJson:
'{"animation_format":"mp4","mp4_alpha_layout":{"alpha_layout":"normal","video_w":750,"video_h":1334,"rgb_frame":[0,0,750,1334],"alpha_frame":null,"confirmed":true,"detect_version":1}}',
resourceId: 11,
},
]);
const [url, init] = vi.mocked(fetch).mock.calls[0];
expect(String(url)).toContain("/api/v1/admin/resources/mp4-layouts/batch");
expect(init?.method).toBe("PUT");
expect(JSON.parse(String(init?.body))).toMatchObject({
items: [{ resourceId: 11 }],
});
});
test("emoji pack APIs use generated admin paths", async () => {
vi.stubGlobal(
"fetch",

View File

@ -48,6 +48,15 @@ export interface ResourcePayload {
status: string;
}
export interface ResourceMp4LayoutUpdatePayload {
metadataJson: string;
resourceId: EntityId;
}
export interface ResourceMp4LayoutsPayload {
items: ResourceMp4LayoutUpdatePayload[];
}
export interface EmojiPackDto {
appCode?: string;
resourceId: number;
@ -196,6 +205,8 @@ export interface ResourceGrantItemDto {
export interface ResourceGrantOperatorDto {
avatar?: string;
displayUserId?: string;
prettyDisplayUserId?: string;
prettyId?: string;
name?: string;
source?: string;
userId?: number | string;
@ -205,6 +216,8 @@ export interface ResourceGrantOperatorDto {
export interface ResourceGrantUserDto {
avatar?: string;
displayUserId?: string;
prettyDisplayUserId?: string;
prettyId?: string;
userId?: number | string;
username?: string;
}
@ -302,6 +315,13 @@ export function updateResource(resourceId: EntityId, payload: ResourcePayload):
);
}
export function updateResourceMp4Layouts(items: ResourceMp4LayoutUpdatePayload[]): Promise<ResourceDto[]> {
return apiRequest<ResourceDto[], ResourceMp4LayoutsPayload>("/v1/admin/resources/mp4-layouts/batch", {
body: { items },
method: "PUT",
});
}
export function enableResource(resourceId: EntityId): Promise<ResourceDto> {
const endpoint = API_ENDPOINTS.enableResource;
return apiRequest<ResourceDto>(apiEndpointPath(API_OPERATIONS.enableResource, { resource_id: resourceId }), {
@ -446,6 +466,13 @@ export function updateGift(giftId: string, payload: GiftPayload): Promise<GiftDt
});
}
export function deleteGift(giftId: string): Promise<GiftDto> {
const endpoint = API_ENDPOINTS.deleteGift;
return apiRequest<GiftDto>(apiEndpointPath(API_OPERATIONS.deleteGift, { gift_id: giftId }), {
method: endpoint.method,
});
}
export function enableGift(giftId: string): Promise<GiftDto> {
const endpoint = API_ENDPOINTS.enableGift;
return apiRequest<GiftDto>(apiEndpointPath(API_OPERATIONS.enableGift, { gift_id: giftId }), {

View File

@ -1,4 +1,4 @@
import { profileCardMetadataJSON } from "@/features/resources/profileCardLayout.js";
import { resourceMetadataJSON } from "@/features/resources/resourceMetadata.js";
export const resourceBatchUploadSize = 10;
@ -109,7 +109,7 @@ export function resourcePlanToPayload(item) {
badgeKind: item.resourceType === "badge" ? "normal" : undefined,
coinPrice,
managerGrantEnabled: true,
metadataJson: item.resourceType === "profile_card" ? profileCardMetadataJSON(item.metadataJson) : undefined,
metadataJson: resourceMetadataJSON(item.resourceType, item.metadataJson),
name: item.name,
previewUrl: item.coverUrl,
priceType: coinPrice > 0 ? "coin" : "free",

View File

@ -88,6 +88,66 @@ test("silently ignores invalid and unpaired material", () => {
expect(plan.resources[0]).toMatchObject({ name: "星光", resourceCode: "星光", resourceType: "avatar_frame" });
});
test("keeps mp4 alpha layout metadata for gift and vehicle batch payloads", () => {
const giftPayload = resourcePlanToPayload({
animationUrl: "https://media.haiyihy.com/resource/gift.mp4",
coverUrl: "cover",
metadataJson: JSON.stringify({ mp4_alpha_layout: leftAlphaRightRgbLayout() }),
name: "Love",
price: 30000,
resourceCode: "love",
resourceType: "gift",
});
const vehiclePayload = resourcePlanToPayload({
animationUrl: "https://media.haiyihy.com/resource/vehicle.mp4",
coverUrl: "cover",
metadataJson: JSON.stringify({ mp4_alpha_layout: leftAlphaRightRgbLayout() }),
name: "Knight",
price: 49000,
resourceCode: "knight",
resourceType: "vehicle",
});
expect(JSON.parse(giftPayload.metadataJson)).toMatchObject({
animation_format: "mp4",
mp4_alpha_layout: {
alpha_frame: [0, 0, 750, 1334],
alpha_layout: "alpha_left_rgb_right",
rgb_frame: [750, 0, 750, 1334],
},
});
expect(JSON.parse(vehiclePayload.metadataJson).mp4_alpha_layout.alpha_layout).toBe("alpha_left_rgb_right");
});
test("keeps profile card layout while adding mp4 alpha layout", () => {
const payload = resourcePlanToPayload({
animationUrl: "https://media.haiyihy.com/resource/profile-card.mp4",
coverUrl: "cover",
metadataJson: JSON.stringify({
mp4_alpha_layout: leftAlphaRightRgbLayout(),
profile_card_layout: {
color_content_width: 750,
content_bottom: 1666,
content_height: 1550,
content_height_ratio: 0.922619,
content_top: 117,
content_top_ratio: 0.069643,
detect_version: 1,
source_height: 1680,
source_width: 1136,
},
}),
name: "Night",
price: 0,
resourceCode: "night",
resourceType: "profile_card",
});
const metadata = JSON.parse(payload.metadataJson);
expect(metadata.profile_card_layout.content_top).toBe(117);
expect(metadata.mp4_alpha_layout.rgb_frame).toEqual([750, 0, 750, 1334]);
});
test("translates chinese names to english resource codes with API fallback", async () => {
vi.spyOn(Math, "random")
.mockReturnValueOnce(0)
@ -170,3 +230,15 @@ test("reports translate progress after suffix is added", async () => {
function file(name) {
return new File(["payload"], name, { type: "image/png" });
}
function leftAlphaRightRgbLayout() {
return {
alpha_frame: [0, 0, 750, 1334],
alpha_layout: "alpha_left_rgb_right",
confirmed: true,
detect_version: 1,
rgb_frame: [750, 0, 750, 1334],
video_h: 1334,
video_w: 1500,
};
}

View File

@ -0,0 +1,77 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { useState } from "react";
import { expect, test, vi } from "vitest";
import { GiftSelectField } from "./GiftSelectDrawer.jsx";
vi.mock("@/shared/ui/SideDrawer.jsx", () => ({
SideDrawer({ children, drawerProps, onClose, open, title }) {
if (!open) {
return null;
}
return (
<aside aria-label={title} data-z-index={drawerProps?.sx?.zIndex} role="dialog">
<button type="button" onClick={onClose}>
关闭
</button>
{children}
</aside>
);
},
}));
const gifts = [
{
chargeAssetType: "COIN",
coinPrice: 10,
giftId: "10001",
giftTypeCode: "normal",
name: "Rose Gift",
resource: { previewUrl: "https://media.haiyihy.com/rose.png", resourceCode: "rose_resource" },
resourceId: 11,
},
{
chargeAssetType: "COIN",
coinPrice: 20,
giftId: "10002",
giftTypeCode: "normal",
name: "Lucky Star",
resource: { resourceCode: "lucky_star_resource" },
resourceId: 12,
},
];
test("gift select field opens right drawer and writes selected gift id", () => {
const changes = [];
function Harness() {
const [value, setValue] = useState("");
return (
<GiftSelectField
gifts={gifts}
value={value}
onChange={(nextValue, gift) => {
changes.push({ gift, value: nextValue });
setValue(nextValue);
}}
/>
);
}
render(<Harness />);
fireEvent.click(screen.getByPlaceholderText("请选择礼物"));
expect(screen.getByRole("dialog", { name: "选择礼物" })).toHaveAttribute("data-z-index", "1600");
expect(screen.getByText("Rose Gift")).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText("搜索礼物名称、礼物 ID、资源编码"), {
target: { value: "star" },
});
expect(screen.queryByText("Rose Gift")).not.toBeInTheDocument();
fireEvent.click(screen.getByText("Lucky Star"));
expect(changes.at(-1)).toMatchObject({ value: "10002", gift: { giftId: "10002", name: "Lucky Star" } });
expect(screen.getByDisplayValue("Lucky Star")).toBeInTheDocument();
expect(screen.queryByRole("dialog", { name: "选择礼物" })).not.toBeInTheDocument();
});

View File

@ -2,12 +2,14 @@ import { useEffect, useMemo, useState } from "react";
import { parseForm } from "@/shared/forms/validation";
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import {
createEmojiPack,
createGift,
createResource,
createResourceGroup,
deleteGift as requestDeleteGift,
disableResourceShopItem,
disableGift,
disableResource,
@ -40,7 +42,7 @@ import {
resourceShopSellableTypes,
} from "@/features/resources/constants.js";
import { useResourceAbilities } from "@/features/resources/permissions.js";
import { profileCardMetadataJSON } from "@/features/resources/profileCardLayout.js";
import { resourceMetadataJSON } from "@/features/resources/resourceMetadata.js";
import {
emojiPackFormSchema,
giftFormSchema,
@ -73,7 +75,10 @@ const emptyResourceForm = (resource = {}) => ({
: "normal",
coinPrice: resource.coinPrice === 0 || resource.coinPrice ? String(resource.coinPrice) : "",
enabled: resource.status ? resource.status === "active" : true,
levelTrack: resource.resourceType === "badge" ? resource.levelTrack || levelTrackFromMetadata(resource.metadataJson) || "" : "",
levelTrack:
resource.resourceType === "badge"
? resource.levelTrack || levelTrackFromMetadata(resource.metadataJson) || ""
: "",
managerGrantEnabled:
resource.managerGrantEnabled === undefined || resource.managerGrantEnabled === null
? true
@ -166,7 +171,11 @@ export function applyGiftPriceDefaults(form, coinPrice) {
export function cpRelationTypeFromPresentationJson(presentationJson) {
const presentation = parseGiftPresentationObject(presentationJson);
const relationType = cpPresentationRelationKeys
.map((key) => String(presentation?.[key] || "").trim().toLowerCase())
.map((key) =>
String(presentation?.[key] || "")
.trim()
.toLowerCase(),
)
.find((value) => cpRelationTypeValues.has(value));
return relationType || "";
}
@ -252,7 +261,9 @@ function normalizedCPRelationType(value) {
}
function validCPRelationType(value) {
const relationType = String(value || "").trim().toLowerCase();
const relationType = String(value || "")
.trim()
.toLowerCase();
return cpRelationTypeValues.has(relationType) ? relationType : "";
}
@ -877,6 +888,7 @@ export function useResourceGroupListPage() {
export function useGiftListPage() {
const abilities = useResourceAbilities();
const confirm = useConfirm();
const { showToast } = useToast();
const [query, setQuery] = useState("");
const [status, setStatus] = useState("");
@ -1134,6 +1146,41 @@ export function useGiftListPage() {
}
};
const deleteGifts = async (gifts = []) => {
const targetGifts = gifts.filter((gift) => gift?.giftId);
if (!abilities.canDeleteGift || !targetGifts.length) {
return false;
}
const title = targetGifts.length > 1 ? "批量删除礼物" : "删除礼物";
const message =
targetGifts.length > 1
? `确认删除选中的 ${targetGifts.length} 个礼物?只删除礼物列表数据,不删除资源列表数据。`
: `确认删除礼物 ${targetGifts[0].name || targetGifts[0].giftId}?只删除礼物列表数据,不删除资源列表数据。`;
const ok = await confirm({
confirmText: "删除",
message,
title,
tone: "danger",
});
if (!ok) {
return false;
}
setLoadingAction(targetGifts.length > 1 ? "gift-delete-batch" : `gift-delete-${targetGifts[0].giftId}`);
try {
for (const gift of targetGifts) {
await requestDeleteGift(gift.giftId);
}
showToast(targetGifts.length > 1 ? `已删除 ${targetGifts.length} 个礼物` : "礼物已删除", "success");
await result.reload();
return true;
} catch (err) {
showToast(err.message || "删除礼物失败", "error");
return false;
} finally {
setLoadingAction("");
}
};
const saveGiftSortOrder = async (gift, rawSortOrder) => {
if (!abilities.canUpdateGift || !gift?.giftId) {
return false;
@ -1191,6 +1238,7 @@ export function useGiftListPage() {
changeGiftTypeCode: resetSetter(setGiftTypeCode, setPage),
closeAction,
closeGiftTypeDialog,
deleteGifts,
form,
giftSortSavingIds,
giftTypeCode,
@ -1519,7 +1567,7 @@ function buildResourcePayload(form) {
coinPrice: form.priceType === "coin" ? Number(form.coinPrice) : 0,
levelTrack: resourceType === "badge" && form.badgeKind === "level" ? form.levelTrack : undefined,
managerGrantEnabled: Boolean(form.managerGrantEnabled),
metadataJson: resourceType === "profile_card" ? profileCardMetadataJSON(form.metadataJson) : undefined,
metadataJson: resourceMetadataJSON(resourceType, form.metadataJson),
name: form.name.trim(),
previewUrl: form.previewUrl.trim(),
priceType: form.priceType,

View File

@ -0,0 +1,601 @@
export const mp4AlphaLayoutKey = "mp4_alpha_layout";
export const mp4AlphaDetectVersion = 1;
export const mp4AlphaLayoutOptions = [
["normal", "普通 MP4"],
["alpha_left_rgb_right", "左 Alpha / 右 RGB"],
["rgb_left_alpha_right", "左 RGB / 右 Alpha"],
["alpha_top_rgb_bottom", "上 Alpha / 下 RGB"],
["rgb_top_alpha_bottom", "上 RGB / 下 Alpha"],
["custom", "自定义"],
];
const transparentLayoutKinds = new Set([
"alpha_left_rgb_right",
"rgb_left_alpha_right",
"alpha_top_rgb_bottom",
"rgb_top_alpha_bottom",
"custom",
]);
const videoFrameStepSeconds = 1 / 24;
const maxSeekFrameCount = 360;
const videoLoadTimeoutMs = 8000;
const videoSeekTimeoutMs = 5000;
export function isMp4File(file) {
if (!file?.name) {
return false;
}
const filename = String(file.name).toLowerCase();
const type = String(file.type || "").toLowerCase();
return filename.endsWith(".mp4") || type === "video/mp4";
}
export function isMp4Source(source = "") {
const normalized = String(source || "").trim();
if (!normalized) {
return false;
}
try {
const url = new URL(normalized, "https://admin.local");
return url.pathname.toLowerCase().endsWith(".mp4");
} catch {
return normalized.toLowerCase().split("?")[0].endsWith(".mp4");
}
}
export function createMp4AlphaLayout({
alphaLayout = "normal",
videoW,
videoH,
rgbFrame,
alphaFrame = null,
confirmed = false,
confidence = 0,
detectVersion = mp4AlphaDetectVersion,
} = {}) {
const videoWidth = positiveInteger(videoW);
const videoHeight = positiveInteger(videoH);
if (videoWidth <= 0 || videoHeight <= 0) {
return null;
}
const layoutKind = normalizeAlphaLayout(alphaLayout);
const defaultFrames = framePairForLayout(layoutKind, videoWidth, videoHeight);
const normalizedRgbFrame = sanitizeFrame(rgbFrame, videoWidth, videoHeight) || defaultFrames.rgbFrame;
const normalizedAlphaFrame =
layoutKind === "normal" ? null : sanitizeFrame(alphaFrame, videoWidth, videoHeight) || defaultFrames.alphaFrame;
if (!normalizedRgbFrame || (layoutKind !== "normal" && !normalizedAlphaFrame)) {
return null;
}
return {
alpha_layout: layoutKind,
video_w: videoWidth,
video_h: videoHeight,
rgb_frame: normalizedRgbFrame,
alpha_frame: normalizedAlphaFrame,
confirmed: Boolean(confirmed),
confidence: roundConfidence(confidence),
detect_version: positiveInteger(detectVersion) || mp4AlphaDetectVersion,
};
}
export function mp4AlphaLayoutFromMetadata(metadataJson) {
const metadata = parseMetadataObject(metadataJson);
return sanitizeMp4AlphaLayout(metadata?.[mp4AlphaLayoutKey]);
}
export function mergeMp4AlphaLayoutMetadata(metadataJson, layout) {
const sanitizedLayout = sanitizeMp4AlphaLayout(layout);
if (!sanitizedLayout) {
return normalizeMetadataJSON(metadataJson);
}
return stringifyMetadata({
...parseMetadataObject(metadataJson),
animation_format: "mp4",
[mp4AlphaLayoutKey]: sanitizedLayout,
});
}
export function removeMp4AlphaLayoutMetadata(metadataJson) {
const metadata = parseMetadataObject(metadataJson);
delete metadata[mp4AlphaLayoutKey];
if (metadata.animation_format === "mp4") {
delete metadata.animation_format;
}
return stringifyMetadata(metadata);
}
export function confirmMp4AlphaLayoutMetadata(metadataJson) {
const layout = mp4AlphaLayoutFromMetadata(metadataJson);
if (!layout) {
return normalizeMetadataJSON(metadataJson);
}
return mergeMp4AlphaLayoutMetadata(metadataJson, { ...layout, confirmed: true });
}
export function updateMp4AlphaLayoutKind(metadataJson, alphaLayout) {
const layout = mp4AlphaLayoutFromMetadata(metadataJson);
if (!layout) {
return normalizeMetadataJSON(metadataJson);
}
const nextLayout = createMp4AlphaLayout({
alphaLayout,
alphaFrame: layout.alpha_frame,
confirmed: false,
confidence: layout.confidence,
detectVersion: layout.detect_version,
rgbFrame: layout.rgb_frame,
videoH: layout.video_h,
videoW: layout.video_w,
});
return nextLayout ? mergeMp4AlphaLayoutMetadata(metadataJson, nextLayout) : normalizeMetadataJSON(metadataJson);
}
export function updateMp4AlphaLayoutFrame(metadataJson, key, value) {
const layout = mp4AlphaLayoutFromMetadata(metadataJson);
if (!layout) {
return normalizeMetadataJSON(metadataJson);
}
const frame = parseFrameText(value);
const nextLayout = createMp4AlphaLayout({
alphaLayout: layout.alpha_layout,
rgbFrame: key === "rgb_frame" ? frame : layout.rgb_frame,
alphaFrame: key === "alpha_frame" ? frame : layout.alpha_frame,
confirmed: false,
confidence: layout.confidence,
detectVersion: layout.detect_version,
videoH: layout.video_h,
videoW: layout.video_w,
});
return nextLayout ? mergeMp4AlphaLayoutMetadata(metadataJson, nextLayout) : normalizeMetadataJSON(metadataJson);
}
export function hasUnconfirmedMp4Layout(metadataJson) {
const layout = mp4AlphaLayoutFromMetadata(metadataJson);
return Boolean(layout && !layout.confirmed);
}
export function mp4LayoutLabel(layout) {
const kind = typeof layout === "string" ? layout : layout?.alpha_layout;
return mp4AlphaLayoutOptions.find(([value]) => value === kind)?.[1] || "未解析";
}
export function frameText(frame) {
return Array.isArray(frame) ? frame.join(",") : "";
}
export async function detectMp4AlphaLayout(file) {
if (!isMp4File(file)) {
throw new Error("请选择 MP4 动效素材");
}
const frames = await readVideoFrames(file);
if (!frames.length) {
const info = await readMp4VideoInfo(file);
return createMp4AlphaLayout({
alphaLayout: "normal",
videoW: info.width,
videoH: info.height,
confirmed: false,
});
}
return detectMp4AlphaLayoutFromFrames(frames);
}
export async function mp4FileFromSource(source) {
const normalizedSource = String(source || "").trim();
if (!isMp4Source(normalizedSource)) {
throw new Error("动态资源不是 MP4");
}
let response;
try {
response = await fetch(normalizedSource);
} catch {
throw new Error("MP4 素材下载失败,请确认素材 URL 可访问");
}
if (!response.ok) {
throw new Error("MP4 素材下载失败,请确认素材 URL 可访问");
}
const blob = await response.blob();
return new File([blob], filenameFromSource(normalizedSource), { type: blob.type || "video/mp4" });
}
export async function readMp4VideoInfo(file) {
const video = await loadVideo(file);
try {
return {
duration: Number.isFinite(video.duration) ? video.duration : 0,
height: video.videoHeight || 0,
width: video.videoWidth || 0,
};
} finally {
releaseVideo(video);
}
}
export function detectMp4AlphaLayoutFromFrames(frames) {
const validFrames = (frames || []).filter((frame) => frame?.data && frame.width > 0 && frame.height > 0);
if (!validFrames.length) {
return null;
}
const { width, height } = validFrames[0];
const candidates = candidateLayoutsForVideo(width, height).map((alphaLayout) => {
const pair = framePairForLayout(alphaLayout, width, height);
return {
alphaLayout,
...pair,
score: scoreCandidate(validFrames, pair),
};
});
candidates.sort((left, right) => right.score - left.score);
const selected = candidates[0];
if (!selected || selected.score < 0.42) {
return createMp4AlphaLayout({
alphaLayout: "normal",
videoW: width,
videoH: height,
confirmed: false,
confidence: selected?.score || 0,
});
}
return createMp4AlphaLayout({
alphaLayout: selected.alphaLayout,
videoW: width,
videoH: height,
rgbFrame: selected.rgbFrame,
alphaFrame: selected.alphaFrame,
confirmed: false,
confidence: selected.score,
});
}
function candidateLayoutsForVideo(width, height) {
const aspectRatio = width / height;
const layouts = [];
if (aspectRatio >= 0.85) {
layouts.push("alpha_left_rgb_right", "rgb_left_alpha_right");
}
if (aspectRatio <= 0.52) {
layouts.push("alpha_top_rgb_bottom", "rgb_top_alpha_bottom");
}
return layouts;
}
export function sanitizeMp4AlphaLayout(layout) {
if (!layout || typeof layout !== "object") {
return null;
}
return createMp4AlphaLayout({
alphaLayout: layout.alpha_layout ?? layout.alphaLayout,
videoW: layout.video_w ?? layout.videoW,
videoH: layout.video_h ?? layout.videoH,
rgbFrame: layout.rgb_frame ?? layout.rgbFrame,
alphaFrame: layout.alpha_frame ?? layout.alphaFrame,
confirmed: layout.confirmed,
confidence: layout.confidence,
detectVersion: layout.detect_version ?? layout.detectVersion,
});
}
function normalizeAlphaLayout(value) {
const normalized = String(value || "")
.trim()
.toLowerCase();
if (normalized === "normal" || transparentLayoutKinds.has(normalized)) {
return normalized;
}
return "normal";
}
function framePairForLayout(alphaLayout, width, height) {
if (alphaLayout === "alpha_left_rgb_right") {
const half = Math.floor(width / 2);
return {
alphaFrame: [0, 0, half, height],
rgbFrame: [half, 0, width - half, height],
};
}
if (alphaLayout === "rgb_left_alpha_right") {
const half = Math.floor(width / 2);
return {
alphaFrame: [half, 0, width - half, height],
rgbFrame: [0, 0, half, height],
};
}
if (alphaLayout === "alpha_top_rgb_bottom") {
const half = Math.floor(height / 2);
return {
alphaFrame: [0, 0, width, half],
rgbFrame: [0, half, width, height - half],
};
}
if (alphaLayout === "rgb_top_alpha_bottom") {
const half = Math.floor(height / 2);
return {
alphaFrame: [0, half, width, height - half],
rgbFrame: [0, 0, width, half],
};
}
return {
alphaFrame: null,
rgbFrame: [0, 0, width, height],
};
}
function sanitizeFrame(value, videoWidth, videoHeight) {
const values = Array.isArray(value) ? value : parseFrameText(value);
if (!Array.isArray(values) || values.length < 4) {
return null;
}
const frame = values.slice(0, 4).map(positiveIntegerOrZero);
if (frame[0] < 0 || frame[1] < 0) {
return null;
}
if (frame[2] <= 0 || frame[3] <= 0) {
return null;
}
if (frame[0] + frame[2] > videoWidth || frame[1] + frame[3] > videoHeight) {
return null;
}
return frame;
}
function parseFrameText(value) {
if (Array.isArray(value)) {
return value;
}
const text = String(value || "").trim();
if (!text) {
return null;
}
const values = text.split(",").map((item) => Number(item.trim()));
return values.length >= 4 && values.every(Number.isFinite) ? values.slice(0, 4) : null;
}
function filenameFromSource(source) {
try {
const url = new URL(source, "https://admin.local");
const pathname = decodeURIComponent(url.pathname || "");
return pathname.split("/").filter(Boolean).pop() || "animation.mp4";
} catch {
const clean = source.split("?")[0].split("#")[0];
return decodeURIComponent(clean.split("/").filter(Boolean).pop() || "animation.mp4");
}
}
function parseMetadataObject(metadataJson) {
const normalized = String(metadataJson || "").trim();
if (!normalized) {
return {};
}
try {
const parsed = JSON.parse(normalized);
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
} catch {
return {};
}
}
function normalizeMetadataJSON(metadataJson) {
return stringifyMetadata(parseMetadataObject(metadataJson));
}
function stringifyMetadata(metadata) {
const clean = {};
Object.entries(metadata || {}).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== "") {
clean[key] = value;
}
});
return Object.keys(clean).length ? JSON.stringify(clean) : "{}";
}
function positiveInteger(value) {
const number = Number(value);
return Number.isInteger(number) && number > 0 ? number : 0;
}
function positiveIntegerOrZero(value) {
const number = Number(value);
return Number.isInteger(number) && number >= 0 ? number : -1;
}
function roundConfidence(value) {
const number = Number(value);
if (!Number.isFinite(number) || number <= 0) {
return 0;
}
return Math.round(Math.min(number, 1) * 1000) / 1000;
}
async function readVideoFrames(file) {
const video = await loadVideo(file);
try {
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context || canvas.width <= 0 || canvas.height <= 0) {
throw new Error("MP4 画面无法读取");
}
await waitForVideoCurrentData(video);
const duration = Number.isFinite(video.duration) && video.duration > 0 ? video.duration : 0;
const frameCount = Math.min(Math.max(Math.ceil(duration / videoFrameStepSeconds), 1), maxSeekFrameCount);
const frames = [];
for (let index = 0; index < frameCount; index += 1) {
const time = duration > 0 ? Math.min(duration - 0.001, index * videoFrameStepSeconds) : 0;
await seekVideo(video, Math.max(0, time));
context.drawImage(video, 0, 0, canvas.width, canvas.height);
frames.push({
data: context.getImageData(0, 0, canvas.width, canvas.height).data,
height: canvas.height,
width: canvas.width,
});
}
return frames;
} finally {
releaseVideo(video);
}
}
function loadVideo(file) {
return new Promise((resolve, reject) => {
const video = document.createElement("video");
let settled = false;
let timeoutId = null;
const finish = (callback, value) => {
if (settled) {
return;
}
settled = true;
window.clearTimeout(timeoutId);
video.onloadedmetadata = null;
video.onerror = null;
callback(value);
};
video.muted = true;
video.playsInline = true;
video.preload = "auto";
video.src = URL.createObjectURL(file);
timeoutId = window.setTimeout(() => {
finish(reject, new Error("MP4 元数据读取超时"));
releaseVideo(video);
}, videoLoadTimeoutMs);
video.onloadedmetadata = () => finish(resolve, video);
video.onerror = () => {
finish(reject, new Error("MP4 元数据读取失败"));
releaseVideo(video);
};
});
}
function seekVideo(video, time) {
return new Promise((resolve, reject) => {
if (Math.abs((video.currentTime || 0) - time) < 0.001 && video.readyState >= 2) {
resolve();
return;
}
let timeoutId = null;
const done = () => {
window.clearTimeout(timeoutId);
video.removeEventListener("seeked", done);
video.removeEventListener("error", fail);
resolve();
};
const fail = () => {
window.clearTimeout(timeoutId);
video.removeEventListener("seeked", done);
video.removeEventListener("error", fail);
reject(new Error("MP4 画面解析失败"));
};
timeoutId = window.setTimeout(() => {
video.removeEventListener("seeked", done);
video.removeEventListener("error", fail);
reject(new Error("MP4 画面解析超时"));
}, videoSeekTimeoutMs);
video.addEventListener("seeked", done, { once: true });
video.addEventListener("error", fail, { once: true });
try {
video.currentTime = time;
} catch {
fail();
}
});
}
function waitForVideoCurrentData(video) {
return new Promise((resolve, reject) => {
if (video.readyState >= 2) {
resolve();
return;
}
let timeoutId = null;
const done = () => {
window.clearTimeout(timeoutId);
video.removeEventListener("loadeddata", done);
video.removeEventListener("canplay", done);
video.removeEventListener("error", fail);
resolve();
};
const fail = () => {
window.clearTimeout(timeoutId);
video.removeEventListener("loadeddata", done);
video.removeEventListener("canplay", done);
video.removeEventListener("error", fail);
reject(new Error("MP4 画面读取失败"));
};
timeoutId = window.setTimeout(() => {
video.removeEventListener("loadeddata", done);
video.removeEventListener("canplay", done);
video.removeEventListener("error", fail);
reject(new Error("MP4 画面读取超时"));
}, videoLoadTimeoutMs);
video.addEventListener("loadeddata", done, { once: true });
video.addEventListener("canplay", done, { once: true });
video.addEventListener("error", fail, { once: true });
});
}
function releaseVideo(video) {
if (!video) {
return;
}
if (video.src) {
URL.revokeObjectURL(video.src);
}
video.removeAttribute("src");
video.load();
}
function scoreCandidate(frames, candidate) {
const scores = frames.map((frame) => scoreFrameCandidate(frame, candidate));
if (!scores.length) {
return 0;
}
const average = scores.reduce((sum, value) => sum + value, 0) / scores.length;
return Math.max(0, Math.min(1, average));
}
function scoreFrameCandidate(frame, candidate) {
const alpha = regionStats(frame, candidate.alphaFrame);
const rgb = regionStats(frame, candidate.rgbFrame);
const maskScore = alpha.lowChromaRatio * 0.45 + alpha.brightnessSpread * 0.35 + alpha.darkOrBrightRatio * 0.2;
const rgbScore = Math.max(0, 1 - rgb.lowChromaRatio) * 0.6 + rgb.brightnessSpread * 0.4;
return maskScore * 0.7 + rgbScore * 0.3;
}
function regionStats(frame, rect) {
const [left, top, width, height] = rect;
const xStep = Math.max(1, Math.floor(width / 90));
const yStep = Math.max(1, Math.floor(height / 90));
let count = 0;
let lowChroma = 0;
let darkOrBright = 0;
let minBrightness = 255;
let maxBrightness = 0;
for (let y = top; y < top + height; y += yStep) {
for (let x = left; x < left + width; x += xStep) {
const index = (y * frame.width + x) * 4;
const r = frame.data[index];
const g = frame.data[index + 1];
const b = frame.data[index + 2];
const brightness = (r + g + b) / 3;
const chroma = Math.max(r, g, b) - Math.min(r, g, b);
count += 1;
if (chroma <= 16) {
lowChroma += 1;
}
if (brightness <= 32 || brightness >= 224) {
darkOrBright += 1;
}
minBrightness = Math.min(minBrightness, brightness);
maxBrightness = Math.max(maxBrightness, brightness);
}
}
return {
brightnessSpread: count ? (maxBrightness - minBrightness) / 255 : 0,
darkOrBrightRatio: count ? darkOrBright / count : 0,
lowChromaRatio: count ? lowChroma / count : 0,
};
}

View File

@ -0,0 +1,197 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import {
confirmMp4AlphaLayoutMetadata,
createMp4AlphaLayout,
detectMp4AlphaLayoutFromFrames,
isMp4Source,
mp4AlphaLayoutFromMetadata,
mp4FileFromSource,
updateMp4AlphaLayoutFrame,
updateMp4AlphaLayoutKind,
} from "./mp4AlphaLayout.js";
afterEach(() => {
vi.unstubAllGlobals();
});
describe("mp4 alpha layout metadata", () => {
test("builds a confirmed normal layout for a full-frame MP4", () => {
const layout = createMp4AlphaLayout({
alphaLayout: "normal",
confirmed: true,
videoH: 1334,
videoW: 750,
});
expect(layout).toMatchObject({
alpha_frame: null,
alpha_layout: "normal",
confirmed: true,
rgb_frame: [0, 0, 750, 1334],
video_h: 1334,
video_w: 750,
});
});
test("builds left-alpha and right-rgb frames for a 1500x1334 MP4", () => {
const layout = createMp4AlphaLayout({
alphaLayout: "alpha_left_rgb_right",
confirmed: true,
videoH: 1334,
videoW: 1500,
});
expect(layout).toMatchObject({
alpha_frame: [0, 0, 750, 1334],
alpha_layout: "alpha_left_rgb_right",
rgb_frame: [750, 0, 750, 1334],
video_h: 1334,
video_w: 1500,
});
});
test("builds split frames for an uneven 1472x1328 MP4", () => {
const layout = createMp4AlphaLayout({
alphaLayout: "rgb_left_alpha_right",
confirmed: true,
videoH: 1328,
videoW: 1472,
});
expect(layout).toMatchObject({
alpha_frame: [736, 0, 736, 1328],
alpha_layout: "rgb_left_alpha_right",
rgb_frame: [0, 0, 736, 1328],
});
});
test("keeps custom rgb and alpha rectangles", () => {
const layout = createMp4AlphaLayout({
alphaFrame: [10, 20, 200, 300],
alphaLayout: "custom",
confirmed: true,
rgbFrame: [240, 20, 200, 300],
videoH: 812,
videoW: 750,
});
expect(layout).toMatchObject({
alpha_frame: [10, 20, 200, 300],
alpha_layout: "custom",
rgb_frame: [240, 20, 200, 300],
});
});
test("marks metadata unconfirmed when layout or frames are edited", () => {
const metadataJson = confirmMp4AlphaLayoutMetadata(
JSON.stringify({
animation_format: "mp4",
mp4_alpha_layout: createMp4AlphaLayout({
alphaLayout: "alpha_left_rgb_right",
confirmed: false,
videoH: 1334,
videoW: 1500,
}),
}),
);
expect(mp4AlphaLayoutFromMetadata(metadataJson)?.confirmed).toBe(true);
expect(mp4AlphaLayoutFromMetadata(updateMp4AlphaLayoutKind(metadataJson, "normal"))).toMatchObject({
alpha_layout: "normal",
confirmed: false,
});
expect(
mp4AlphaLayoutFromMetadata(updateMp4AlphaLayoutFrame(metadataJson, "rgb_frame", "760,0,700,1334")),
).toMatchObject({
confirmed: false,
rgb_frame: [760, 0, 700, 1334],
});
});
test("detects and downloads mp4 source urls for row recalculation", async () => {
const fetchMock = vi.fn(async () => ({
blob: async () => new Blob(["mp4"], { type: "video/mp4" }),
ok: true,
}));
vi.stubGlobal("fetch", fetchMock);
expect(isMp4Source("https://cdn.example.test/assets/礼物_love_animation.mp4?token=1")).toBe(true);
expect(isMp4Source("https://cdn.example.test/assets/rose.svga")).toBe(false);
const file = await mp4FileFromSource("https://cdn.example.test/assets/%E7%A4%BC%E7%89%A9.mp4?token=1");
expect(fetchMock).toHaveBeenCalledWith("https://cdn.example.test/assets/%E7%A4%BC%E7%89%A9.mp4?token=1");
expect(file.name).toBe("礼物.mp4");
expect(file.type).toBe("video/mp4");
});
test("keeps a regular 750x1334 portrait MP4 as normal layout", () => {
const layout = detectMp4AlphaLayoutFromFrames([
buildSyntheticFrame(750, 1334, {
alphaFrame: [0, 0, 375, 1334],
rgbFrame: [375, 0, 375, 1334],
}),
]);
expect(layout).toMatchObject({
alpha_frame: null,
alpha_layout: "normal",
rgb_frame: [0, 0, 750, 1334],
video_h: 1334,
video_w: 750,
});
});
test("detects a wide left-alpha right-rgb MP4 layout", () => {
const layout = detectMp4AlphaLayoutFromFrames([
buildSyntheticFrame(1500, 1334, {
alphaFrame: [0, 0, 750, 1334],
rgbFrame: [750, 0, 750, 1334],
}),
]);
expect(layout).toMatchObject({
alpha_frame: [0, 0, 750, 1334],
alpha_layout: "alpha_left_rgb_right",
rgb_frame: [750, 0, 750, 1334],
video_h: 1334,
video_w: 1500,
});
});
});
function buildSyntheticFrame(width, height, { alphaFrame, rgbFrame }) {
const data = new Uint8ClampedArray(width * height * 4);
fillRect(data, width, [0, 0, width, height], [32, 32, 32]);
fillCheckerRect(data, width, alphaFrame, [0, 0, 0], [255, 255, 255]);
fillCheckerRect(data, width, rgbFrame, [230, 30, 20], [20, 150, 240]);
return { data, height, width };
}
function fillRect(data, canvasWidth, rect, color) {
const [left, top, width, height] = rect;
for (let y = top; y < top + height; y += 1) {
for (let x = left; x < left + width; x += 1) {
const offset = (y * canvasWidth + x) * 4;
data[offset] = color[0];
data[offset + 1] = color[1];
data[offset + 2] = color[2];
data[offset + 3] = 255;
}
}
}
function fillCheckerRect(data, canvasWidth, rect, firstColor, secondColor) {
const [left, top, width, height] = rect;
for (let y = top; y < top + height; y += 1) {
for (let x = left; x < left + width; x += 1) {
const color =
(Math.floor(x / 12) + Math.floor(y / 12)) % 2 === 0 ? firstColor : secondColor;
const offset = (y * canvasWidth + x) * 4;
data[offset] = color[0];
data[offset + 1] = color[1];
data[offset + 2] = color[2];
data[offset + 3] = 255;
}
}
}

View File

@ -3,15 +3,19 @@ import CalendarMonthOutlined from "@mui/icons-material/CalendarMonthOutlined";
import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
import ChevronLeftOutlined from "@mui/icons-material/ChevronLeftOutlined";
import ChevronRightOutlined from "@mui/icons-material/ChevronRightOutlined";
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import EditOutlined from "@mui/icons-material/EditOutlined";
import PublicOutlined from "@mui/icons-material/PublicOutlined";
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
import Checkbox from "@mui/material/Checkbox";
import CircularProgress from "@mui/material/CircularProgress";
import IconButton from "@mui/material/IconButton";
import InputAdornment from "@mui/material/InputAdornment";
import MenuItem from "@mui/material/MenuItem";
import Popover from "@mui/material/Popover";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import TextField from "@mui/material/TextField";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import {
AdminFormDialog,
AdminFormFieldGrid,
@ -97,7 +101,16 @@ const baseColumns = (giftTypeOptions) => [
render: (gift) => (
<div className={styles.stack}>
{giftEffectDetails(gift).map((item, index) => (
<span className={index > 0 ? styles.meta : undefined} key={item}>
<span
className={
item === effectLabels.global_broadcast
? styles.globalBroadcastEffect
: index > 0
? styles.meta
: undefined
}
key={item}
>
{item}
</span>
))}
@ -118,7 +131,7 @@ const baseColumns = (giftTypeOptions) => [
{
key: "actions",
label: "操作",
width: "minmax(76px, 0.4fr)",
width: "minmax(112px, 0.5fr)",
},
];
@ -126,75 +139,149 @@ export function GiftListPage() {
const page = useGiftListPage();
const items = page.data.items || [];
const total = page.data.total || 0;
const [selectedGiftIds, setSelectedGiftIds] = useState([]);
const isEditing = page.activeAction === "edit";
const giftRegionOptions = [{ label: "全局 · 0", value: "0" }, ...page.regionOptions];
const regionLabelById = buildRegionLabelMap(giftRegionOptions);
const giftTypeOptions = page.giftTypeOptions?.length ? page.giftTypeOptions : defaultGiftTypeOptions;
const tableColumns = baseColumns(giftTypeOptions).map((column) => {
if (column.key === "regions") {
return {
...column,
filter: createRegionColumnFilter({
loading: page.loadingRegions,
options: giftRegionOptions,
value: page.regionId,
onChange: page.changeRegionId,
}),
render: (gift) => <RegionTags regionIds={gift.regionIds} regionLabelById={regionLabelById} />,
};
const selectedGiftIdSet = useMemo(() => new Set(selectedGiftIds), [selectedGiftIds]);
const selectedGifts = useMemo(
() => items.filter((gift) => selectedGiftIdSet.has(String(gift.giftId))),
[items, selectedGiftIdSet],
);
const allChecked = items.length > 0 && items.every((gift) => selectedGiftIdSet.has(String(gift.giftId)));
const selectedOnPageCount = items.filter((gift) => selectedGiftIdSet.has(String(gift.giftId))).length;
const someChecked = selectedOnPageCount > 0 && !allChecked;
const deletingBatch = page.loadingAction === "gift-delete-batch";
useEffect(() => {
const itemIds = new Set(items.map((gift) => String(gift.giftId)));
setSelectedGiftIds((current) => {
const next = current.filter((giftId) => itemIds.has(giftId));
return next.length === current.length ? current : next;
});
}, [items]);
const toggleAllGifts = (checked) => {
const pageIds = items.map((gift) => String(gift.giftId));
setSelectedGiftIds((current) => {
if (!checked) {
return current.filter((giftId) => !pageIds.includes(giftId));
}
return Array.from(new Set([...current, ...pageIds]));
});
};
const toggleGiftSelection = (gift, checked) => {
const giftId = String(gift.giftId);
setSelectedGiftIds((current) =>
checked ? Array.from(new Set([...current, giftId])) : current.filter((selectedId) => selectedId !== giftId),
);
};
const deleteSelectedGifts = async () => {
const deleted = await page.deleteGifts(selectedGifts);
if (deleted) {
setSelectedGiftIds([]);
}
if (column.key === "status") {
return {
...column,
filter: createOptionsColumnFilter({
options: resourceStatusFilters,
placeholder: "搜索状态",
value: page.status,
onChange: page.changeStatus,
}),
render: (gift) => <GiftStatusSwitch gift={gift} page={page} />,
};
}
if (column.key === "type") {
return {
...column,
filter: createOptionsColumnFilter({
emptyLabel: "全部类型",
loading: page.giftTypesLoading,
options: giftTypeOptions.map((item) => ({
label: item.displayName || item.tabName || item.tabKey,
value: item.tabKey,
})),
placeholder: "搜索类型",
value: page.giftTypeCode,
onChange: page.changeGiftTypeCode,
}),
};
}
if (column.key === "sortOrder") {
return {
...column,
render: (gift) => <GiftSortOrderCell gift={gift} page={page} />,
};
}
if (column.key === "gift") {
return {
...column,
filter: createTextColumnFilter({
placeholder: "搜索礼物名称、礼物 ID",
value: page.query,
onChange: page.changeQuery,
}),
};
}
if (column.key === "actions") {
return {
...column,
render: (gift) => <GiftRowActions gift={gift} page={page} />,
};
}
return column;
});
};
const selectionColumn = {
key: "select",
className: styles.selectColumn,
header: (
<Checkbox
checked={allChecked}
disabled={!page.abilities.canDeleteGift || items.length === 0 || deletingBatch}
indeterminate={someChecked}
size="small"
slotProps={{ input: { "aria-label": "选择当前页礼物" } }}
onChange={(event) => toggleAllGifts(event.target.checked)}
/>
),
resizable: false,
width: "56px",
render: (gift) => {
const giftId = String(gift.giftId);
return (
<Checkbox
checked={selectedGiftIdSet.has(giftId)}
disabled={!page.abilities.canDeleteGift || deletingBatch}
size="small"
slotProps={{ input: { "aria-label": `选择 ${gift.name || gift.giftId}` } }}
onChange={(event) => toggleGiftSelection(gift, event.target.checked)}
/>
);
},
};
const tableColumns = [
selectionColumn,
...baseColumns(giftTypeOptions).map((column) => {
if (column.key === "regions") {
return {
...column,
filter: createRegionColumnFilter({
loading: page.loadingRegions,
options: giftRegionOptions,
value: page.regionId,
onChange: page.changeRegionId,
}),
render: (gift) => <RegionTags regionIds={gift.regionIds} regionLabelById={regionLabelById} />,
};
}
if (column.key === "status") {
return {
...column,
filter: createOptionsColumnFilter({
options: resourceStatusFilters,
placeholder: "搜索状态",
value: page.status,
onChange: page.changeStatus,
}),
render: (gift) => <GiftStatusSwitch gift={gift} page={page} />,
};
}
if (column.key === "type") {
return {
...column,
filter: createOptionsColumnFilter({
emptyLabel: "全部类型",
loading: page.giftTypesLoading,
options: giftTypeOptions.map((item) => ({
label: item.displayName || item.tabName || item.tabKey,
value: item.tabKey,
})),
placeholder: "搜索类型",
value: page.giftTypeCode,
onChange: page.changeGiftTypeCode,
}),
};
}
if (column.key === "sortOrder") {
return {
...column,
render: (gift) => <GiftSortOrderCell gift={gift} page={page} />,
};
}
if (column.key === "gift") {
return {
...column,
filter: createTextColumnFilter({
placeholder: "搜索礼物名称、礼物 ID",
value: page.query,
onChange: page.changeQuery,
}),
};
}
if (column.key === "actions") {
return {
...column,
render: (gift) => <GiftRowActions gift={gift} page={page} />,
};
}
return column;
}),
];
return (
<AdminListPage>
@ -206,6 +293,19 @@ export function GiftListPage() {
<SettingsOutlined fontSize="small" />
</AdminActionIconButton>
) : null}
{page.abilities.canDeleteGift ? (
<AdminActionIconButton
disabled={!selectedGifts.length || deletingBatch}
label={deletingBatch ? "批量删除中" : `批量删除礼物(${selectedGifts.length}`}
onClick={deleteSelectedGifts}
>
{deletingBatch ? (
<CircularProgress size={16} />
) : (
<DeleteOutlineOutlined fontSize="small" />
)}
</AdminActionIconButton>
) : null}
{page.abilities.canCreateGift ? (
<AdminActionIconButton label="添加礼物" primary onClick={page.openCreateGift}>
<Add fontSize="small" />
@ -219,7 +319,7 @@ export function GiftListPage() {
<DataTable
columns={tableColumns}
items={items}
minWidth="1440px"
minWidth="1500px"
pagination={
total > 0
? {
@ -260,6 +360,9 @@ export function GiftListPage() {
}
function GiftRowActions({ gift, page }) {
const deleting = page.loadingAction === `gift-delete-${gift.giftId}`;
const deletingBatch = page.loadingAction === "gift-delete-batch";
return (
<AdminRowActions>
<AdminActionIconButton
@ -269,6 +372,15 @@ function GiftRowActions({ gift, page }) {
>
<EditOutlined fontSize="small" />
</AdminActionIconButton>
{page.abilities.canDeleteGift ? (
<AdminActionIconButton
disabled={deleting || deletingBatch}
label={deleting ? "删除中" : "删除礼物"}
onClick={() => page.deleteGifts([gift])}
>
{deleting ? <CircularProgress size={16} /> : <DeleteOutlineOutlined fontSize="small" />}
</AdminActionIconButton>
) : null}
</AdminRowActions>
);
}
@ -641,10 +753,16 @@ function GiftIdentity({ gift }) {
const preview = imageURL(gift.resource?.previewUrl || gift.resource?.assetUrl);
const resourceCode = String(gift.resource?.resourceCode || "").trim();
const title = `${gift.name || "-"}${resourceCode ? `${resourceCode}` : ""}`;
const globalBroadcast = hasGlobalBroadcastEffect(gift);
return (
<div className={styles.identity}>
<span className={styles.thumb}>
{preview ? <img src={preview} alt="" /> : <CardGiftcardOutlined fontSize="small" />}
{globalBroadcast ? (
<span className={styles.giftGlobalBroadcastBadge} title="全局广播">
<PublicOutlined fontSize="inherit" />
</span>
) : null}
</span>
<div className={styles.identityText}>
<span className={styles.name}>{title}</span>
@ -689,6 +807,10 @@ function effectTypesLabel(values) {
return list.length ? list.map(effectTypeLabel).join("、") : "无特效";
}
function hasGlobalBroadcastEffect(gift) {
return (gift.effectTypes || []).some((item) => String(item || "").trim() === "global_broadcast");
}
function giftEffectDetails(gift) {
const details = [];
const effective = effectiveRangeLabel(gift);

View File

@ -1,16 +1,11 @@
import Add from "@mui/icons-material/Add";
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { AdminFormDialog, AdminFormFieldGrid, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import {
AdminActionIconButton,
AdminListBody,
AdminListPage,
AdminListToolbar,
} from "@/shared/ui/AdminListLayout.jsx";
import { AdminActionIconButton, AdminListBody, AdminListPage, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
import { GiftResourceSelectField } from "@/features/resources/components/GiftResourceSelectDrawer.jsx";
@ -233,7 +228,9 @@ function ResourceGrantDialog({
required
resources={resourceOptions}
selectedResourceIds={form.resourceIds || []}
onChange={(resourceIds) => setForm({ ...form, resourceId: resourceIds[0] || "", resourceIds })}
onChange={(resourceIds) =>
setForm({ ...form, resourceId: resourceIds[0] || "", resourceIds })
}
/>
<TextField
disabled={disabled}
@ -305,20 +302,12 @@ function GrantItems({ grant }) {
function GrantTargetUser({ grant }) {
const user = grant.targetUser || grant.target_user || grant.user || {};
const userId = user.userId || user.user_id || grant.targetUserId;
const displayUserId = user.displayUserId || user.display_user_id;
const username = user.username || "";
const name = username || (displayUserId ? `用户 ${displayUserId}` : userId ? `用户 ${userId}` : "-");
return (
<div className={styles.identity}>
<span className={styles.operatorAvatar}>
{user.avatar ? <img alt="" src={user.avatar} /> : <PersonOutlineOutlined fontSize="small" />}
</span>
<span className={styles.identityText}>
<span className={styles.name}>{name}</span>
<span className={styles.meta}>长ID {userId || "-"}</span>
{displayUserId ? <span className={styles.meta}>短ID {displayUserId}</span> : null}
</span>
</div>
<AdminUserIdentity
openInAppUserDetail
rows={[user.displayUserId || user.display_user_id, userId]}
user={{ ...user, userId }}
/>
);
}
@ -327,22 +316,12 @@ function GrantOperator({ grant }) {
const source = operator.source || grant.grantSource || "";
const operatorUserId = operator.userId || grant.operatorUserId;
if (source === "manager_center") {
const name = operator.username || operator.name || (operatorUserId ? `用户 ${operatorUserId}` : "-");
const shortId = operator.displayUserId || operatorUserId || "-";
return (
<div className={styles.identity}>
<span className={styles.operatorAvatar}>
{operator.avatar ? (
<img alt="" src={operator.avatar} />
) : (
<PersonOutlineOutlined fontSize="small" />
)}
</span>
<span className={styles.identityText}>
<span className={styles.name}>{name}</span>
<span className={styles.meta}>{shortId}</span>
</span>
</div>
<AdminUserIdentity
openInAppUserDetail
name={operator.username || operator.name}
user={{ ...operator, userId: operatorUserId }}
/>
);
}
if (source === "admin") {

Some files were not shown because too many files have changed in this diff Show More