Compare commits
14 Commits
8175726223
...
103b70f5e5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
103b70f5e5 | ||
|
|
7a6f0db859 | ||
|
|
53b74f9dc2 | ||
|
|
ebb4545ad6 | ||
|
|
7d6418e43c | ||
|
|
54fdc867fe | ||
|
|
7b094f2b6f | ||
|
|
e2d8d03260 | ||
|
|
0b3589a701 | ||
|
|
e423adc738 | ||
|
|
7740d611b6 | ||
|
|
4e56597adb | ||
|
|
fcd44f8c13 | ||
|
|
8a6e9dc18d |
@ -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。
|
||||
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
174
src/features/app-config/hooks/usePopupConfigPage.js
Normal file
174
src/features/app-config/hooks/usePopupConfigPage.js
Normal 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",
|
||||
};
|
||||
}
|
||||
207
src/features/app-config/hooks/useSplashScreenConfigPage.js
Normal file
207
src/features/app-config/hooks/useSplashScreenConfigPage.js
Normal 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",
|
||||
};
|
||||
}
|
||||
@ -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>
|
||||
|
||||
326
src/features/app-config/pages/PopupConfigPage.jsx
Normal file
326
src/features/app-config/pages/PopupConfigPage.jsx
Normal 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 "关闭";
|
||||
}
|
||||
440
src/features/app-config/pages/SplashScreenConfigPage.jsx
Normal file
440
src/features/app-config/pages/SplashScreenConfigPage.jsx
Normal 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;
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(""),
|
||||
|
||||
@ -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");
|
||||
});
|
||||
|
||||
@ -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 返回,生成类型和页面代码使用 camelCase;Raw 类型同时接收两种形态,便于后续切换生成客户端。
|
||||
interface RawPrettyDisplayIDPool {
|
||||
app_code?: string;
|
||||
appCode?: string;
|
||||
created_at_ms?: number;
|
||||
createdAtMs?: number;
|
||||
created_by_admin_id?: EntityId;
|
||||
createdByAdminId?: EntityId;
|
||||
level_track?: string;
|
||||
levelTrack?: string;
|
||||
max_level?: number;
|
||||
maxLevel?: number;
|
||||
min_level?: number;
|
||||
minLevel?: number;
|
||||
name?: string;
|
||||
pool_id?: string;
|
||||
poolId?: string;
|
||||
rule_config_json?: string;
|
||||
ruleConfigJson?: string;
|
||||
rule_type?: string;
|
||||
ruleType?: string;
|
||||
sort_order?: number;
|
||||
sortOrder?: number;
|
||||
status?: string;
|
||||
updated_at_ms?: number;
|
||||
updatedAtMs?: number;
|
||||
updated_by_admin_id?: EntityId;
|
||||
updatedByAdminId?: EntityId;
|
||||
}
|
||||
|
||||
interface RawPrettyDisplayID {
|
||||
app_code?: string;
|
||||
appCode?: string;
|
||||
assigned_at_ms?: number;
|
||||
assignedAtMs?: number;
|
||||
assigned_lease_id?: string;
|
||||
assignedLeaseId?: string;
|
||||
assigned_user_id?: EntityId;
|
||||
assignedUserId?: EntityId;
|
||||
created_at_ms?: number;
|
||||
createdAtMs?: number;
|
||||
created_by_admin_id?: EntityId;
|
||||
createdByAdminId?: EntityId;
|
||||
display_user_id?: string;
|
||||
displayUserId?: string;
|
||||
generated_batch_id?: string;
|
||||
generatedBatchId?: string;
|
||||
pool?: RawPrettyDisplayIDPool | null;
|
||||
pool_id?: string;
|
||||
poolId?: string;
|
||||
pretty_id?: string;
|
||||
prettyId?: string;
|
||||
release_reason?: string;
|
||||
releaseReason?: string;
|
||||
released_at_ms?: number;
|
||||
releasedAtMs?: number;
|
||||
source?: string;
|
||||
status?: string;
|
||||
updated_at_ms?: number;
|
||||
updatedAtMs?: number;
|
||||
}
|
||||
|
||||
interface RawPrettyDisplayIDGenerationBatch {
|
||||
app_code?: string;
|
||||
appCode?: string;
|
||||
batch_id?: string;
|
||||
batchId?: string;
|
||||
created_at_ms?: number;
|
||||
createdAtMs?: number;
|
||||
generated_count?: number;
|
||||
generatedCount?: number;
|
||||
operator_admin_id?: EntityId;
|
||||
operatorAdminId?: EntityId;
|
||||
pool_id?: string;
|
||||
poolId?: string;
|
||||
request_id?: string;
|
||||
requestId?: string;
|
||||
requested_count?: number;
|
||||
requestedCount?: number;
|
||||
rule_config_json?: string;
|
||||
ruleConfigJson?: string;
|
||||
rule_type?: string;
|
||||
ruleType?: string;
|
||||
skipped_conflict_count?: number;
|
||||
skippedConflictCount?: number;
|
||||
status?: string;
|
||||
updated_at_ms?: number;
|
||||
updatedAtMs?: number;
|
||||
}
|
||||
|
||||
interface RawAdminGrantPrettyDisplayIDResult {
|
||||
identity?: {
|
||||
default_display_user_id?: string;
|
||||
defaultDisplayUserId?: string;
|
||||
display_user_id?: string;
|
||||
displayUserId?: string;
|
||||
pretty_display_user_id?: string;
|
||||
prettyDisplayUserId?: string;
|
||||
pretty_id?: string;
|
||||
prettyId?: string;
|
||||
user_id?: EntityId;
|
||||
userId?: EntityId;
|
||||
};
|
||||
lease_id?: string;
|
||||
leaseId?: string;
|
||||
pretty_id?: string;
|
||||
prettyId?: string;
|
||||
}
|
||||
|
||||
// 分页结构也做一次兼容,page_size 来自 Go 响应,pageSize 来自前端 ApiPage 约定。
|
||||
function normalizePage<TRaw, TItem>(
|
||||
data: ApiPage<TRaw> & { page_size?: number },
|
||||
normalize: (item: TRaw) => TItem,
|
||||
): ApiPage<TItem> {
|
||||
return {
|
||||
items: (data.items || []).map(normalize),
|
||||
page: Number(data.page || 1),
|
||||
pageSize: Number(data.pageSize || data.page_size || 20),
|
||||
total: Number(data.total || 0),
|
||||
};
|
||||
}
|
||||
|
||||
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 ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
export const AppUserDetailContext = createContext({
|
||||
openAppUserDetail: null,
|
||||
});
|
||||
|
||||
export function useAppUserDetail() {
|
||||
return useContext(AppUserDetailContext);
|
||||
}
|
||||
201
src/features/app-users/components/AppUserDetailProvider.jsx
Normal file
201
src/features/app-users/components/AppUserDetailProvider.jsx
Normal 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 "";
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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({});
|
||||
|
||||
346
src/features/app-users/hooks/usePrettyIdsPage.js
Normal file
346
src/features/app-users/hooks/usePrettyIdsPage.js
Normal file
@ -0,0 +1,346 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { parseForm } from "@/shared/forms/validation";
|
||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
import {
|
||||
createPrettyDisplayIDPool,
|
||||
generatePrettyDisplayIDs,
|
||||
grantPrettyDisplayID,
|
||||
listPrettyDisplayIDPools,
|
||||
listPrettyDisplayIDs,
|
||||
setPrettyDisplayIDStatus,
|
||||
updatePrettyDisplayIDPool,
|
||||
} from "@/features/app-users/api";
|
||||
import { useAppUserAbilities } from "@/features/app-users/permissions.js";
|
||||
import {
|
||||
prettyDisplayIDGenerateSchema,
|
||||
prettyDisplayIDGrantSchema,
|
||||
prettyDisplayIDPoolSchema,
|
||||
prettyDisplayIDStatusSchema,
|
||||
} from "@/features/app-users/schema";
|
||||
|
||||
const pageSize = 20;
|
||||
const emptyPoolForm = () => ({
|
||||
levelTrack: "wealth",
|
||||
maxLevel: 10,
|
||||
minLevel: 1,
|
||||
name: "",
|
||||
ruleConfigJson: "",
|
||||
ruleType: "aabbcc",
|
||||
sortOrder: 0,
|
||||
status: "active",
|
||||
});
|
||||
const emptyGenerateForm = () => ({ count: 100, poolId: "", ruleConfigJson: "", ruleType: "aabbcc" });
|
||||
const emptyGrantForm = () => ({ displayUserId: "", durationDays: "", reason: "", targetUserId: "" });
|
||||
const emptyStatusForm = () => ({ prettyId: "", reason: "", status: "available" });
|
||||
|
||||
export function usePrettyIdsPage() {
|
||||
const abilities = useAppUserAbilities();
|
||||
const { showToast } = useToast();
|
||||
const [poolPage, setPoolPage] = useState(1);
|
||||
const [poolStatus, setPoolStatus] = useState("");
|
||||
const [poolTrack, setPoolTrack] = useState("");
|
||||
const [idPage, setIdPage] = useState(1);
|
||||
const [idKeyword, setIdKeyword] = useState("");
|
||||
const [idStatus, setIdStatus] = useState("");
|
||||
const [idSource, setIdSource] = useState("");
|
||||
const [idPoolId, setIdPoolId] = useState("");
|
||||
const [assignedUserId, setAssignedUserId] = useState("");
|
||||
const [activeDialog, setActiveDialog] = useState("");
|
||||
const [activePool, setActivePool] = useState(null);
|
||||
const [activePrettyID, setActivePrettyID] = useState(null);
|
||||
const [poolForm, setPoolForm] = useState(emptyPoolForm);
|
||||
const [generateForm, setGenerateForm] = useState(emptyGenerateForm);
|
||||
const [grantForm, setGrantForm] = useState(emptyGrantForm);
|
||||
const [statusForm, setStatusForm] = useState(emptyStatusForm);
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
|
||||
// 靓号池和靓号明细是两个独立分页接口,筛选条件保持服务端字段名,避免在 hook 内再做一次字段映射。
|
||||
const poolFilters = useMemo(
|
||||
() => ({
|
||||
level_track: poolTrack,
|
||||
status: poolStatus,
|
||||
}),
|
||||
[poolStatus, poolTrack],
|
||||
);
|
||||
// 靓号明细筛选会直接透传到后台列表接口,assigned_user_id 在输入阶段已 trim,keyword 保留原值用于模糊搜索。
|
||||
const idFilters = useMemo(
|
||||
() => ({
|
||||
assigned_user_id: assignedUserId,
|
||||
keyword: idKeyword,
|
||||
pool_id: idPoolId,
|
||||
source: idSource,
|
||||
status: idStatus,
|
||||
}),
|
||||
[assignedUserId, idKeyword, idPoolId, idSource, idStatus],
|
||||
);
|
||||
|
||||
const poolQuery = usePaginatedQuery({
|
||||
errorMessage: "加载靓号池失败",
|
||||
fetcher: listPrettyDisplayIDPools,
|
||||
filters: poolFilters,
|
||||
page: poolPage,
|
||||
pageSize,
|
||||
queryKey: ["pretty-id-pools", poolFilters, poolPage],
|
||||
});
|
||||
const idQuery = usePaginatedQuery({
|
||||
errorMessage: "加载靓号列表失败",
|
||||
fetcher: listPrettyDisplayIDs,
|
||||
filters: idFilters,
|
||||
page: idPage,
|
||||
pageSize,
|
||||
queryKey: ["pretty-ids", idFilters, idPage],
|
||||
});
|
||||
|
||||
// 生成弹窗和明细筛选只需要当前已加载池列表;没有额外拉全量,避免页面初始化时放大后台查询。
|
||||
const poolOptions = useMemo(
|
||||
() =>
|
||||
(poolQuery.data.items || []).map((pool) => ({
|
||||
label: `${pool.name} · ${pool.minLevel}-${pool.maxLevel}`,
|
||||
value: pool.poolId,
|
||||
})),
|
||||
[poolQuery.data.items],
|
||||
);
|
||||
|
||||
const openCreatePool = () => {
|
||||
setActivePool(null);
|
||||
setPoolForm(emptyPoolForm());
|
||||
setActiveDialog("pool");
|
||||
};
|
||||
|
||||
const openEditPool = (pool) => {
|
||||
setActivePool(pool);
|
||||
// 编辑弹窗以服务端当前值为准回填,缺失字段只兜底到首版规则,避免把旧数据误清空。
|
||||
setPoolForm({
|
||||
levelTrack: pool.levelTrack || "wealth",
|
||||
maxLevel: pool.maxLevel ?? 10,
|
||||
minLevel: pool.minLevel ?? 1,
|
||||
name: pool.name || "",
|
||||
ruleConfigJson: pool.ruleConfigJson || "",
|
||||
ruleType: pool.ruleType || "aabbcc",
|
||||
sortOrder: pool.sortOrder || 0,
|
||||
status: pool.status || "active",
|
||||
});
|
||||
setActiveDialog("pool");
|
||||
};
|
||||
|
||||
const openGenerate = (pool) => {
|
||||
setActivePool(pool || null);
|
||||
// 从池行进入时优先使用该池,从工具栏进入时沿用列表筛选池,减少管理员重复选择。
|
||||
setGenerateForm({
|
||||
...emptyGenerateForm(),
|
||||
poolId: pool?.poolId || idPoolId || "",
|
||||
ruleConfigJson: pool?.ruleConfigJson || "",
|
||||
ruleType: pool?.ruleType || "aabbcc",
|
||||
});
|
||||
setActiveDialog("generate");
|
||||
};
|
||||
|
||||
const openGrant = () => {
|
||||
setGrantForm(emptyGrantForm());
|
||||
setActiveDialog("grant");
|
||||
};
|
||||
|
||||
const openStatus = (item) => {
|
||||
setActivePrettyID(item);
|
||||
// 状态弹窗默认给出最常用的反向操作:禁用中的号码恢复可用,其余未占用号码默认禁用。
|
||||
setStatusForm({
|
||||
prettyId: item?.prettyId || "",
|
||||
reason: "",
|
||||
status: item?.status === "disabled" ? "available" : "disabled",
|
||||
});
|
||||
setActiveDialog("status");
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
setActiveDialog("");
|
||||
setActivePool(null);
|
||||
setActivePrettyID(null);
|
||||
};
|
||||
|
||||
const submitPool = async (event) => {
|
||||
event.preventDefault();
|
||||
await runAction("pool", activePool ? "靓号池已更新" : "靓号池已创建", async () => {
|
||||
// 表单字段使用前端 camelCase,提交边界统一转换为后端 JSON 的 snake_case。
|
||||
const form = parseForm(prettyDisplayIDPoolSchema, poolForm);
|
||||
const payload = {
|
||||
level_track: form.levelTrack,
|
||||
max_level: form.maxLevel,
|
||||
min_level: form.minLevel,
|
||||
name: form.name,
|
||||
rule_config_json: form.ruleConfigJson,
|
||||
rule_type: form.ruleType,
|
||||
sort_order: form.sortOrder,
|
||||
status: form.status,
|
||||
};
|
||||
if (activePool?.poolId) {
|
||||
await updatePrettyDisplayIDPool(activePool.poolId, payload);
|
||||
} else {
|
||||
await createPrettyDisplayIDPool(payload);
|
||||
}
|
||||
closeDialog();
|
||||
await reloadAll();
|
||||
});
|
||||
};
|
||||
|
||||
const submitGenerate = async (event) => {
|
||||
event.preventDefault();
|
||||
await runAction("generate", "靓号已生成", async () => {
|
||||
// 批量生成只让后台接收池 ID、规则和数量,冲突过滤与批次审计由 user-service 事务完成。
|
||||
const form = parseForm(prettyDisplayIDGenerateSchema, generateForm);
|
||||
await generatePrettyDisplayIDs(form.poolId, {
|
||||
count: form.count,
|
||||
rule_config_json: form.ruleConfigJson,
|
||||
rule_type: form.ruleType,
|
||||
});
|
||||
closeDialog();
|
||||
await reloadAll();
|
||||
});
|
||||
};
|
||||
|
||||
const submitGrant = async (event) => {
|
||||
event.preventDefault();
|
||||
await runAction("grant", "靓号已发放", async () => {
|
||||
const form = parseForm(prettyDisplayIDGrantSchema, grantForm);
|
||||
// 后台发放用 0 表示长期,正数天数在前端换算成毫秒,服务端只处理 duration_ms 这个统一单位。
|
||||
const days = Number(form.durationDays || 0);
|
||||
await grantPrettyDisplayID({
|
||||
display_user_id: form.displayUserId,
|
||||
duration_ms: days > 0 ? days * 24 * 60 * 60 * 1000 : 0,
|
||||
reason: form.reason,
|
||||
target_user_id: form.targetUserId,
|
||||
});
|
||||
closeDialog();
|
||||
await idQuery.reload();
|
||||
});
|
||||
};
|
||||
|
||||
const submitStatus = async (event) => {
|
||||
event.preventDefault();
|
||||
await runAction("status", "靓号状态已更新", async () => {
|
||||
// 状态变更只允许未分配号码,前端提交 pretty_id 和目标状态,真正可变状态由后端再次校验。
|
||||
const form = parseForm(prettyDisplayIDStatusSchema, statusForm);
|
||||
await setPrettyDisplayIDStatus(form.prettyId, {
|
||||
reason: form.reason,
|
||||
status: form.status,
|
||||
});
|
||||
closeDialog();
|
||||
await idQuery.reload();
|
||||
});
|
||||
};
|
||||
|
||||
const changePoolStatus = (value) => {
|
||||
setPoolStatus(value);
|
||||
// 任意筛选变化都回到第一页,避免旧页码在新条件下落到空页。
|
||||
setPoolPage(1);
|
||||
};
|
||||
const changePoolTrack = (value) => {
|
||||
setPoolTrack(value);
|
||||
setPoolPage(1);
|
||||
};
|
||||
const changeIDKeyword = (value) => {
|
||||
setIdKeyword(value);
|
||||
// 明细列表的每个筛选项都会改变总数,页码统一回到第一页。
|
||||
setIdPage(1);
|
||||
};
|
||||
const changeIDStatus = (value) => {
|
||||
setIdStatus(value);
|
||||
setIdPage(1);
|
||||
};
|
||||
const changeIDSource = (value) => {
|
||||
setIdSource(value);
|
||||
setIdPage(1);
|
||||
};
|
||||
const changeIDPool = (value) => {
|
||||
setIdPoolId(value);
|
||||
setIdPage(1);
|
||||
};
|
||||
const changeAssignedUserId = (value) => {
|
||||
setAssignedUserId(value.trim());
|
||||
setIdPage(1);
|
||||
};
|
||||
|
||||
const resetPoolFilters = () => {
|
||||
// 重置筛选后立即回到第一页,保持 URL 之外的页面状态和查询条件同步。
|
||||
setPoolStatus("");
|
||||
setPoolTrack("");
|
||||
setPoolPage(1);
|
||||
};
|
||||
const resetIDFilters = () => {
|
||||
// 靓号列表筛选项彼此独立,重置时一次性清空,避免残留 assigned_user_id 影响排查。
|
||||
setAssignedUserId("");
|
||||
setIdKeyword("");
|
||||
setIdPoolId("");
|
||||
setIdSource("");
|
||||
setIdStatus("");
|
||||
setIdPage(1);
|
||||
};
|
||||
|
||||
const reloadAll = async () => {
|
||||
// 池配置会影响生成入口和号码列表展示,池相关操作完成后两个分页一起刷新。
|
||||
await Promise.all([poolQuery.reload(), idQuery.reload()]);
|
||||
};
|
||||
|
||||
const runAction = async (action, successMessage, submitter) => {
|
||||
// 所有写操作共享 loading、toast 和异常展示;弹窗只在 submitter 内确认上游成功后关闭。
|
||||
setLoadingAction(action);
|
||||
try {
|
||||
await submitter();
|
||||
showToast(successMessage, "success");
|
||||
} catch (err) {
|
||||
showToast(err.message || "操作失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
activeDialog,
|
||||
activePool,
|
||||
activePrettyID,
|
||||
assignedUserId,
|
||||
changeAssignedUserId,
|
||||
changeIDKeyword,
|
||||
changeIDPool,
|
||||
changeIDSource,
|
||||
changeIDStatus,
|
||||
changePoolStatus,
|
||||
changePoolTrack,
|
||||
closeDialog,
|
||||
generateForm,
|
||||
grantForm,
|
||||
idKeyword,
|
||||
idPage,
|
||||
idPoolId,
|
||||
idQuery,
|
||||
idSource,
|
||||
idStatus,
|
||||
loadingAction,
|
||||
openCreatePool,
|
||||
openEditPool,
|
||||
openGenerate,
|
||||
openGrant,
|
||||
openStatus,
|
||||
poolForm,
|
||||
poolOptions,
|
||||
poolPage,
|
||||
poolQuery,
|
||||
poolStatus,
|
||||
poolTrack,
|
||||
reloadAll,
|
||||
resetIDFilters,
|
||||
resetPoolFilters,
|
||||
setGenerateForm,
|
||||
setGrantForm,
|
||||
setIdPage,
|
||||
setPoolForm,
|
||||
setPoolPage,
|
||||
setStatusForm,
|
||||
statusForm,
|
||||
submitGenerate,
|
||||
submitGrant,
|
||||
submitPool,
|
||||
submitStatus,
|
||||
};
|
||||
}
|
||||
@ -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">
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
679
src/features/app-users/pages/AppUserPrettyIdsPage.jsx
Normal file
679
src/features/app-users/pages/AppUserPrettyIdsPage.jsx
Normal 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;
|
||||
}
|
||||
@ -2,13 +2,17 @@ import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||
import { PERMISSIONS } from "@/app/permissions";
|
||||
|
||||
export function useAppUserAbilities() {
|
||||
const { can } = useAuth();
|
||||
const { can } = useAuth();
|
||||
|
||||
return {
|
||||
canPassword: can(PERMISSIONS.appUserPassword),
|
||||
canStatus: can(PERMISSIONS.appUserStatus),
|
||||
canUpdate: can(PERMISSIONS.appUserUpdate),
|
||||
canUpload: can(PERMISSIONS.uploadCreate),
|
||||
canView: can(PERMISSIONS.appUserView)
|
||||
};
|
||||
return {
|
||||
canPassword: can(PERMISSIONS.appUserPassword),
|
||||
canPrettyGenerate: can(PERMISSIONS.prettyIdGenerate),
|
||||
canPrettyGrant: can(PERMISSIONS.prettyIdGrant),
|
||||
canPrettyUpdate: can(PERMISSIONS.prettyIdUpdate),
|
||||
canPrettyView: can(PERMISSIONS.prettyIdView),
|
||||
canStatus: can(PERMISSIONS.appUserStatus),
|
||||
canUpdate: can(PERMISSIONS.appUserUpdate),
|
||||
canUpload: can(PERMISSIONS.uploadCreate),
|
||||
canView: can(PERMISSIONS.appUserView),
|
||||
};
|
||||
}
|
||||
|
||||
@ -25,4 +25,12 @@ export const appUserRoutes = [
|
||||
path: "/app/users/bans",
|
||||
permission: PERMISSIONS.appUserView,
|
||||
},
|
||||
{
|
||||
label: "靓号管理",
|
||||
loader: () => import("./pages/AppUserPrettyIdsPage.jsx").then((module) => module.AppUserPrettyIdsPage),
|
||||
menuCode: MENU_CODES.appUserPrettyIds,
|
||||
pageKey: "app-user-pretty-ids",
|
||||
path: "/app/users/pretty-ids",
|
||||
permission: PERMISSIONS.prettyIdView,
|
||||
},
|
||||
];
|
||||
|
||||
@ -1,19 +1,120 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const appUserUpdateSchema = z.object({
|
||||
avatar: z.string().trim().max(512, "头像不能超过 512 个字符").optional().default(""),
|
||||
gender: z.string().trim().max(32, "性别不能超过 32 个字符").optional().default(""),
|
||||
username: z.string().trim().max(64, "用户名称不能超过 64 个字符").optional().default("")
|
||||
avatar: z.string().trim().max(512, "头像不能超过 512 个字符").optional().default(""),
|
||||
gender: z.string().trim().max(32, "性别不能超过 32 个字符").optional().default(""),
|
||||
username: z.string().trim().max(64, "用户名称不能超过 64 个字符").optional().default(""),
|
||||
});
|
||||
|
||||
export const appUserCountrySchema = z.object({
|
||||
country: z.string().trim().min(2, "请选择国家").max(16, "国家码不能超过 16 个字符")
|
||||
country: z.string().trim().min(2, "请选择国家").max(16, "国家码不能超过 16 个字符"),
|
||||
});
|
||||
|
||||
export const appUserPasswordSchema = z.object({
|
||||
password: z.string().trim().min(6, "密码至少 6 位").max(128, "密码不能超过 128 个字符")
|
||||
password: z.string().trim().min(6, "密码至少 6 位").max(128, "密码不能超过 128 个字符"),
|
||||
});
|
||||
|
||||
// 与服务端后台发放规则保持一致:只允许 Unicode 字母、数字和组合标记,空格、标点、emoji 都在前端先拦截。
|
||||
const prettyContentPattern = /^[\p{L}\p{N}\p{M}]+$/u;
|
||||
const prettyRuleValues = [
|
||||
"aa",
|
||||
"aaa",
|
||||
"aaaa",
|
||||
"aaaaa",
|
||||
"aaaaaa",
|
||||
"aaaaaaa",
|
||||
"aabb",
|
||||
"aabbcc",
|
||||
"aabbccdd",
|
||||
"abab",
|
||||
"ababab",
|
||||
"abba",
|
||||
"abcba",
|
||||
"abccba",
|
||||
"aaab",
|
||||
"aaaab",
|
||||
"aaaaab",
|
||||
"aaabbb",
|
||||
"aaaabbbb",
|
||||
"abc",
|
||||
"abcd",
|
||||
"abcde",
|
||||
"abcdef",
|
||||
"cba",
|
||||
"dcba",
|
||||
"edcba",
|
||||
"fedcba",
|
||||
"abcabc",
|
||||
"date_yyyymmdd",
|
||||
"date_yymmdd",
|
||||
"love",
|
||||
"custom_values",
|
||||
"custom_pattern",
|
||||
] as const;
|
||||
|
||||
// 靓号池只配置等级区间和生成规则,等级上下限在表单层先做顺序校验,后端仍会再次校验权限和状态。
|
||||
export const prettyDisplayIDPoolSchema = z
|
||||
.object({
|
||||
levelTrack: z.enum(["wealth", "game", "charm"], "请选择等级轨道"),
|
||||
maxLevel: z.coerce.number().int("最高等级必须是整数").min(0, "最高等级不能小于 0"),
|
||||
minLevel: z.coerce.number().int("最低等级必须是整数").min(0, "最低等级不能小于 0"),
|
||||
name: z.string().trim().min(1, "请输入靓号池名称").max(128, "靓号池名称不能超过 128 个字符"),
|
||||
ruleConfigJson: z.string().trim().max(4000, "规则配置不能超过 4000 个字符").optional().default(""),
|
||||
ruleType: z.enum(prettyRuleValues, "请选择生成规则"),
|
||||
sortOrder: z.coerce.number().int("排序必须是整数").min(0, "排序不能小于 0").optional().default(0),
|
||||
status: z.enum(["active", "disabled"], "请选择状态"),
|
||||
})
|
||||
.refine((value) => value.maxLevel >= value.minLevel, {
|
||||
message: "最高等级不能小于最低等级",
|
||||
path: ["maxLevel"],
|
||||
});
|
||||
|
||||
// 批量生成支持市面常见靓号模式,单次最多 1000 个,避免一次生成把候选空间和数据库锁时间放大。
|
||||
export const prettyDisplayIDGenerateSchema = z.object({
|
||||
count: z.coerce.number().int("生成数量必须是整数").min(1, "生成数量至少 1 个").max(1000, "单次最多生成 1000 个"),
|
||||
poolId: z.string().trim().min(1, "请选择靓号池"),
|
||||
ruleConfigJson: z.string().trim().max(4000, "规则配置不能超过 4000 个字符").optional().default(""),
|
||||
ruleType: z.enum(prettyRuleValues, "请选择生成规则"),
|
||||
});
|
||||
|
||||
// 后台发放允许管理员输入非数字靓号;durationDays 为空或 0 都会转成 duration_ms=0,表示长期持有。
|
||||
export const prettyDisplayIDGrantSchema = z.object({
|
||||
displayUserId: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "请输入靓号内容")
|
||||
.max(64, "靓号不能超过 64 个字符")
|
||||
.regex(prettyContentPattern, "靓号只支持字母、数字和组合标记"),
|
||||
durationDays: z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.default("")
|
||||
.refine((value) => {
|
||||
if (!value) {
|
||||
return true;
|
||||
}
|
||||
const days = Number(value);
|
||||
return Number.isInteger(days) && days >= 0 && days <= 36500;
|
||||
}, "有效天数必须是 0 到 36500 的整数"),
|
||||
reason: z.string().trim().max(64, "原因不能超过 64 个字符").optional().default(""),
|
||||
targetUserId: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^[1-9]\d*$/, "请输入用户 ID"),
|
||||
});
|
||||
|
||||
// 状态变更只暴露未占用号码的 available/disabled/reserved,assigned 号码必须通过释放或覆盖链路改变。
|
||||
export const prettyDisplayIDStatusSchema = z.object({
|
||||
prettyId: z.string().trim().min(1, "请选择靓号"),
|
||||
reason: z.string().trim().max(64, "原因不能超过 64 个字符").optional().default(""),
|
||||
status: z.enum(["available", "disabled", "reserved"], "请选择状态"),
|
||||
});
|
||||
|
||||
export type AppUserUpdateForm = z.infer<typeof appUserUpdateSchema>;
|
||||
export type AppUserCountryForm = z.infer<typeof appUserCountrySchema>;
|
||||
export type AppUserPasswordForm = z.infer<typeof appUserPasswordSchema>;
|
||||
export type PrettyDisplayIDPoolForm = z.infer<typeof prettyDisplayIDPoolSchema>;
|
||||
export type PrettyDisplayIDGenerateForm = z.infer<typeof prettyDisplayIDGenerateSchema>;
|
||||
export type PrettyDisplayIDGrantForm = z.infer<typeof prettyDisplayIDGrantSchema>;
|
||||
export type PrettyDisplayIDStatusForm = z.infer<typeof prettyDisplayIDStatusSchema>;
|
||||
|
||||
@ -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),
|
||||
},
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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", "发布心情"],
|
||||
];
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 跳转 JSON;action_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);
|
||||
|
||||
@ -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 "归档";
|
||||
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
38
src/features/daily-tasks/taskPayload.test.js
Normal file
38
src/features/daily-tasks/taskPayload.test.js
Normal 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"}');
|
||||
});
|
||||
@ -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),
|
||||
|
||||
@ -1,20 +1,13 @@
|
||||
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||
import SaveOutlined from "@mui/icons-material/SaveOutlined";
|
||||
import Drawer from "@mui/material/Drawer";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { RewardTierEditorTable } from "@/shared/ui/RewardTierEditorTable.jsx";
|
||||
import { ResourceGroupSelectField } from "@/shared/ui/ResourceGroupSelectDrawer.jsx";
|
||||
import styles from "@/features/first-recharge-reward/first-recharge-reward.module.css";
|
||||
|
||||
const statusOptions = [
|
||||
["active", "启用"],
|
||||
["inactive", "停用"],
|
||||
];
|
||||
|
||||
export function FirstRechargeRewardConfigDrawer({
|
||||
abilities,
|
||||
configLoading,
|
||||
@ -35,10 +28,11 @@ export function FirstRechargeRewardConfigDrawer({
|
||||
...(current.tiers || []),
|
||||
{
|
||||
tierId: 0,
|
||||
tierCode: "",
|
||||
tierCode: `first_recharge_${Date.now()}_${(current.tiers || []).length + 1}`,
|
||||
tierName: "",
|
||||
minCoinAmount: "",
|
||||
maxCoinAmount: "",
|
||||
usdAmount: "",
|
||||
googleProductId: "",
|
||||
discountPercent: "70",
|
||||
resourceGroupId: "",
|
||||
status: "active",
|
||||
sortOrder: String((current.tiers || []).length),
|
||||
@ -63,110 +57,126 @@ export function FirstRechargeRewardConfigDrawer({
|
||||
|
||||
return (
|
||||
<Drawer anchor="right" open={open} onClose={configSaving ? undefined : onClose}>
|
||||
<form className="form-drawer" onSubmit={onSubmit}>
|
||||
<h2>首冲奖励配置</h2>
|
||||
<section className="form-drawer__section">
|
||||
<div className="form-drawer__section-title">发放状态</div>
|
||||
<AdminSwitch
|
||||
checked={form.enabled}
|
||||
checkedLabel="开启"
|
||||
disabled={disabled}
|
||||
label="首冲奖励状态"
|
||||
uncheckedLabel="关闭"
|
||||
onChange={(event) => setForm((current) => ({ ...current, enabled: event.target.checked }))}
|
||||
/>
|
||||
</section>
|
||||
<section className="form-drawer__section">
|
||||
<div className={styles.drawerSectionTitle}>
|
||||
<span>奖励档位</span>
|
||||
<Button
|
||||
<form className="form-drawer form-drawer--activity-config" onSubmit={onSubmit}>
|
||||
<h2>首充礼包配置</h2>
|
||||
<div className="form-drawer__content">
|
||||
<section className="form-drawer__section">
|
||||
<div className="form-drawer__section-title">发放状态</div>
|
||||
<AdminSwitch
|
||||
checked={form.enabled}
|
||||
checkedLabel="开启"
|
||||
disabled={disabled}
|
||||
startIcon={<AddOutlined fontSize="small" />}
|
||||
type="button"
|
||||
onClick={addTier}
|
||||
>
|
||||
添加档位
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.tierEditorList}>
|
||||
{(form.tiers || []).map((tier, index) => (
|
||||
<div className={styles.tierEditor} key={`${tier.tierId || "new"}-${index}`}>
|
||||
<div className={styles.tierEditorHeader}>
|
||||
<span>档位 {index + 1}</span>
|
||||
<IconButton
|
||||
aria-label="删除档位"
|
||||
disabled={disabled}
|
||||
size="small"
|
||||
onClick={() => removeTier(index)}
|
||||
>
|
||||
<DeleteOutlineOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="form-drawer__grid">
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="档位编码"
|
||||
value={tier.tierCode}
|
||||
onChange={(event) => updateTier(index, { tierCode: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="档位名称"
|
||||
value={tier.tierName}
|
||||
onChange={(event) => updateTier(index, { tierName: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
inputProps={{ min: 1 }}
|
||||
label="最小金币"
|
||||
type="number"
|
||||
value={tier.minCoinAmount}
|
||||
onChange={(event) => updateTier(index, { minCoinAmount: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
inputProps={{ min: 0 }}
|
||||
label="最大金币"
|
||||
placeholder="0 表示无上限"
|
||||
type="number"
|
||||
value={tier.maxCoinAmount}
|
||||
onChange={(event) => updateTier(index, { maxCoinAmount: event.target.value })}
|
||||
/>
|
||||
<ResourceGroupSelectField
|
||||
disabled={disabled}
|
||||
drawerTitle="选择首冲奖励资源组"
|
||||
groups={resourceGroups}
|
||||
label="奖励资源组"
|
||||
value={tier.resourceGroupId}
|
||||
onChange={(value) => updateTier(index, { resourceGroupId: value })}
|
||||
/>
|
||||
<TextField
|
||||
select
|
||||
disabled={disabled}
|
||||
label="状态"
|
||||
value={tier.status}
|
||||
onChange={(event) => updateTier(index, { status: event.target.value })}
|
||||
>
|
||||
{statusOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
inputProps={{ min: 0 }}
|
||||
label="排序"
|
||||
type="number"
|
||||
value={tier.sortOrder}
|
||||
onChange={(event) => updateTier(index, { sortOrder: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!form.tiers?.length ? <div className={styles.emptyState}>暂无档位</div> : null}
|
||||
</div>
|
||||
</section>
|
||||
label="首充礼包状态"
|
||||
uncheckedLabel="关闭"
|
||||
onChange={(event) => setForm((current) => ({ ...current, enabled: event.target.checked }))}
|
||||
/>
|
||||
</section>
|
||||
<section className="form-drawer__section">
|
||||
<div className={styles.drawerSectionTitle}>
|
||||
<span>奖励档位</span>
|
||||
<Button
|
||||
disabled={disabled}
|
||||
startIcon={<AddOutlined fontSize="small" />}
|
||||
type="button"
|
||||
onClick={addTier}
|
||||
>
|
||||
添加档位
|
||||
</Button>
|
||||
</div>
|
||||
<RewardTierEditorTable
|
||||
columns={[
|
||||
{
|
||||
key: "tier",
|
||||
label: "档位",
|
||||
render: (_tier, index) => (
|
||||
<span className="reward-tier-editor-table__index">档位 {index + 1}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "usdAmount",
|
||||
label: "USD",
|
||||
render: (tier, index) => (
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
inputProps={{ "aria-label": "美元档位", min: 0.01, step: 0.01 }}
|
||||
placeholder="0.99"
|
||||
type="number"
|
||||
value={tier.usdAmount}
|
||||
onChange={(event) => updateTier(index, { usdAmount: event.target.value })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "googleProductId",
|
||||
label: "Google 商品 ID",
|
||||
render: (tier, index) => (
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
inputProps={{ "aria-label": "Google 商品 ID" }}
|
||||
placeholder="first_recharge_google_099"
|
||||
value={tier.googleProductId}
|
||||
onChange={(event) =>
|
||||
updateTier(index, { googleProductId: event.target.value })
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "resourceGroupId",
|
||||
label: "奖励资源组",
|
||||
render: (tier, index) => (
|
||||
<ResourceGroupSelectField
|
||||
disabled={disabled}
|
||||
drawerTitle="选择首充礼包资源组"
|
||||
groups={resourceGroups}
|
||||
label="奖励资源组"
|
||||
value={tier.resourceGroupId}
|
||||
onChange={(value) => updateTier(index, { resourceGroupId: value })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "discountPercent",
|
||||
label: "优惠%",
|
||||
render: (tier, index) => (
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
inputProps={{ "aria-label": "优惠百分比", min: 0, max: 100, step: 1 }}
|
||||
type="number"
|
||||
value={tier.discountPercent}
|
||||
onChange={(event) =>
|
||||
updateTier(index, { discountPercent: event.target.value })
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
render: (tier, index) => (
|
||||
<AdminSwitch
|
||||
checked={tier.status !== "inactive"}
|
||||
checkedLabel="启用"
|
||||
disabled={disabled}
|
||||
label={`档位 ${index + 1} 状态`}
|
||||
uncheckedLabel="停用"
|
||||
onChange={(event) =>
|
||||
updateTier(index, {
|
||||
status: event.target.checked ? "active" : "inactive",
|
||||
})
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
disabled={disabled}
|
||||
emptyText="暂无档位"
|
||||
gridTemplateColumns="72px minmax(110px, 0.6fr) minmax(230px, 1.2fr) minmax(260px, 1.4fr) 92px 120px 52px"
|
||||
rows={form.tiers || []}
|
||||
onRemoveTier={removeTier}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
<div className="form-drawer__actions">
|
||||
<Button disabled={configSaving} type="button" onClick={onClose}>
|
||||
取消
|
||||
|
||||
@ -36,7 +36,7 @@ export function useFirstRechargeRewardPage() {
|
||||
loading: claimsLoading,
|
||||
reload: reloadClaims,
|
||||
} = usePaginatedQuery({
|
||||
errorMessage: "加载首冲奖励记录失败",
|
||||
errorMessage: "加载首充礼包记录失败",
|
||||
fetcher: listFirstRechargeRewardClaims,
|
||||
filters,
|
||||
page,
|
||||
@ -55,7 +55,7 @@ export function useFirstRechargeRewardPage() {
|
||||
setForm(formFromConfig(config));
|
||||
setResourceGroups(groups.items || []);
|
||||
} catch (err) {
|
||||
showToast(err.message || "加载首冲奖励配置失败", "error");
|
||||
showToast(err.message || "加载首充礼包配置失败", "error");
|
||||
} finally {
|
||||
setConfigLoading(false);
|
||||
}
|
||||
@ -96,10 +96,10 @@ export function useFirstRechargeRewardPage() {
|
||||
setConfig(saved);
|
||||
setForm(formFromConfig(saved));
|
||||
setConfigDrawerOpen(false);
|
||||
showToast("首冲奖励配置已保存", "success");
|
||||
showToast("首充礼包配置已保存", "success");
|
||||
await reloadClaims();
|
||||
} catch (err) {
|
||||
showToast(err.message || "保存首冲奖励配置失败", "error");
|
||||
showToast(err.message || "保存首充礼包配置失败", "error");
|
||||
} finally {
|
||||
setConfigSaving(false);
|
||||
}
|
||||
@ -138,8 +138,9 @@ function formFromConfig(config) {
|
||||
tierId: tier.tierId || 0,
|
||||
tierCode: tier.tierCode || "",
|
||||
tierName: tier.tierName || "",
|
||||
minCoinAmount: tier.minCoinAmount ? String(tier.minCoinAmount) : "",
|
||||
maxCoinAmount: tier.maxCoinAmount ? String(tier.maxCoinAmount) : "",
|
||||
usdAmount: tier.usdMinorAmount ? (tier.usdMinorAmount / 100).toFixed(2) : "",
|
||||
googleProductId: tier.googleProductId || "",
|
||||
discountPercent: String(tier.discountPercent || 0),
|
||||
resourceGroupId: tier.resourceGroupId ? String(tier.resourceGroupId) : "",
|
||||
status: tier.status || "active",
|
||||
sortOrder: String(tier.sortOrder || 0),
|
||||
@ -148,31 +149,40 @@ function formFromConfig(config) {
|
||||
}
|
||||
|
||||
function payloadFromForm(form) {
|
||||
const seenGoogleProductIds = new Set();
|
||||
const tiers = (form.tiers || []).map((tier, index) => {
|
||||
const minCoinAmount = Number(tier.minCoinAmount || 0);
|
||||
const maxCoinAmount = Number(tier.maxCoinAmount || 0);
|
||||
const usdMinorAmount = Math.round(Number(tier.usdAmount || 0) * 100);
|
||||
const googleProductId = String(tier.googleProductId || "").trim();
|
||||
const discountPercent = Number(tier.discountPercent || 0);
|
||||
const resourceGroupId = Number(tier.resourceGroupId || 0);
|
||||
if (!tier.tierCode.trim()) {
|
||||
throw new Error("档位编码不能为空");
|
||||
if (usdMinorAmount <= 0) {
|
||||
throw new Error("美元档位必须大于 0");
|
||||
}
|
||||
if (!tier.tierName.trim()) {
|
||||
throw new Error("档位名称不能为空");
|
||||
if (!googleProductId) {
|
||||
throw new Error("Google 商品 ID 不能为空");
|
||||
}
|
||||
if (minCoinAmount <= 0) {
|
||||
throw new Error("最小金币必须大于 0");
|
||||
const normalizedGoogleProductId = googleProductId.toLowerCase();
|
||||
if (seenGoogleProductIds.has(normalizedGoogleProductId)) {
|
||||
throw new Error("Google 商品 ID 不能重复");
|
||||
}
|
||||
if (maxCoinAmount > 0 && maxCoinAmount < minCoinAmount) {
|
||||
throw new Error("最大金币不能小于最小金币");
|
||||
seenGoogleProductIds.add(normalizedGoogleProductId);
|
||||
if (discountPercent < 0 || discountPercent > 100) {
|
||||
throw new Error("优惠百分比必须在 0 到 100 之间");
|
||||
}
|
||||
if (resourceGroupId <= 0) {
|
||||
throw new Error("请选择资源组");
|
||||
}
|
||||
const tierCode = String(tier.tierCode || "").trim() || `first_recharge_${index + 1}_${usdMinorAmount}`;
|
||||
const tierName = String(tier.tierName || "").trim() || `$${(usdMinorAmount / 100).toFixed(2)}`;
|
||||
return {
|
||||
tier_id: Number(tier.tierId || 0),
|
||||
tier_code: tier.tierCode.trim(),
|
||||
tier_name: tier.tierName.trim(),
|
||||
min_coin_amount: minCoinAmount,
|
||||
max_coin_amount: maxCoinAmount,
|
||||
tier_code: tierCode,
|
||||
tier_name: tierName,
|
||||
min_coin_amount: 0,
|
||||
max_coin_amount: 0,
|
||||
usd_minor_amount: usdMinorAmount,
|
||||
google_product_id: googleProductId,
|
||||
discount_percent: discountPercent,
|
||||
resource_group_id: resourceGroupId,
|
||||
status: tier.status === "inactive" ? "inactive" : "active",
|
||||
sort_order: Number(tier.sortOrder || index),
|
||||
|
||||
@ -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)}`;
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
|
||||
|
||||
export const firstRechargeRewardRoutes = [
|
||||
{
|
||||
label: "首冲奖励",
|
||||
label: "首充礼包",
|
||||
loader: () => import("./pages/FirstRechargeRewardPage.jsx").then((module) => module.FirstRechargeRewardPage),
|
||||
menuCode: MENU_CODES.firstRechargeReward,
|
||||
pageKey: "first-recharge-reward",
|
||||
|
||||
@ -74,6 +74,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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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>
|
||||
|
||||
292
src/features/games/pages/GameRobotPage.jsx
Normal file
292
src/features/games/pages/GameRobotPage.jsx
Normal 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() : "?";
|
||||
}
|
||||
62
src/features/games/pages/GameRobotPage.test.jsx
Normal file
62
src/features/games/pages/GameRobotPage.test.jsx
Normal file
@ -0,0 +1,62 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import { AppProviders } from "@/app/providers.jsx";
|
||||
import { GameRobotPage } from "@/features/games/pages/GameRobotPage.jsx";
|
||||
|
||||
vi.mock("@/app/auth/AuthProvider.jsx", () => ({
|
||||
AuthProvider: ({ children }) => children,
|
||||
useAuth: () => ({ can: () => true }),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => jsonResponse({ items: [] })),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("renders game robot page without crashing", async () => {
|
||||
render(
|
||||
<AppProviders>
|
||||
<MemoryRouter>
|
||||
<GameRobotPage />
|
||||
</MemoryRouter>
|
||||
</AppProviders>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("机器人用户")).toBeInTheDocument());
|
||||
});
|
||||
|
||||
test("shows nickname language selector when generating robots", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AppProviders>
|
||||
<MemoryRouter>
|
||||
<GameRobotPage />
|
||||
</MemoryRouter>
|
||||
</AppProviders>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("机器人用户")).toBeInTheDocument());
|
||||
await user.click(screen.getByRole("button", { name: "批量创建" }));
|
||||
|
||||
expect(screen.getByLabelText("昵称语言")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("combobox", { name: "昵称语言" }));
|
||||
expect(screen.getByRole("option", { name: "阿拉伯语名称" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "英语名称" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
function jsonResponse(body) {
|
||||
return {
|
||||
headers: new Headers({ "content-type": "application/json" }),
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => JSON.stringify(body),
|
||||
};
|
||||
}
|
||||
357
src/features/games/pages/RoomRpsChallengePage.jsx
Normal file
357
src/features/games/pages/RoomRpsChallengePage.jsx
Normal 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");
|
||||
}
|
||||
325
src/features/games/pages/RoomRpsConfigPage.jsx
Normal file
325
src/features/games/pages/RoomRpsConfigPage.jsx
Normal 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`;
|
||||
}
|
||||
127
src/features/games/pages/RoomRpsConfigPage.test.jsx
Normal file
127
src/features/games/pages/RoomRpsConfigPage.test.jsx
Normal 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 });
|
||||
}
|
||||
354
src/features/games/pages/SelfGamePage.jsx
Normal file
354
src/features/games/pages/SelfGamePage.jsx
Normal file
@ -0,0 +1,354 @@
|
||||
import AddCircleOutlineOutlined from "@mui/icons-material/AddCircleOutlineOutlined";
|
||||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { PERMISSIONS } from "@/app/permissions";
|
||||
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||
import { adjustDicePool, listSelfGames, updateDiceConfig } from "@/features/games/api";
|
||||
import styles from "@/features/games/games.module.css";
|
||||
import {
|
||||
AdminActionIconButton,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminListToolbar,
|
||||
AdminRowActions,
|
||||
} from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { AdminFormDialog, AdminFormFieldGrid, AdminFormSection, AdminFormSwitchField } from "@/shared/ui/AdminFormDialog.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { PageSkeleton } from "@/shared/ui/PageSkeleton.jsx";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
|
||||
const defaultConfigForm = {
|
||||
status: "active",
|
||||
stakeOptionsText: "100,500,1000,8000",
|
||||
feeBps: 500,
|
||||
poolBps: 100,
|
||||
minPlayers: 2,
|
||||
maxPlayers: 2,
|
||||
robotEnabled: true,
|
||||
robotMatchWaitMs: 3000,
|
||||
};
|
||||
|
||||
const defaultPoolForm = {
|
||||
amountCoin: "",
|
||||
direction: "in",
|
||||
reason: "admin_adjust",
|
||||
};
|
||||
|
||||
export function SelfGamePage() {
|
||||
const { can } = useAuth();
|
||||
const { showToast } = useToast();
|
||||
const canUpdate = can(PERMISSIONS.gameUpdate);
|
||||
const [items, setItems] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
const [activeAction, setActiveAction] = useState("");
|
||||
const [activeGame, setActiveGame] = useState(null);
|
||||
const [configForm, setConfigForm] = useState(defaultConfigForm);
|
||||
const [poolForm, setPoolForm] = useState(defaultPoolForm);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await listSelfGames();
|
||||
setItems(result.items || []);
|
||||
} catch (err) {
|
||||
showToast(err.message || "加载自研游戏失败", "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: "game",
|
||||
label: "游戏",
|
||||
width: "minmax(180px, 1.2fr)",
|
||||
render: (game) => (
|
||||
<div className={styles.identity}>
|
||||
<span className={styles.name}>{game.gameId === "dice" ? "骰子" : game.gameId}</span>
|
||||
<span className={styles.meta}>{game.gameId}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "stakes",
|
||||
label: "下注档位",
|
||||
width: "minmax(220px, 1.4fr)",
|
||||
render: (game) => game.stakeOptions.map((item) => formatNumber(item.stakeCoin)).join(" / "),
|
||||
},
|
||||
{
|
||||
key: "fee",
|
||||
label: "费率",
|
||||
width: "minmax(140px, .8fr)",
|
||||
render: (game) => `${bpsPercent(game.feeBps)} / 入池 ${bpsPercent(game.poolBps)}`,
|
||||
},
|
||||
{
|
||||
key: "pool",
|
||||
label: "奖池",
|
||||
width: "minmax(120px, .8fr)",
|
||||
render: (game) => formatNumber(game.poolBalanceCoin),
|
||||
},
|
||||
{
|
||||
key: "robot",
|
||||
label: "机器人",
|
||||
width: "minmax(150px, .8fr)",
|
||||
render: (game) => `${game.robotEnabled ? "启用" : "关闭"} · ${game.robotMatchWaitMs}ms`,
|
||||
},
|
||||
{
|
||||
key: "updated",
|
||||
label: "更新时间",
|
||||
width: "minmax(170px, 1fr)",
|
||||
render: (game) => (game.updatedAtMs ? formatMillis(game.updatedAtMs) : "-"),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
width: "128px",
|
||||
render: (game) => (
|
||||
<AdminRowActions>
|
||||
<AdminActionIconButton disabled={!canUpdate} label="配置骰子" onClick={() => openConfig(game)}>
|
||||
<SettingsOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
<AdminActionIconButton disabled={!canUpdate} label="调整奖池" onClick={() => openPool(game)}>
|
||||
<AddCircleOutlineOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
</AdminRowActions>
|
||||
),
|
||||
},
|
||||
],
|
||||
[canUpdate],
|
||||
);
|
||||
|
||||
const openConfig = (game) => {
|
||||
setActiveGame(game);
|
||||
setConfigForm(configToForm(game));
|
||||
setActiveAction("config");
|
||||
};
|
||||
|
||||
const openPool = (game) => {
|
||||
setActiveGame(game);
|
||||
setPoolForm(defaultPoolForm);
|
||||
setActiveAction("pool");
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
setActiveAction("");
|
||||
setActiveGame(null);
|
||||
};
|
||||
|
||||
const submitConfig = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!activeGame) return;
|
||||
setLoadingAction("config");
|
||||
try {
|
||||
await updateDiceConfig(activeGame.gameId, configPayload(configForm, activeGame.gameId));
|
||||
await load();
|
||||
showToast("骰子配置已保存", "success");
|
||||
closeDialog();
|
||||
} catch (err) {
|
||||
showToast(err.message || "保存骰子配置失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
const submitPool = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!activeGame) return;
|
||||
setLoadingAction("pool");
|
||||
try {
|
||||
await adjustDicePool(activeGame.gameId, {
|
||||
amountCoin: Number(poolForm.amountCoin || 0),
|
||||
direction: poolForm.direction,
|
||||
reason: poolForm.reason,
|
||||
});
|
||||
await load();
|
||||
showToast("奖池已调整", "success");
|
||||
closeDialog();
|
||||
} catch (err) {
|
||||
showToast(err.message || "调整奖池失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
actions={
|
||||
<AdminActionIconButton label="刷新" onClick={load}>
|
||||
<RefreshOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
}
|
||||
/>
|
||||
<AdminListBody>
|
||||
<DataTable columns={columns} items={items} minWidth="980px" rowKey={(game) => game.gameId} />
|
||||
</AdminListBody>
|
||||
<ConfigDialog
|
||||
form={configForm}
|
||||
loading={loadingAction === "config"}
|
||||
open={activeAction === "config"}
|
||||
setForm={setConfigForm}
|
||||
onClose={closeDialog}
|
||||
onSubmit={submitConfig}
|
||||
/>
|
||||
<PoolDialog
|
||||
form={poolForm}
|
||||
loading={loadingAction === "pool"}
|
||||
open={activeAction === "pool"}
|
||||
setForm={setPoolForm}
|
||||
onClose={closeDialog}
|
||||
onSubmit={submitPool}
|
||||
/>
|
||||
</AdminListPage>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigDialog({ form, loading, open, setForm, onClose, onSubmit }) {
|
||||
return (
|
||||
<AdminFormDialog loading={loading} open={open} submitLabel="保存配置" title="骰子配置" onClose={onClose} onSubmit={onSubmit}>
|
||||
<AdminFormSection title="下注与费率">
|
||||
<AdminFormFieldGrid>
|
||||
<TextField
|
||||
label="下注档位"
|
||||
value={form.stakeOptionsText}
|
||||
onChange={(event) => setForm({ ...form, stakeOptionsText: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="平台抽水 bps"
|
||||
type="number"
|
||||
value={form.feeBps}
|
||||
onChange={(event) => setForm({ ...form, feeBps: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="入池 bps"
|
||||
type="number"
|
||||
value={form.poolBps}
|
||||
onChange={(event) => setForm({ ...form, poolBps: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="状态"
|
||||
select
|
||||
value={form.status}
|
||||
onChange={(event) => setForm({ ...form, status: event.target.value })}
|
||||
>
|
||||
<MenuItem value="active">启用</MenuItem>
|
||||
<MenuItem value="disabled">停用</MenuItem>
|
||||
</TextField>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="匹配">
|
||||
<AdminFormFieldGrid>
|
||||
<TextField
|
||||
label="最少人数"
|
||||
type="number"
|
||||
value={form.minPlayers}
|
||||
onChange={(event) => setForm({ ...form, minPlayers: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="最多人数"
|
||||
type="number"
|
||||
value={form.maxPlayers}
|
||||
onChange={(event) => setForm({ ...form, maxPlayers: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="机器人等待 ms"
|
||||
type="number"
|
||||
value={form.robotMatchWaitMs}
|
||||
onChange={(event) => setForm({ ...form, robotMatchWaitMs: event.target.value })}
|
||||
/>
|
||||
<AdminFormSwitchField
|
||||
checked={form.robotEnabled}
|
||||
label="机器人补位"
|
||||
onChange={(event) => setForm({ ...form, robotEnabled: event.target.checked })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
</AdminFormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function PoolDialog({ form, loading, open, setForm, onClose, onSubmit }) {
|
||||
return (
|
||||
<AdminFormDialog loading={loading} open={open} submitLabel="确认调整" title="调整奖池" onClose={onClose} onSubmit={onSubmit}>
|
||||
<AdminFormFieldGrid>
|
||||
<TextField
|
||||
label="方向"
|
||||
select
|
||||
value={form.direction}
|
||||
onChange={(event) => setForm({ ...form, direction: event.target.value })}
|
||||
>
|
||||
<MenuItem value="in">增加</MenuItem>
|
||||
<MenuItem value="out">减少</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
label="金币"
|
||||
type="number"
|
||||
value={form.amountCoin}
|
||||
onChange={(event) => setForm({ ...form, amountCoin: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="原因"
|
||||
value={form.reason}
|
||||
onChange={(event) => setForm({ ...form, reason: event.target.value })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function configToForm(game) {
|
||||
return {
|
||||
status: game.status || "active",
|
||||
stakeOptionsText: (game.stakeOptions || []).map((item) => item.stakeCoin).join(","),
|
||||
feeBps: game.feeBps,
|
||||
poolBps: game.poolBps,
|
||||
minPlayers: game.minPlayers,
|
||||
maxPlayers: game.maxPlayers,
|
||||
robotEnabled: game.robotEnabled,
|
||||
robotMatchWaitMs: game.robotMatchWaitMs,
|
||||
};
|
||||
}
|
||||
|
||||
function configPayload(form, gameId) {
|
||||
return {
|
||||
gameId,
|
||||
status: form.status,
|
||||
stakeOptions: parseStakeOptions(form.stakeOptionsText),
|
||||
feeBps: Number(form.feeBps || 0),
|
||||
poolBps: Number(form.poolBps || 0),
|
||||
minPlayers: Number(form.minPlayers || 2),
|
||||
maxPlayers: Number(form.maxPlayers || 2),
|
||||
robotEnabled: Boolean(form.robotEnabled),
|
||||
robotMatchWaitMs: Number(form.robotMatchWaitMs || 3000),
|
||||
};
|
||||
}
|
||||
|
||||
function parseStakeOptions(text) {
|
||||
return String(text || "")
|
||||
.split(/[,,\s]+/)
|
||||
.map((value) => Number(value))
|
||||
.filter((value) => Number.isFinite(value) && value > 0)
|
||||
.map((stakeCoin, index) => ({ stakeCoin, enabled: true, sortOrder: (index + 1) * 10 }));
|
||||
}
|
||||
|
||||
function bpsPercent(value) {
|
||||
return `${(Number(value || 0) / 100).toFixed(2)}%`;
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return new Intl.NumberFormat("en-US").format(Number(value || 0));
|
||||
}
|
||||
@ -9,4 +9,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,
|
||||
},
|
||||
];
|
||||
|
||||
@ -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),
|
||||
};
|
||||
|
||||
@ -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,
|
||||
];
|
||||
// 默认值也纳入 hasFilters,这样运营能一键回到“BD + 自动政策 + 上月周期”的标准入口。
|
||||
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 ||
|
||||
"-"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 }) {
|
||||
|
||||
@ -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}
|
||||
|
||||
186
src/features/invite-activity-reward/api.ts
Normal file
186
src/features/invite-activity-reward/api.ts
Normal 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;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
11
src/features/invite-activity-reward/permissions.js
Normal file
11
src/features/invite-activity-reward/permissions.js
Normal 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),
|
||||
};
|
||||
}
|
||||
12
src/features/invite-activity-reward/routes.js
Normal file
12
src/features/invite-activity-reward/routes.js
Normal 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,
|
||||
},
|
||||
];
|
||||
@ -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) {
|
||||
|
||||
@ -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 || "-";
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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 = [] }) {
|
||||
|
||||
@ -2,11 +2,14 @@ import { apiRequest } from "@/shared/api/request";
|
||||
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||
import type {
|
||||
ApiPage,
|
||||
ApiList,
|
||||
EntityId,
|
||||
PageQuery,
|
||||
RechargeBillDto,
|
||||
RechargeProductDto,
|
||||
RechargeProductPayload,
|
||||
ThirdPartyPaymentChannelDto,
|
||||
ThirdPartyPaymentMethodDto,
|
||||
} from "@/shared/api/types";
|
||||
|
||||
export function listRechargeBills(query: PageQuery = {}): Promise<ApiPage<RechargeBillDto>> {
|
||||
@ -56,3 +59,41 @@ export function deleteRechargeProduct(productId: EntityId): Promise<unknown> {
|
||||
method: endpoint.method,
|
||||
});
|
||||
}
|
||||
|
||||
export function listThirdPartyPaymentChannels(): Promise<ApiList<ThirdPartyPaymentChannelDto>> {
|
||||
const endpoint = API_ENDPOINTS.listThirdPartyPaymentChannels;
|
||||
return apiRequest<ApiList<ThirdPartyPaymentChannelDto>>(
|
||||
apiEndpointPath(API_OPERATIONS.listThirdPartyPaymentChannels),
|
||||
{
|
||||
method: endpoint.method,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function setThirdPartyPaymentMethodStatus(
|
||||
methodId: EntityId,
|
||||
payload: { enabled: boolean },
|
||||
): Promise<ThirdPartyPaymentMethodDto> {
|
||||
const endpoint = API_ENDPOINTS.setThirdPartyPaymentMethodStatus;
|
||||
return apiRequest<ThirdPartyPaymentMethodDto, { enabled: boolean }>(
|
||||
apiEndpointPath(API_OPERATIONS.setThirdPartyPaymentMethodStatus, { method_id: methodId }),
|
||||
{
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function updateThirdPartyPaymentRate(
|
||||
methodId: EntityId,
|
||||
payload: { usdToCurrencyRate: string },
|
||||
): Promise<ThirdPartyPaymentMethodDto> {
|
||||
const endpoint = API_ENDPOINTS.updateThirdPartyPaymentRate;
|
||||
return apiRequest<ThirdPartyPaymentMethodDto, { usdToCurrencyRate: string }>(
|
||||
apiEndpointPath(API_OPERATIONS.updateThirdPartyPaymentRate, { method_id: methodId }),
|
||||
{
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -15,12 +15,14 @@ import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
|
||||
const pageSize = 50;
|
||||
|
||||
// 新建默认走 H5 web 档位和普通用户档位;币商档位必须由运营显式选择,避免误配商品受众。
|
||||
const emptyForm = () => ({
|
||||
amountUsdt: "",
|
||||
audienceType: "normal",
|
||||
coinAmount: "",
|
||||
description: "",
|
||||
enabled: true,
|
||||
platform: "android",
|
||||
platform: "web",
|
||||
productName: "",
|
||||
regionIds: [],
|
||||
});
|
||||
@ -33,6 +35,7 @@ export function useRechargeProductsPage() {
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [platform, setPlatform] = useState("");
|
||||
const [audienceType, setAudienceType] = useState("");
|
||||
const [regionId, setRegionId] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [activeAction, setActiveAction] = useState("");
|
||||
@ -40,14 +43,16 @@ export function useRechargeProductsPage() {
|
||||
const [form, setFormState] = useState(emptyForm);
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
|
||||
// audienceType 直接透传给 admin-server,后台再转 wallet-service 过滤 normal / coin_seller 档位。
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
audienceType,
|
||||
keyword,
|
||||
platform,
|
||||
regionId,
|
||||
status,
|
||||
}),
|
||||
[keyword, platform, regionId, status],
|
||||
[audienceType, keyword, platform, regionId, status],
|
||||
);
|
||||
const result = usePaginatedQuery({
|
||||
errorMessage: "加载内购配置失败",
|
||||
@ -88,6 +93,7 @@ export function useRechargeProductsPage() {
|
||||
setKeyword("");
|
||||
setStatus("");
|
||||
setPlatform("");
|
||||
setAudienceType("");
|
||||
setRegionId("");
|
||||
};
|
||||
|
||||
@ -107,12 +113,36 @@ export function useRechargeProductsPage() {
|
||||
if (editingItem) {
|
||||
await updateRechargeProduct(editingItem.productId, payload);
|
||||
showToast("内购配置已更新", "success");
|
||||
closeDialog();
|
||||
await result.reload();
|
||||
} else {
|
||||
await createRechargeProduct(payload);
|
||||
const createdProduct = await createRechargeProduct(payload);
|
||||
const nextFilters = visibleFiltersForCreatedProduct(createdProduct, payload);
|
||||
const canReloadCurrentQuery =
|
||||
page === 1 &&
|
||||
keyword === nextFilters.keyword &&
|
||||
status === nextFilters.status &&
|
||||
platform === nextFilters.platform &&
|
||||
audienceType === nextFilters.audienceType &&
|
||||
regionId === nextFilters.regionId;
|
||||
|
||||
showToast("内购配置已新增", "success");
|
||||
closeDialog();
|
||||
|
||||
// 新增币商档位时,当前列表可能还停在“普通用户”过滤条件;这里主动切到新商品所属的受众、
|
||||
// 平台和上下架状态,并清空搜索/区域过滤,保证运营提交后能立刻看到刚创建的记录。
|
||||
setKeyword(nextFilters.keyword);
|
||||
setStatus(nextFilters.status);
|
||||
setPlatform(nextFilters.platform);
|
||||
setAudienceType(nextFilters.audienceType);
|
||||
setRegionId(nextFilters.regionId);
|
||||
setPage(1);
|
||||
|
||||
// 如果当前查询条件已经正好能展示新商品,直接刷新当前页;否则交给上面的过滤条件变更触发新查询。
|
||||
if (canReloadCurrentQuery) {
|
||||
await result.reload();
|
||||
}
|
||||
}
|
||||
closeDialog();
|
||||
await result.reload();
|
||||
} catch (err) {
|
||||
showToast(err.message || "操作失败", "error");
|
||||
} finally {
|
||||
@ -158,6 +188,7 @@ export function useRechargeProductsPage() {
|
||||
return {
|
||||
abilities,
|
||||
activeAction,
|
||||
audienceType,
|
||||
closeDialog,
|
||||
data: result.data,
|
||||
editingItem,
|
||||
@ -178,6 +209,7 @@ export function useRechargeProductsPage() {
|
||||
removeProduct,
|
||||
resetFilters,
|
||||
setForm,
|
||||
setAudienceType,
|
||||
setKeyword,
|
||||
setPage,
|
||||
setPlatform,
|
||||
@ -193,35 +225,60 @@ function normalizeForm(form) {
|
||||
return {
|
||||
...form,
|
||||
amountUsdt: form.amountUsdt || "",
|
||||
audienceType: form.audienceType || "normal",
|
||||
coinAmount: form.coinAmount || "",
|
||||
regionIds: form.regionIds || [],
|
||||
};
|
||||
}
|
||||
|
||||
function formFromProduct(item) {
|
||||
// 后端老数据可能没有 audienceType;前端按普通用户兜底,保证旧档位不会被误认为币商档位。
|
||||
return {
|
||||
amountUsdt: item.amountUsdt || "",
|
||||
audienceType: item.audienceType === "coin_seller" ? "coin_seller" : "normal",
|
||||
coinAmount: item.coinAmount === 0 || item.coinAmount ? String(item.coinAmount) : "",
|
||||
description: item.description || "",
|
||||
enabled: item.enabled ?? item.status === "active",
|
||||
platform: item.platform || "android",
|
||||
platform: item.platform || "web",
|
||||
productName: item.productName || "",
|
||||
regionIds: (item.regionIds || []).map(String),
|
||||
};
|
||||
}
|
||||
|
||||
function productPayloadFromItem(item, enabled) {
|
||||
// 行内上下架只改变 enabled,其他字段必须按订单快照原样回传,避免一次快捷开关丢失受众或平台。
|
||||
return {
|
||||
amountUsdt: item.amountUsdt || formatMicroAmount(item.amountUsdtMicro),
|
||||
audienceType: item.audienceType === "coin_seller" ? "coin_seller" : "normal",
|
||||
coinAmount: item.coinAmount,
|
||||
description: item.description || "",
|
||||
enabled,
|
||||
platform: item.platform === "ios" ? "ios" : "android",
|
||||
platform: item.platform === "android" || item.platform === "ios" ? item.platform : "web",
|
||||
productName: item.productName || "",
|
||||
regionIds: item.regionIds || [],
|
||||
};
|
||||
}
|
||||
|
||||
function visibleFiltersForCreatedProduct(product, payload) {
|
||||
const enabled = product?.enabled ?? payload.enabled;
|
||||
const audienceType =
|
||||
product?.audienceType === "coin_seller" || payload.audienceType === "coin_seller" ? "coin_seller" : "normal";
|
||||
return {
|
||||
audienceType,
|
||||
keyword: "",
|
||||
platform: normalizeProductPlatform(product?.platform || payload.platform),
|
||||
regionId: "",
|
||||
status: product?.status === "disabled" || enabled === false ? "disabled" : "active",
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProductPlatform(value) {
|
||||
if (value === "android" || value === "ios" || value === "web") {
|
||||
return value;
|
||||
}
|
||||
return "web";
|
||||
}
|
||||
|
||||
function formatMicroAmount(value) {
|
||||
const numberValue = Number(value || 0);
|
||||
if (!Number.isFinite(numberValue) || numberValue <= 0) {
|
||||
|
||||
124
src/features/payment/hooks/useThirdPartyPaymentPage.js
Normal file
124
src/features/payment/hooks/useThirdPartyPaymentPage.js
Normal file
@ -0,0 +1,124 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
listThirdPartyPaymentChannels,
|
||||
setThirdPartyPaymentMethodStatus,
|
||||
updateThirdPartyPaymentRate,
|
||||
} from "@/features/payment/api";
|
||||
import { usePaymentAbilities } from "@/features/payment/permissions.js";
|
||||
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
|
||||
const emptyChannels = { items: [], total: 0 };
|
||||
|
||||
export function useThirdPartyPaymentPage() {
|
||||
const abilities = usePaymentAbilities();
|
||||
const { showToast } = useToast();
|
||||
const [expandedChannel, setExpandedChannel] = useState("");
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
const [rateDrafts, setRateDrafts] = useState({});
|
||||
|
||||
// 三方支付页是配置中心入口,列表必须包含已关闭方式,H5 只展示后端判定可用的方式。
|
||||
// 这里不在前端过滤关闭项,保证运营可以重新打开某个国家或某个支付方式。
|
||||
const result = useAdminQuery(listThirdPartyPaymentChannels, {
|
||||
errorMessage: "加载第三方支付失败",
|
||||
initialData: emptyChannels,
|
||||
queryKey: ["payment", "third-party-channels"],
|
||||
});
|
||||
|
||||
// 后端按 providerCode 返回渠道;展开态只保存 code,避免列表刷新后持有旧对象。
|
||||
const channels = useMemo(() => result.data?.items || [], [result.data]);
|
||||
const activeChannelCode = expandedChannel || channels[0]?.providerCode || "";
|
||||
const activeChannel = useMemo(
|
||||
() => channels.find((channel) => channel.providerCode === activeChannelCode) || channels[0] || null,
|
||||
[activeChannelCode, channels],
|
||||
);
|
||||
|
||||
// 默认打开第一条渠道,让运营进页面即可看到国家/方式明细;没有渠道时保持空态。
|
||||
useEffect(() => {
|
||||
if (!expandedChannel && channels[0]?.providerCode) {
|
||||
setExpandedChannel(channels[0].providerCode);
|
||||
}
|
||||
}, [channels, expandedChannel]);
|
||||
|
||||
// 汇率输入是可编辑草稿,不能直接绑定服务端对象,否则用户输入中途刷新会覆盖正在编辑的值。
|
||||
// 只在本地没有草稿时用接口值初始化,保存后再通过 reload 收敛到服务端事实。
|
||||
useEffect(() => {
|
||||
setRateDrafts((current) => {
|
||||
const next = { ...current };
|
||||
channels.forEach((channel) => {
|
||||
(channel.methods || []).forEach((method) => {
|
||||
const key = String(method.methodId);
|
||||
if (next[key] === undefined) {
|
||||
next[key] = method.usdToCurrencyRate || "";
|
||||
}
|
||||
});
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}, [channels]);
|
||||
|
||||
const toggleChannel = (channelCode) => {
|
||||
setExpandedChannel((current) => (current === channelCode ? "" : channelCode));
|
||||
};
|
||||
|
||||
const setRateDraft = (methodId, value) => {
|
||||
setRateDrafts((current) => ({ ...current, [String(methodId)]: value }));
|
||||
};
|
||||
|
||||
const toggleMethod = async (method, active) => {
|
||||
// 权限判断放在动作入口,避免只依赖按钮禁用;接口失败时保留原状态并提示。
|
||||
if (!abilities.canUpdateThirdParty) {
|
||||
return;
|
||||
}
|
||||
const actionKey = `status:${method.methodId}`;
|
||||
setLoadingAction(actionKey);
|
||||
try {
|
||||
await setThirdPartyPaymentMethodStatus(method.methodId, { enabled: active });
|
||||
await result.reload();
|
||||
showToast(active ? "支付方式已打开" : "支付方式已关闭", "success");
|
||||
} catch (err) {
|
||||
showToast(err.message || "状态更新失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
const saveRate = async (method) => {
|
||||
// 汇率决定 H5 展示和下单本币金额,前端先挡掉空值和非正数,最终仍以后端校验为准。
|
||||
if (!abilities.canUpdateThirdParty) {
|
||||
return;
|
||||
}
|
||||
const rateToUsd = String(rateDrafts[String(method.methodId)] || "").trim();
|
||||
if (!rateToUsd || Number(rateToUsd) <= 0) {
|
||||
showToast("请填写大于 0 的汇率", "error");
|
||||
return;
|
||||
}
|
||||
const actionKey = `rate:${method.methodId}`;
|
||||
setLoadingAction(actionKey);
|
||||
try {
|
||||
await updateThirdPartyPaymentRate(method.methodId, { usdToCurrencyRate: rateToUsd });
|
||||
await result.reload();
|
||||
showToast("汇率已保存", "success");
|
||||
} catch (err) {
|
||||
showToast(err.message || "汇率保存失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
activeChannel,
|
||||
channels,
|
||||
error: result.error,
|
||||
expandedChannel,
|
||||
loading: result.loading,
|
||||
loadingAction,
|
||||
rateDrafts,
|
||||
reload: result.reload,
|
||||
saveRate,
|
||||
setRateDraft,
|
||||
toggleChannel,
|
||||
toggleMethod,
|
||||
};
|
||||
}
|
||||
@ -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 || "",
|
||||
};
|
||||
|
||||
@ -36,10 +36,17 @@ const statusOptions = [
|
||||
|
||||
const platformOptions = [
|
||||
["", "全部平台"],
|
||||
["web", "Web"],
|
||||
["android", "Android"],
|
||||
["ios", "iOS"],
|
||||
];
|
||||
|
||||
const audienceOptions = [
|
||||
["", "全部用户类型"],
|
||||
["normal", "普通用户"],
|
||||
["coin_seller", "币商"],
|
||||
];
|
||||
|
||||
export function RechargeProductConfigPage() {
|
||||
const page = useRechargeProductsPage();
|
||||
const items = page.data?.items || [];
|
||||
@ -107,9 +114,21 @@ export function RechargeProductConfigPage() {
|
||||
value={page.form.platform}
|
||||
onChange={(event) => page.setForm({ platform: event.target.value })}
|
||||
>
|
||||
<MenuItem value="web">Web</MenuItem>
|
||||
<MenuItem value="android">Android</MenuItem>
|
||||
<MenuItem value="ios">iOS</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={!canSubmit}
|
||||
label="用户类型"
|
||||
required
|
||||
select
|
||||
value={page.form.audienceType}
|
||||
onChange={(event) => page.setForm({ audienceType: event.target.value })}
|
||||
>
|
||||
<MenuItem value="normal">普通用户</MenuItem>
|
||||
<MenuItem value="coin_seller">币商</MenuItem>
|
||||
</TextField>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="价格配置">
|
||||
@ -205,6 +224,18 @@ function productColumns(page, regionLabels) {
|
||||
}),
|
||||
width: "minmax(120px, 0.55fr)",
|
||||
},
|
||||
{
|
||||
key: "audienceType",
|
||||
label: "用户类型",
|
||||
render: (item) => audienceLabel(item.audienceType),
|
||||
filter: createOptionsColumnFilter({
|
||||
options: audienceOptions,
|
||||
placeholder: "搜索用户类型",
|
||||
value: page.audienceType,
|
||||
onChange: page.setAudienceType,
|
||||
}),
|
||||
width: "minmax(130px, 0.6fr)",
|
||||
},
|
||||
{
|
||||
key: "regionIds",
|
||||
label: "支持区域",
|
||||
@ -309,6 +340,9 @@ function Stack({ primary, secondary }) {
|
||||
}
|
||||
|
||||
function platformLabel(value) {
|
||||
if (value === "web") {
|
||||
return "Web";
|
||||
}
|
||||
if (value === "android") {
|
||||
return "Android";
|
||||
}
|
||||
@ -318,6 +352,16 @@ function platformLabel(value) {
|
||||
return value || "-";
|
||||
}
|
||||
|
||||
function audienceLabel(value) {
|
||||
if (value === "coin_seller") {
|
||||
return "币商";
|
||||
}
|
||||
if (value === "normal") {
|
||||
return "普通用户";
|
||||
}
|
||||
return value || "普通用户";
|
||||
}
|
||||
|
||||
function regionLabelMap(options) {
|
||||
return options.reduce((labels, option) => {
|
||||
labels[String(option.value)] = option.name || option.label;
|
||||
|
||||
174
src/features/payment/pages/ThirdPartyPaymentPage.jsx
Normal file
174
src/features/payment/pages/ThirdPartyPaymentPage.jsx
Normal file
@ -0,0 +1,174 @@
|
||||
import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined";
|
||||
import KeyboardArrowRightOutlined from "@mui/icons-material/KeyboardArrowRightOutlined";
|
||||
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useThirdPartyPaymentPage } from "@/features/payment/hooks/useThirdPartyPaymentPage.js";
|
||||
import { AdminListBody, AdminListPage, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import styles from "@/features/payment/payment.module.css";
|
||||
|
||||
export function ThirdPartyPaymentPage() {
|
||||
const page = useThirdPartyPaymentPage();
|
||||
const navigate = useNavigate();
|
||||
// 国家分组只影响展示密度,不改变后端返回的可开关粒度;每个 method 仍独立保存状态和汇率。
|
||||
const groupedMethods = useMemo(() => groupMethods(page.activeChannel?.methods || []), [page.activeChannel]);
|
||||
|
||||
return (
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
actions={
|
||||
<Button
|
||||
startIcon={<SettingsOutlined fontSize="small" />}
|
||||
variant="primary"
|
||||
onClick={() => navigate("/payment/recharge-products")}
|
||||
>
|
||||
三方支付内购配置
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<AdminListBody>
|
||||
<div className={styles.thirdPartyLayout}>
|
||||
<div className={styles.channelList}>
|
||||
{page.channels.map((channel) => (
|
||||
<button
|
||||
key={channel.providerCode}
|
||||
className={[
|
||||
styles.channelRow,
|
||||
page.activeChannel?.providerCode === channel.providerCode
|
||||
? styles.channelRowActive
|
||||
: "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
type="button"
|
||||
onClick={() => page.toggleChannel(channel.providerCode)}
|
||||
>
|
||||
<span className={styles.channelExpandIcon}>
|
||||
{page.expandedChannel === channel.providerCode ? (
|
||||
<KeyboardArrowDownOutlined fontSize="small" />
|
||||
) : (
|
||||
<KeyboardArrowRightOutlined fontSize="small" />
|
||||
)}
|
||||
</span>
|
||||
<span className={styles.channelText}>
|
||||
<span>{channel.providerName || channel.providerCode}</span>
|
||||
<span>{channel.providerCode}</span>
|
||||
</span>
|
||||
<StatusPill active={channel.status === "active"} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.methodPanel}>
|
||||
{page.activeChannel ? (
|
||||
groupedMethods.map((group) => (
|
||||
<section key={group.key} className={styles.methodGroup}>
|
||||
<div className={styles.methodGroupHeader}>
|
||||
<span>{group.title}</span>
|
||||
<span>{group.methods.length} 个支付方式</span>
|
||||
</div>
|
||||
<div className={styles.methodTable}>
|
||||
<div className={styles.methodHeader}>
|
||||
<span>支付方式</span>
|
||||
<span>类型</span>
|
||||
<span>币种</span>
|
||||
<span>USD 到本币汇率</span>
|
||||
<span>状态</span>
|
||||
</div>
|
||||
{group.methods.map((method) => (
|
||||
<div key={method.methodId} className={styles.methodRow}>
|
||||
<span className={styles.methodName}>
|
||||
<strong>{method.methodName || method.payWay || "-"}</strong>
|
||||
<small>{method.payWay || "-"}</small>
|
||||
</span>
|
||||
<span>{method.payType || "-"}</span>
|
||||
<span>{method.currencyCode || "-"}</span>
|
||||
<span className={styles.rateEditor}>
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdateThirdParty}
|
||||
size="small"
|
||||
slotProps={{ htmlInput: { inputMode: "decimal" } }}
|
||||
value={page.rateDrafts[String(method.methodId)] ?? ""}
|
||||
onChange={(event) =>
|
||||
page.setRateDraft(method.methodId, event.target.value)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
disabled={
|
||||
!page.abilities.canUpdateThirdParty ||
|
||||
page.loadingAction === `rate:${method.methodId}`
|
||||
}
|
||||
onClick={() => page.saveRate(method)}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</span>
|
||||
<span>
|
||||
<AdminSwitch
|
||||
checked={method.status === "active"}
|
||||
checkedLabel="开"
|
||||
disabled={
|
||||
!page.abilities.canUpdateThirdParty ||
|
||||
page.loadingAction === `status:${method.methodId}`
|
||||
}
|
||||
inputProps={{
|
||||
"aria-label": method.status === "active"
|
||||
? "关闭支付方式"
|
||||
: "打开支付方式",
|
||||
}}
|
||||
uncheckedLabel="关"
|
||||
onChange={(event) =>
|
||||
page.toggleMethod(method, event.target.checked)
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))
|
||||
) : (
|
||||
<div className={styles.emptyMethods}>当前无第三方支付渠道</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
</AdminListPage>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusPill({ active }) {
|
||||
return <span className={active ? styles.statusActive : styles.statusDisabled}>{active ? "已启用" : "已停用"}</span>;
|
||||
}
|
||||
|
||||
function groupMethods(methods) {
|
||||
// GLOBAL 是三方支付的跨国家方式,放在最前面,国家方式再按标题排序,方便运营先处理全局支付。
|
||||
const groups = new Map();
|
||||
methods.forEach((method) => {
|
||||
const countryCode = method.countryCode || "GLOBAL";
|
||||
const countryName = countryCode === "GLOBAL" ? "全球方式" : method.countryName || countryCode;
|
||||
if (!groups.has(countryCode)) {
|
||||
groups.set(countryCode, {
|
||||
key: countryCode,
|
||||
methods: [],
|
||||
title: `${countryName} / ${countryCode}`,
|
||||
});
|
||||
}
|
||||
groups.get(countryCode).methods.push(method);
|
||||
});
|
||||
return Array.from(groups.values()).sort((left, right) => {
|
||||
if (left.key === "GLOBAL") {
|
||||
return -1;
|
||||
}
|
||||
if (right.key === "GLOBAL") {
|
||||
return 1;
|
||||
}
|
||||
return left.title.localeCompare(right.title);
|
||||
});
|
||||
}
|
||||
@ -54,3 +54,220 @@
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--admin-font-size);
|
||||
}
|
||||
|
||||
.thirdPartyLayout {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: minmax(260px, 320px) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.channelList,
|
||||
.methodPanel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.channelList {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
overflow: auto;
|
||||
padding: var(--space-4) 0 var(--space-4) var(--space-4);
|
||||
}
|
||||
|
||||
.channelRow {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-height: 72px;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
gap: var(--space-2);
|
||||
grid-template-columns: 24px minmax(0, 1fr) auto;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.channelRow:hover,
|
||||
.channelRowActive {
|
||||
border-color: var(--primary-border);
|
||||
background: var(--active-surface);
|
||||
}
|
||||
|
||||
.channelExpandIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.channelText {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.channelText span:first-child {
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
font-weight: 720;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.channelText span:last-child {
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--admin-font-size);
|
||||
}
|
||||
|
||||
.methodPanel {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
overflow: auto;
|
||||
padding: var(--space-4) var(--space-4) var(--space-4) 0;
|
||||
}
|
||||
|
||||
.methodGroup {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.methodGroupHeader {
|
||||
display: flex;
|
||||
min-height: 48px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: 0 var(--space-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text-primary);
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
.methodGroupHeader span:last-child {
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--admin-font-size);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.methodTable {
|
||||
display: grid;
|
||||
min-width: 760px;
|
||||
}
|
||||
|
||||
.methodHeader,
|
||||
.methodRow {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
grid-template-columns: minmax(180px, 1.1fr) 120px 100px minmax(220px, 0.9fr) 104px;
|
||||
}
|
||||
|
||||
.methodHeader {
|
||||
min-height: 42px;
|
||||
padding: 0 var(--space-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--admin-font-size);
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
.methodRow {
|
||||
min-height: 64px;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.methodRow:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.methodName {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.methodName strong,
|
||||
.methodName small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.methodName small {
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--admin-font-size);
|
||||
}
|
||||
|
||||
.rateEditor {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
grid-template-columns: minmax(96px, 1fr) auto;
|
||||
}
|
||||
|
||||
.statusActive,
|
||||
.statusDisabled {
|
||||
display: inline-flex;
|
||||
min-width: 56px;
|
||||
height: 26px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 12px;
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
.statusActive {
|
||||
background: var(--success-surface);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.statusDisabled {
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.emptyMethods {
|
||||
display: flex;
|
||||
min-height: 280px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.thirdPartyLayout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.channelList {
|
||||
padding: var(--space-4) var(--space-4) 0;
|
||||
}
|
||||
|
||||
.methodPanel {
|
||||
padding: 0 var(--space-4) var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,5 +10,7 @@ export function usePaymentAbilities() {
|
||||
canUpdateProduct: can(PERMISSIONS.paymentProductUpdate),
|
||||
canViewBill: can(PERMISSIONS.paymentBillView),
|
||||
canViewProduct: can(PERMISSIONS.paymentProductView),
|
||||
canUpdateThirdParty: can(PERMISSIONS.paymentThirdPartyUpdate),
|
||||
canViewThirdParty: can(PERMISSIONS.paymentThirdPartyView),
|
||||
};
|
||||
}
|
||||
|
||||
@ -8,5 +8,22 @@ export const paymentRoutes = [
|
||||
pageKey: "payment-bill-list",
|
||||
path: "/payment/bills",
|
||||
permission: PERMISSIONS.paymentBillView,
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "第三方支付",
|
||||
loader: () => import("./pages/ThirdPartyPaymentPage.jsx").then((module) => module.ThirdPartyPaymentPage),
|
||||
menuCode: MENU_CODES.paymentThirdParty,
|
||||
pageKey: "payment-third-party",
|
||||
path: "/payment/third-party",
|
||||
permission: PERMISSIONS.paymentThirdPartyView,
|
||||
},
|
||||
{
|
||||
label: "三方支付内购配置",
|
||||
loader: () =>
|
||||
import("./pages/RechargeProductConfigPage.jsx").then((module) => module.RechargeProductConfigPage),
|
||||
menuCode: MENU_CODES.paymentRechargeProducts,
|
||||
pageKey: "payment-recharge-products",
|
||||
path: "/payment/recharge-products",
|
||||
permission: PERMISSIONS.paymentProductView,
|
||||
},
|
||||
];
|
||||
|
||||
@ -8,10 +8,11 @@ const usdtAmountSchema = z
|
||||
|
||||
export const rechargeProductSchema = z.object({
|
||||
amountUsdt: usdtAmountSchema,
|
||||
audienceType: z.enum(["normal", "coin_seller"]),
|
||||
coinAmount: z.coerce.number().int("获得金币数必须是整数").positive("获得金币数必须大于 0"),
|
||||
description: z.string().trim().max(512, "描述不能超过 512 个字符").default(""),
|
||||
enabled: z.boolean(),
|
||||
platform: z.enum(["android", "ios"]),
|
||||
platform: z.enum(["android", "ios", "web"]),
|
||||
productName: z.string().trim().min(1, "请填写产品名称").max(128, "产品名称不能超过 128 个字符"),
|
||||
regionIds: z.array(z.coerce.number().int().positive()).min(1, "请选择支持区域"),
|
||||
});
|
||||
|
||||
@ -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),
|
||||
},
|
||||
|
||||
@ -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 }) {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 }), {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
77
src/features/resources/components/GiftSelectDrawer.test.jsx
Normal file
77
src/features/resources/components/GiftSelectDrawer.test.jsx
Normal 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();
|
||||
});
|
||||
@ -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,
|
||||
|
||||
601
src/features/resources/mp4AlphaLayout.js
Normal file
601
src/features/resources/mp4AlphaLayout.js
Normal 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,
|
||||
};
|
||||
}
|
||||
197
src/features/resources/mp4AlphaLayout.test.js
Normal file
197
src/features/resources/mp4AlphaLayout.test.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user