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 样式。
|
13. 左侧菜单 active 样式优先级必须高于 hover 样式。
|
||||||
14. 侧边栏展开/收起按钮必须跟随状态切换图标。
|
14. 侧边栏展开/收起按钮必须跟随状态切换图标。
|
||||||
15. 展开态 Logo 区域和侧边栏宽度保持 248px。
|
15. 展开态 Logo 区域和侧边栏宽度保持 248px。
|
||||||
|
16. 所有逻辑功能配置默认打开;只有密钥、地址、三方账号等必须由环境提供的敏感值使用占位符或环境配置,不在默认配置里伪造可用凭证。
|
||||||
|
17. 所有业务逻辑、状态流转、权限判断、支付链路、异步流程都保持高密度逻辑注释,注释解释为什么这样处理和失败分支,不复述语法。
|
||||||
|
|
||||||
## 当前实现方式
|
## 当前实现方式
|
||||||
|
|
||||||
@ -97,7 +99,7 @@ import { Refresh } from "@mui/icons-material";
|
|||||||
- 区域编辑弹窗必须同时包含区域基础字段和国家码字段;国家码使用可搜索多选下拉框,不再拆成独立“区域国家”弹窗。
|
- 区域编辑弹窗必须同时包含区域基础字段和国家码字段;国家码使用可搜索多选下拉框,不再拆成独立“区域国家”弹窗。
|
||||||
- 头像、图片、封面等图片资源字段必须使用 `src/shared/ui/UploadField.jsx` 上传表单组件,不使用普通文本输入框承载 URL。
|
- 头像、图片、封面等图片资源字段必须使用 `src/shared/ui/UploadField.jsx` 上传表单组件,不使用普通文本输入框承载 URL。
|
||||||
- 图片上传表单必须提供统一预览;`webp` 使用浏览器原生动效预览,`svga` 使用 SVGA 播放器预览,`pag` 使用 libpag + wasm 预览。
|
- 图片上传表单必须提供统一预览;`webp` 使用浏览器原生动效预览,`svga` 使用 SVGA 播放器预览,`pag` 使用 libpag + wasm 预览。
|
||||||
- BD Leader 创建弹窗必须使用区域下拉选择 `regionId`;目标用户原区域可以不同,创建成功后后端会把目标用户当前区域同步到所选区域。
|
- BD Leader 创建弹窗不选择区域、不提交 `regionId`;创建结果按目标用户当前区域生成,若区域不对,先修复用户国家/区域映射,再创建 BD Leader。
|
||||||
- 区域创建和区域启用/禁用调用后台管理后端接口,不要求 user-service 新增对应 RPC。
|
- 区域创建和区域启用/禁用调用后台管理后端接口,不要求 user-service 新增对应 RPC。
|
||||||
- 国家创建和国家启用/禁用调用后台管理后端接口,不要求 user-service 新增对应 RPC。
|
- 国家创建和国家启用/禁用调用后台管理后端接口,不要求 user-service 新增对应 RPC。
|
||||||
|
|
||||||
|
|||||||
@ -90,7 +90,7 @@
|
|||||||
"name": "task_id",
|
"name": "task_id",
|
||||||
"required": true,
|
"required": true,
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "integer"
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@ -112,7 +112,7 @@
|
|||||||
"name": "task_id",
|
"name": "task_id",
|
||||||
"required": true,
|
"required": true,
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "integer"
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@ -270,6 +270,18 @@
|
|||||||
"x-permissions": ["first-recharge-reward:view"]
|
"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": {
|
"/admin/activity/cumulative-recharge-reward/grants": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listCumulativeRechargeRewardGrants",
|
"operationId": "listCumulativeRechargeRewardGrants",
|
||||||
@ -488,6 +500,28 @@
|
|||||||
"x-permissions": ["cumulative-recharge-reward:update"]
|
"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": {
|
"/admin/activity/room-turnover-reward/config": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getRoomTurnoverRewardConfig",
|
"operationId": "getRoomTurnoverRewardConfig",
|
||||||
@ -760,6 +794,134 @@
|
|||||||
"x-permissions": ["app-config:update"]
|
"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": {
|
"/admin/app-config/h5-links": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listH5Links",
|
"operationId": "listH5Links",
|
||||||
@ -1543,6 +1705,230 @@
|
|||||||
"x-permissions": ["game:update"]
|
"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": {
|
"/admin/gift-types": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listGiftTypes",
|
"operationId": "listGiftTypes",
|
||||||
@ -1629,6 +2015,26 @@
|
|||||||
],
|
],
|
||||||
"x-permission": "gift:update",
|
"x-permission": "gift:update",
|
||||||
"x-permissions": ["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": {
|
"/admin/gifts/{gift_id}/disable": {
|
||||||
@ -2015,6 +2421,62 @@
|
|||||||
"x-permissions": ["payment-product:delete"]
|
"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": {
|
"/admin/regions": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listRegions",
|
"operationId": "listRegions",
|
||||||
@ -3457,6 +3919,118 @@
|
|||||||
},
|
},
|
||||||
"x-permissions": ["user:export"]
|
"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": {
|
"components": {
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import HubOutlined from "@mui/icons-material/HubOutlined";
|
|||||||
import ImageOutlined from "@mui/icons-material/ImageOutlined";
|
import ImageOutlined from "@mui/icons-material/ImageOutlined";
|
||||||
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
|
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
|
||||||
import LeaderboardOutlined from "@mui/icons-material/LeaderboardOutlined";
|
import LeaderboardOutlined from "@mui/icons-material/LeaderboardOutlined";
|
||||||
|
import LocalOfferOutlined from "@mui/icons-material/LocalOfferOutlined";
|
||||||
import LoginOutlined from "@mui/icons-material/LoginOutlined";
|
import LoginOutlined from "@mui/icons-material/LoginOutlined";
|
||||||
import ManageAccountsOutlined from "@mui/icons-material/ManageAccountsOutlined";
|
import ManageAccountsOutlined from "@mui/icons-material/ManageAccountsOutlined";
|
||||||
import MapOutlined from "@mui/icons-material/MapOutlined";
|
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 PublicOutlined from "@mui/icons-material/PublicOutlined";
|
||||||
import PushPinOutlined from "@mui/icons-material/PushPinOutlined";
|
import PushPinOutlined from "@mui/icons-material/PushPinOutlined";
|
||||||
import PaidOutlined from "@mui/icons-material/PaidOutlined";
|
import PaidOutlined from "@mui/icons-material/PaidOutlined";
|
||||||
|
import PersonAddAltOutlined from "@mui/icons-material/PersonAddAltOutlined";
|
||||||
import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined";
|
import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined";
|
||||||
import RedeemOutlined from "@mui/icons-material/RedeemOutlined";
|
import RedeemOutlined from "@mui/icons-material/RedeemOutlined";
|
||||||
import SendOutlined from "@mui/icons-material/SendOutlined";
|
import SendOutlined from "@mui/icons-material/SendOutlined";
|
||||||
@ -63,6 +65,7 @@ const iconMap = {
|
|||||||
menu: MenuOpenOutlined,
|
menu: MenuOpenOutlined,
|
||||||
operations: ReceiptLongOutlined,
|
operations: ReceiptLongOutlined,
|
||||||
paid: PaidOutlined,
|
paid: PaidOutlined,
|
||||||
|
person_add: PersonAddAltOutlined,
|
||||||
public: PublicOutlined,
|
public: PublicOutlined,
|
||||||
push_pin: PushPinOutlined,
|
push_pin: PushPinOutlined,
|
||||||
receipt: ReceiptLongOutlined,
|
receipt: ReceiptLongOutlined,
|
||||||
@ -74,6 +77,7 @@ const iconMap = {
|
|||||||
sports_esports: SportsEsportsOutlined,
|
sports_esports: SportsEsportsOutlined,
|
||||||
star: StarBorderOutlined,
|
star: StarBorderOutlined,
|
||||||
storefront: StorefrontOutlined,
|
storefront: StorefrontOutlined,
|
||||||
|
tag: LocalOfferOutlined,
|
||||||
task: TaskAltOutlined,
|
task: TaskAltOutlined,
|
||||||
users: ManageAccountsOutlined,
|
users: ManageAccountsOutlined,
|
||||||
wallet: WalletOutlined,
|
wallet: WalletOutlined,
|
||||||
@ -108,6 +112,7 @@ export const fallbackNavigation = [
|
|||||||
routeNavItem("app-user-list", { icon: ManageAccountsOutlined }),
|
routeNavItem("app-user-list", { icon: ManageAccountsOutlined }),
|
||||||
routeNavItem("app-user-bans", { icon: BlockOutlined }),
|
routeNavItem("app-user-bans", { icon: BlockOutlined }),
|
||||||
routeNavItem("app-user-login-logs", { icon: LoginOutlined }),
|
routeNavItem("app-user-login-logs", { icon: LoginOutlined }),
|
||||||
|
routeNavItem("app-user-pretty-ids", { icon: LocalOfferOutlined }),
|
||||||
routeNavItem("app-user-region-blocks", { icon: PublicOutlined }),
|
routeNavItem("app-user-region-blocks", { icon: PublicOutlined }),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -146,8 +151,9 @@ export const fallbackNavigation = [
|
|||||||
children: [
|
children: [
|
||||||
routeNavItem("app-config-h5", { icon: SettingsApplicationsOutlined }),
|
routeNavItem("app-config-h5", { icon: SettingsApplicationsOutlined }),
|
||||||
routeNavItem("app-config-banners", { icon: ImageOutlined }),
|
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("app-config-explore", { icon: MapOutlined }),
|
||||||
routeNavItem("payment-recharge-products", { icon: WalletOutlined }),
|
|
||||||
routeNavItem("app-config-versions", { icon: SettingsApplicationsOutlined }),
|
routeNavItem("app-config-versions", { icon: SettingsApplicationsOutlined }),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -184,7 +190,11 @@ export const fallbackNavigation = [
|
|||||||
icon: WalletOutlined,
|
icon: WalletOutlined,
|
||||||
id: "payment",
|
id: "payment",
|
||||||
label: "支付管理",
|
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",
|
code: "activities",
|
||||||
@ -196,6 +206,7 @@ export const fallbackNavigation = [
|
|||||||
routeNavItem("registration-reward", { icon: CardGiftcardOutlined }),
|
routeNavItem("registration-reward", { icon: CardGiftcardOutlined }),
|
||||||
routeNavItem("first-recharge-reward", { icon: CardGiftcardOutlined }),
|
routeNavItem("first-recharge-reward", { icon: CardGiftcardOutlined }),
|
||||||
routeNavItem("cumulative-recharge-reward", { icon: WorkspacePremiumOutlined }),
|
routeNavItem("cumulative-recharge-reward", { icon: WorkspacePremiumOutlined }),
|
||||||
|
routeNavItem("invite-activity-reward", { icon: PersonAddAltOutlined }),
|
||||||
routeNavItem("seven-day-checkin", { icon: EventAvailableOutlined }),
|
routeNavItem("seven-day-checkin", { icon: EventAvailableOutlined }),
|
||||||
routeNavItem("room-rocket", { icon: RedeemOutlined }),
|
routeNavItem("room-rocket", { icon: RedeemOutlined }),
|
||||||
routeNavItem("room-turnover-reward", { icon: PaidOutlined }),
|
routeNavItem("room-turnover-reward", { icon: PaidOutlined }),
|
||||||
@ -211,7 +222,13 @@ export const fallbackNavigation = [
|
|||||||
icon: SportsEsportsOutlined,
|
icon: SportsEsportsOutlined,
|
||||||
id: "games",
|
id: "games",
|
||||||
label: "游戏管理",
|
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",
|
code: "geo",
|
||||||
@ -262,7 +279,7 @@ export function mapBackendMenus(menus = []) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function relocateBackendMenus(menus = []) {
|
function relocateBackendMenus(menus = []) {
|
||||||
return relocateRechargeProductsToAppConfig(relocateLogMenusToSystem(menus));
|
return relocateLogMenusToSystem(menus);
|
||||||
}
|
}
|
||||||
|
|
||||||
function orderTopLevelMenus(menus = []) {
|
function orderTopLevelMenus(menus = []) {
|
||||||
@ -276,34 +293,6 @@ function orderTopLevelMenus(menus = []) {
|
|||||||
.map(({ item }) => item);
|
.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 = []) {
|
function relocateLogMenusToSystem(menus = []) {
|
||||||
const systemMenu = menus.find((item) => item.code === "system");
|
const systemMenu = menus.find((item) => item.code === "system");
|
||||||
const logsMenu = menus.find((item) => item.code === "logs");
|
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) {
|
export function findNavItemByPath(pathname, items = fallbackNavigation) {
|
||||||
|
|||||||
@ -84,7 +84,7 @@ describe("navigation menu helpers", () => {
|
|||||||
expect(flattenCodes(merged)).toContain("host-org-managers");
|
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([
|
const mapped = mapBackendMenus([
|
||||||
{ children: [], code: "app-config", icon: "settings", id: "app-config", label: "APP配置" },
|
{ children: [], code: "app-config", icon: "settings", id: "app-config", label: "APP配置" },
|
||||||
{
|
{
|
||||||
@ -97,6 +97,14 @@ describe("navigation menu helpers", () => {
|
|||||||
path: "/payment/recharge-products",
|
path: "/payment/recharge-products",
|
||||||
permissionCode: "payment-product:view",
|
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",
|
code: "payment",
|
||||||
icon: "wallet",
|
icon: "wallet",
|
||||||
@ -107,11 +115,61 @@ describe("navigation menu helpers", () => {
|
|||||||
const appConfig = mapped.find((item) => item.code === "app-config");
|
const appConfig = mapped.find((item) => item.code === "app-config");
|
||||||
const payment = mapped.find((item) => item.code === "payment");
|
const payment = mapped.find((item) => item.code === "payment");
|
||||||
|
|
||||||
expect(appConfig.children.map((item) => item.code)).toContain("payment-recharge-products");
|
expect(appConfig.children || []).toEqual([]);
|
||||||
expect(appConfig.children.find((item) => item.code === "payment-recharge-products").path).toBe(
|
expect(payment.children.map((item) => item.code)).toEqual(["payment-recharge-products", "payment-third-party"]);
|
||||||
"/app-config/recharge-products",
|
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",
|
paymentProductCreate: "payment-product:create",
|
||||||
paymentProductUpdate: "payment-product:update",
|
paymentProductUpdate: "payment-product:update",
|
||||||
paymentProductDelete: "payment-product:delete",
|
paymentProductDelete: "payment-product:delete",
|
||||||
|
paymentThirdPartyView: "payment-third-party:view",
|
||||||
|
paymentThirdPartyUpdate: "payment-third-party:update",
|
||||||
gameView: "game:view",
|
gameView: "game:view",
|
||||||
gameCreate: "game:create",
|
gameCreate: "game:create",
|
||||||
gameUpdate: "game:update",
|
gameUpdate: "game:update",
|
||||||
@ -79,6 +81,10 @@ export const PERMISSIONS = {
|
|||||||
appUserUpdate: "app-user:update",
|
appUserUpdate: "app-user:update",
|
||||||
appUserStatus: "app-user:status",
|
appUserStatus: "app-user:status",
|
||||||
appUserPassword: "app-user:password",
|
appUserPassword: "app-user:password",
|
||||||
|
prettyIdView: "pretty-id:view",
|
||||||
|
prettyIdUpdate: "pretty-id:update",
|
||||||
|
prettyIdGenerate: "pretty-id:generate",
|
||||||
|
prettyIdGrant: "pretty-id:grant",
|
||||||
levelConfigView: "level-config:view",
|
levelConfigView: "level-config:view",
|
||||||
levelConfigUpdate: "level-config:update",
|
levelConfigUpdate: "level-config:update",
|
||||||
regionBlockView: "region-block:view",
|
regionBlockView: "region-block:view",
|
||||||
@ -111,6 +117,7 @@ export const PERMISSIONS = {
|
|||||||
giftCreate: "gift:create",
|
giftCreate: "gift:create",
|
||||||
giftUpdate: "gift:update",
|
giftUpdate: "gift:update",
|
||||||
giftStatus: "gift:status",
|
giftStatus: "gift:status",
|
||||||
|
giftDelete: "gift:delete",
|
||||||
emojiPackView: "emoji-pack:view",
|
emojiPackView: "emoji-pack:view",
|
||||||
emojiPackCreate: "emoji-pack:create",
|
emojiPackCreate: "emoji-pack:create",
|
||||||
dailyTaskView: "daily-task:view",
|
dailyTaskView: "daily-task:view",
|
||||||
@ -126,6 +133,8 @@ export const PERMISSIONS = {
|
|||||||
firstRechargeRewardUpdate: "first-recharge-reward:update",
|
firstRechargeRewardUpdate: "first-recharge-reward:update",
|
||||||
cumulativeRechargeRewardView: "cumulative-recharge-reward:view",
|
cumulativeRechargeRewardView: "cumulative-recharge-reward:view",
|
||||||
cumulativeRechargeRewardUpdate: "cumulative-recharge-reward:update",
|
cumulativeRechargeRewardUpdate: "cumulative-recharge-reward:update",
|
||||||
|
inviteActivityRewardView: "invite-activity-reward:view",
|
||||||
|
inviteActivityRewardUpdate: "invite-activity-reward:update",
|
||||||
sevenDayCheckInView: "seven-day-checkin:view",
|
sevenDayCheckInView: "seven-day-checkin:view",
|
||||||
sevenDayCheckInUpdate: "seven-day-checkin:update",
|
sevenDayCheckInUpdate: "seven-day-checkin:update",
|
||||||
luckyGiftView: "lucky-gift:view",
|
luckyGiftView: "lucky-gift:view",
|
||||||
@ -169,6 +178,7 @@ export const MENU_CODES = {
|
|||||||
appUserLoginLogs: "app-user-login-logs",
|
appUserLoginLogs: "app-user-login-logs",
|
||||||
appUserLevelConfig: "app-user-level-config",
|
appUserLevelConfig: "app-user-level-config",
|
||||||
appUserRegionBlocks: "app-user-region-blocks",
|
appUserRegionBlocks: "app-user-region-blocks",
|
||||||
|
appUserPrettyIds: "app-user-pretty-ids",
|
||||||
rooms: "rooms",
|
rooms: "rooms",
|
||||||
roomList: "room-list",
|
roomList: "room-list",
|
||||||
roomPins: "room-pins",
|
roomPins: "room-pins",
|
||||||
@ -176,6 +186,8 @@ export const MENU_CODES = {
|
|||||||
appConfig: "app-config",
|
appConfig: "app-config",
|
||||||
appConfigH5: "app-config-h5",
|
appConfigH5: "app-config-h5",
|
||||||
appConfigBanners: "app-config-banners",
|
appConfigBanners: "app-config-banners",
|
||||||
|
appConfigSplashScreens: "app-config-splash-screens",
|
||||||
|
appConfigPopups: "app-config-popups",
|
||||||
appConfigExplore: "app-config-explore",
|
appConfigExplore: "app-config-explore",
|
||||||
appConfigVersions: "app-config-versions",
|
appConfigVersions: "app-config-versions",
|
||||||
resources: "resources",
|
resources: "resources",
|
||||||
@ -197,6 +209,7 @@ export const MENU_CODES = {
|
|||||||
registrationReward: "registration-reward",
|
registrationReward: "registration-reward",
|
||||||
firstRechargeReward: "first-recharge-reward",
|
firstRechargeReward: "first-recharge-reward",
|
||||||
cumulativeRechargeReward: "cumulative-recharge-reward",
|
cumulativeRechargeReward: "cumulative-recharge-reward",
|
||||||
|
inviteActivityReward: "invite-activity-reward",
|
||||||
achievementConfig: "achievement-config",
|
achievementConfig: "achievement-config",
|
||||||
sevenDayCheckIn: "seven-day-checkin",
|
sevenDayCheckIn: "seven-day-checkin",
|
||||||
roomRocket: "room-rocket",
|
roomRocket: "room-rocket",
|
||||||
@ -221,8 +234,13 @@ export const MENU_CODES = {
|
|||||||
payment: "payment",
|
payment: "payment",
|
||||||
paymentBillList: "payment-bill-list",
|
paymentBillList: "payment-bill-list",
|
||||||
paymentRechargeProducts: "payment-recharge-products",
|
paymentRechargeProducts: "payment-recharge-products",
|
||||||
|
paymentThirdParty: "payment-third-party",
|
||||||
games: "games",
|
games: "games",
|
||||||
gameList: "game-list",
|
gameList: "game-list",
|
||||||
|
selfGames: "self-games",
|
||||||
|
gameRobots: "game-robots",
|
||||||
|
roomRpsConfig: "room-rps-config",
|
||||||
|
roomRpsChallenges: "room-rps-challenges",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type MenuCode = (typeof MENU_CODES)[keyof typeof MENU_CODES];
|
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 { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
|
||||||
import { QueryProvider } from "@/shared/query/QueryProvider.jsx";
|
import { QueryProvider } from "@/shared/query/QueryProvider.jsx";
|
||||||
import { RefreshProvider } from "@/shared/hooks/useRefreshSignal.js";
|
import { RefreshProvider } from "@/shared/hooks/useRefreshSignal.js";
|
||||||
|
import { AppUserDetailProvider } from "@/features/app-users/components/AppUserDetailProvider.jsx";
|
||||||
import { theme } from "@/theme.js";
|
import { theme } from "@/theme.js";
|
||||||
|
|
||||||
export function AppProviders({ children }) {
|
export function AppProviders({ children }) {
|
||||||
@ -17,7 +18,9 @@ export function AppProviders({ children }) {
|
|||||||
<RefreshProvider>
|
<RefreshProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<AppScopeProvider>
|
<AppScopeProvider>
|
||||||
<TimeZoneProvider>{children}</TimeZoneProvider>
|
<TimeZoneProvider>
|
||||||
|
<AppUserDetailProvider>{children}</AppUserDetailProvider>
|
||||||
|
</TimeZoneProvider>
|
||||||
</AppScopeProvider>
|
</AppScopeProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</RefreshProvider>
|
</RefreshProvider>
|
||||||
|
|||||||
@ -16,11 +16,23 @@ export function DeferredPage({ route }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setPage(null);
|
setPage(null);
|
||||||
route.loader().then((component) => {
|
route
|
||||||
|
.loader()
|
||||||
|
.then((component) => {
|
||||||
pageCache.set(route.pageKey, component);
|
pageCache.set(route.pageKey, component);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setPage(() => component);
|
setPage(() => component);
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (mounted) {
|
||||||
|
setPage(
|
||||||
|
() =>
|
||||||
|
function DeferredPageLoaderError() {
|
||||||
|
throw error;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { firstRechargeRewardRoutes } from "@/features/first-recharge-reward/rout
|
|||||||
import { gameRoutes } from "@/features/games/routes.js";
|
import { gameRoutes } from "@/features/games/routes.js";
|
||||||
import { hostAgencyPolicyRoutes } from "@/features/host-agency-policy/routes.js";
|
import { hostAgencyPolicyRoutes } from "@/features/host-agency-policy/routes.js";
|
||||||
import { hostOrgRoutes } from "@/features/host-org/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 { levelConfigRoutes } from "@/features/level-config/routes.js";
|
||||||
import { logsRoutes } from "@/features/logs/routes.js";
|
import { logsRoutes } from "@/features/logs/routes.js";
|
||||||
import { luckyGiftRoutes } from "@/features/lucky-gift/routes.js";
|
import { luckyGiftRoutes } from "@/features/lucky-gift/routes.js";
|
||||||
@ -46,6 +47,7 @@ export const adminRoutes: AdminRoute[] = [
|
|||||||
...registrationRewardRoutes,
|
...registrationRewardRoutes,
|
||||||
...firstRechargeRewardRoutes,
|
...firstRechargeRewardRoutes,
|
||||||
...cumulativeRechargeRewardRoutes,
|
...cumulativeRechargeRewardRoutes,
|
||||||
|
...inviteActivityRewardRoutes,
|
||||||
...sevenDayCheckInRoutes,
|
...sevenDayCheckInRoutes,
|
||||||
...roomRocketRoutes,
|
...roomRocketRoutes,
|
||||||
...roomTurnoverRewardRoutes,
|
...roomTurnoverRewardRoutes,
|
||||||
|
|||||||
@ -4,6 +4,10 @@ import type {
|
|||||||
ApiList,
|
ApiList,
|
||||||
AppBannerDto,
|
AppBannerDto,
|
||||||
AppBannerPayload,
|
AppBannerPayload,
|
||||||
|
AppPopupDto,
|
||||||
|
AppPopupPayload,
|
||||||
|
AppSplashScreenDto,
|
||||||
|
AppSplashScreenPayload,
|
||||||
AppVersionDto,
|
AppVersionDto,
|
||||||
AppVersionPayload,
|
AppVersionPayload,
|
||||||
EntityId,
|
EntityId,
|
||||||
@ -18,23 +22,26 @@ import type {
|
|||||||
export function listH5Links(): Promise<ApiList<H5LinkConfigDto>> {
|
export function listH5Links(): Promise<ApiList<H5LinkConfigDto>> {
|
||||||
const endpoint = API_ENDPOINTS.listH5Links;
|
const endpoint = API_ENDPOINTS.listH5Links;
|
||||||
return apiRequest<ApiList<H5LinkConfigDto>>(apiEndpointPath(API_OPERATIONS.listH5Links), {
|
return apiRequest<ApiList<H5LinkConfigDto>>(apiEndpointPath(API_OPERATIONS.listH5Links), {
|
||||||
method: endpoint.method
|
method: endpoint.method,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateH5Links(payload: H5LinkConfigUpdatePayload): Promise<ApiList<H5LinkConfigDto>> {
|
export function updateH5Links(payload: H5LinkConfigUpdatePayload): Promise<ApiList<H5LinkConfigDto>> {
|
||||||
const endpoint = API_ENDPOINTS.updateH5Links;
|
const endpoint = API_ENDPOINTS.updateH5Links;
|
||||||
return apiRequest<ApiList<H5LinkConfigDto>, H5LinkConfigUpdatePayload>(apiEndpointPath(API_OPERATIONS.updateH5Links), {
|
return apiRequest<ApiList<H5LinkConfigDto>, H5LinkConfigUpdatePayload>(
|
||||||
|
apiEndpointPath(API_OPERATIONS.updateH5Links),
|
||||||
|
{
|
||||||
body: payload,
|
body: payload,
|
||||||
method: endpoint.method
|
method: endpoint.method,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createH5Link(payload: H5LinkConfigPayload): Promise<H5LinkConfigDto> {
|
export function createH5Link(payload: H5LinkConfigPayload): Promise<H5LinkConfigDto> {
|
||||||
const endpoint = API_ENDPOINTS.createH5Link;
|
const endpoint = API_ENDPOINTS.createH5Link;
|
||||||
return apiRequest<H5LinkConfigDto, H5LinkConfigPayload>(apiEndpointPath(API_OPERATIONS.createH5Link), {
|
return apiRequest<H5LinkConfigDto, H5LinkConfigPayload>(apiEndpointPath(API_OPERATIONS.createH5Link), {
|
||||||
body: payload,
|
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;
|
const endpoint = API_ENDPOINTS.updateH5Link;
|
||||||
return apiRequest<H5LinkConfigDto, H5LinkConfigPayload>(apiEndpointPath(API_OPERATIONS.updateH5Link, { key }), {
|
return apiRequest<H5LinkConfigDto, H5LinkConfigPayload>(apiEndpointPath(API_OPERATIONS.updateH5Link, { key }), {
|
||||||
body: payload,
|
body: payload,
|
||||||
method: endpoint.method
|
method: endpoint.method,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteH5Link(key: EntityId): Promise<{ deleted: boolean }> {
|
export function deleteH5Link(key: EntityId): Promise<{ deleted: boolean }> {
|
||||||
const endpoint = API_ENDPOINTS.deleteH5Link;
|
const endpoint = API_ENDPOINTS.deleteH5Link;
|
||||||
return apiRequest<{ deleted: boolean }>(apiEndpointPath(API_OPERATIONS.deleteH5Link, { key }), {
|
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;
|
const endpoint = API_ENDPOINTS.listExploreTabs;
|
||||||
return apiRequest<ApiList<ExploreTabDto>>(apiEndpointPath(API_OPERATIONS.listExploreTabs), {
|
return apiRequest<ApiList<ExploreTabDto>>(apiEndpointPath(API_OPERATIONS.listExploreTabs), {
|
||||||
method: endpoint.method,
|
method: endpoint.method,
|
||||||
query
|
query,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -65,22 +72,25 @@ export function createExploreTab(payload: ExploreTabPayload): Promise<ExploreTab
|
|||||||
const endpoint = API_ENDPOINTS.createExploreTab;
|
const endpoint = API_ENDPOINTS.createExploreTab;
|
||||||
return apiRequest<ExploreTabDto, ExploreTabPayload>(apiEndpointPath(API_OPERATIONS.createExploreTab), {
|
return apiRequest<ExploreTabDto, ExploreTabPayload>(apiEndpointPath(API_OPERATIONS.createExploreTab), {
|
||||||
body: payload,
|
body: payload,
|
||||||
method: endpoint.method
|
method: endpoint.method,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateExploreTab(tabId: EntityId, payload: ExploreTabPayload): Promise<ExploreTabDto> {
|
export function updateExploreTab(tabId: EntityId, payload: ExploreTabPayload): Promise<ExploreTabDto> {
|
||||||
const endpoint = API_ENDPOINTS.updateExploreTab;
|
const endpoint = API_ENDPOINTS.updateExploreTab;
|
||||||
return apiRequest<ExploreTabDto, ExploreTabPayload>(apiEndpointPath(API_OPERATIONS.updateExploreTab, { tab_id: tabId }), {
|
return apiRequest<ExploreTabDto, ExploreTabPayload>(
|
||||||
|
apiEndpointPath(API_OPERATIONS.updateExploreTab, { tab_id: tabId }),
|
||||||
|
{
|
||||||
body: payload,
|
body: payload,
|
||||||
method: endpoint.method
|
method: endpoint.method,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteExploreTab(tabId: EntityId): Promise<{ deleted: boolean }> {
|
export function deleteExploreTab(tabId: EntityId): Promise<{ deleted: boolean }> {
|
||||||
const endpoint = API_ENDPOINTS.deleteExploreTab;
|
const endpoint = API_ENDPOINTS.deleteExploreTab;
|
||||||
return apiRequest<{ deleted: boolean }>(apiEndpointPath(API_OPERATIONS.deleteExploreTab, { tab_id: tabId }), {
|
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;
|
const endpoint = API_ENDPOINTS.listBanners;
|
||||||
return apiRequest<ApiList<AppBannerDto>>(apiEndpointPath(API_OPERATIONS.listBanners), {
|
return apiRequest<ApiList<AppBannerDto>>(apiEndpointPath(API_OPERATIONS.listBanners), {
|
||||||
method: endpoint.method,
|
method: endpoint.method,
|
||||||
query
|
query,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -96,22 +106,96 @@ export function createBanner(payload: AppBannerPayload): Promise<AppBannerDto> {
|
|||||||
const endpoint = API_ENDPOINTS.createBanner;
|
const endpoint = API_ENDPOINTS.createBanner;
|
||||||
return apiRequest<AppBannerDto, AppBannerPayload>(apiEndpointPath(API_OPERATIONS.createBanner), {
|
return apiRequest<AppBannerDto, AppBannerPayload>(apiEndpointPath(API_OPERATIONS.createBanner), {
|
||||||
body: payload,
|
body: payload,
|
||||||
method: endpoint.method
|
method: endpoint.method,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateBanner(bannerId: EntityId, payload: AppBannerPayload): Promise<AppBannerDto> {
|
export function updateBanner(bannerId: EntityId, payload: AppBannerPayload): Promise<AppBannerDto> {
|
||||||
const endpoint = API_ENDPOINTS.updateBanner;
|
const endpoint = API_ENDPOINTS.updateBanner;
|
||||||
return apiRequest<AppBannerDto, AppBannerPayload>(apiEndpointPath(API_OPERATIONS.updateBanner, { banner_id: bannerId }), {
|
return apiRequest<AppBannerDto, AppBannerPayload>(
|
||||||
|
apiEndpointPath(API_OPERATIONS.updateBanner, { banner_id: bannerId }),
|
||||||
|
{
|
||||||
body: payload,
|
body: payload,
|
||||||
method: endpoint.method
|
method: endpoint.method,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteBanner(bannerId: EntityId): Promise<{ deleted: boolean }> {
|
export function deleteBanner(bannerId: EntityId): Promise<{ deleted: boolean }> {
|
||||||
const endpoint = API_ENDPOINTS.deleteBanner;
|
const endpoint = API_ENDPOINTS.deleteBanner;
|
||||||
return apiRequest<{ deleted: boolean }>(apiEndpointPath(API_OPERATIONS.deleteBanner, { banner_id: bannerId }), {
|
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;
|
const endpoint = API_ENDPOINTS.listAppVersions;
|
||||||
return apiRequest<ApiList<AppVersionDto>>(apiEndpointPath(API_OPERATIONS.listAppVersions), {
|
return apiRequest<ApiList<AppVersionDto>>(apiEndpointPath(API_OPERATIONS.listAppVersions), {
|
||||||
method: endpoint.method,
|
method: endpoint.method,
|
||||||
query
|
query,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -127,7 +211,7 @@ export function createAppVersion(payload: AppVersionPayload): Promise<AppVersion
|
|||||||
const endpoint = API_ENDPOINTS.createAppVersion;
|
const endpoint = API_ENDPOINTS.createAppVersion;
|
||||||
return apiRequest<AppVersionDto, AppVersionPayload>(apiEndpointPath(API_OPERATIONS.createAppVersion), {
|
return apiRequest<AppVersionDto, AppVersionPayload>(apiEndpointPath(API_OPERATIONS.createAppVersion), {
|
||||||
body: payload,
|
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 }),
|
apiEndpointPath(API_OPERATIONS.updateAppVersion, { version_id: versionId }),
|
||||||
{
|
{
|
||||||
body: payload,
|
body: payload,
|
||||||
method: endpoint.method
|
method: endpoint.method,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteAppVersion(versionId: EntityId): Promise<{ deleted: boolean }> {
|
export function deleteAppVersion(versionId: EntityId): Promise<{ deleted: boolean }> {
|
||||||
const endpoint = API_ENDPOINTS.deleteAppVersion;
|
const endpoint = API_ENDPOINTS.deleteAppVersion;
|
||||||
return apiRequest<{ deleted: boolean }>(apiEndpointPath(API_OPERATIONS.deleteAppVersion, { version_id: versionId }), {
|
return apiRequest<{ deleted: boolean }>(
|
||||||
method: endpoint.method
|
apiEndpointPath(API_OPERATIONS.deleteAppVersion, { version_id: versionId }),
|
||||||
});
|
{
|
||||||
|
method: endpoint.method,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,11 +24,153 @@
|
|||||||
object-fit: cover;
|
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 {
|
.formWideField {
|
||||||
grid-column: 1 / -1;
|
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 {
|
.statusCell {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
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 { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
|
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
|
||||||
import { RegionSelect } from "@/shared/ui/RegionSelect.jsx";
|
import { RegionSelect } from "@/shared/ui/RegionSelect.jsx";
|
||||||
|
import { AppJumpConfigField } from "@/shared/ui/AppJumpConfigField.jsx";
|
||||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||||
import { UploadField } from "@/shared/ui/UploadField.jsx";
|
import { UploadField } from "@/shared/ui/UploadField.jsx";
|
||||||
@ -116,17 +117,6 @@ export function BannerConfigPage() {
|
|||||||
</AdminFormSection>
|
</AdminFormSection>
|
||||||
<AdminFormSection title="跳转信息">
|
<AdminFormSection title="跳转信息">
|
||||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
<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
|
<TextField
|
||||||
disabled={!page.abilities.canUpdate}
|
disabled={!page.abilities.canUpdate}
|
||||||
label="平台"
|
label="平台"
|
||||||
@ -138,13 +128,16 @@ export function BannerConfigPage() {
|
|||||||
<MenuItem value="android">安卓</MenuItem>
|
<MenuItem value="android">安卓</MenuItem>
|
||||||
<MenuItem value="ios">iOS</MenuItem>
|
<MenuItem value="ios">iOS</MenuItem>
|
||||||
</TextField>
|
</TextField>
|
||||||
<TextField
|
<AppJumpConfigField
|
||||||
|
appParamLabel="参数(APP公共跳转JSON)"
|
||||||
className={styles.formWideField}
|
className={styles.formWideField}
|
||||||
disabled={!page.abilities.canUpdate}
|
disabled={!page.abilities.canUpdate}
|
||||||
label={page.form.bannerType === "h5" ? "参数(H5链接)" : "参数"}
|
h5Label="参数(H5链接)"
|
||||||
required={page.form.bannerType === "h5"}
|
paramValue={page.form.param}
|
||||||
value={page.form.param}
|
required
|
||||||
onChange={(event) => page.setForm({ param: event.target.value })}
|
typeLabel="类型"
|
||||||
|
typeValue={page.form.bannerType}
|
||||||
|
onChange={({ param, type }) => page.setForm({ bannerType: type, param })}
|
||||||
/>
|
/>
|
||||||
</AdminFormFieldGrid>
|
</AdminFormFieldGrid>
|
||||||
</AdminFormSection>
|
</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;
|
||||||
|
}
|
||||||
@ -7,7 +7,7 @@ export const appConfigRoutes = [
|
|||||||
menuCode: MENU_CODES.appConfigH5,
|
menuCode: MENU_CODES.appConfigH5,
|
||||||
pageKey: "app-config-h5",
|
pageKey: "app-config-h5",
|
||||||
path: "/app-config/h5",
|
path: "/app-config/h5",
|
||||||
permission: PERMISSIONS.appConfigView
|
permission: PERMISSIONS.appConfigView,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "BANNER配置",
|
label: "BANNER配置",
|
||||||
@ -15,7 +15,23 @@ export const appConfigRoutes = [
|
|||||||
menuCode: MENU_CODES.appConfigBanners,
|
menuCode: MENU_CODES.appConfigBanners,
|
||||||
pageKey: "app-config-banners",
|
pageKey: "app-config-banners",
|
||||||
path: "/app-config/banners",
|
path: "/app-config/banners",
|
||||||
permission: PERMISSIONS.appConfigView
|
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配置",
|
label: "Explore配置",
|
||||||
@ -23,16 +39,7 @@ export const appConfigRoutes = [
|
|||||||
menuCode: MENU_CODES.appConfigExplore,
|
menuCode: MENU_CODES.appConfigExplore,
|
||||||
pageKey: "app-config-explore",
|
pageKey: "app-config-explore",
|
||||||
path: "/app-config/explore",
|
path: "/app-config/explore",
|
||||||
permission: PERMISSIONS.appConfigView
|
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: "版本管理",
|
label: "版本管理",
|
||||||
@ -40,6 +47,6 @@ export const appConfigRoutes = [
|
|||||||
menuCode: MENU_CODES.appConfigVersions,
|
menuCode: MENU_CODES.appConfigVersions,
|
||||||
pageKey: "app-config-versions",
|
pageKey: "app-config-versions",
|
||||||
path: "/app-config/versions",
|
path: "/app-config/versions",
|
||||||
permission: PERMISSIONS.appVersionView
|
permission: PERMISSIONS.appVersionView,
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -1,5 +1,11 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
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";
|
import { FormValidationError, parseForm } from "@/shared/forms/validation";
|
||||||
|
|
||||||
const validBannerForm = {
|
const validBannerForm = {
|
||||||
@ -19,6 +25,34 @@ const validBannerForm = {
|
|||||||
status: "active",
|
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", () => {
|
describe("app config form schema", () => {
|
||||||
test("validates dynamic h5 config item", () => {
|
test("validates dynamic h5 config item", () => {
|
||||||
const payload = parseForm(h5LinkUpdateSchema, {
|
const payload = parseForm(h5LinkUpdateSchema, {
|
||||||
@ -49,6 +83,27 @@ describe("app config form schema", () => {
|
|||||||
expect(payload.endsAtMs).toBe(1800000000000);
|
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", () => {
|
test("accepts multiple banner display scopes", () => {
|
||||||
const payload = parseForm(appBannerSchema, {
|
const payload = parseForm(appBannerSchema, {
|
||||||
...validBannerForm,
|
...validBannerForm,
|
||||||
@ -79,6 +134,106 @@ describe("app config form schema", () => {
|
|||||||
).toThrow(FormValidationError);
|
).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", () => {
|
test("validates explore tab h5 url and sort order", () => {
|
||||||
const payload = parseForm(exploreTabSchema, {
|
const payload = parseForm(exploreTabSchema, {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { validatePublicAppJumpParam } from "@/shared/app-jump/appJumpConfig.js";
|
||||||
|
|
||||||
const appBannerDisplayScopes = ["home", "room", "recharge"] as const;
|
const appBannerDisplayScopes = ["home", "room", "recharge"] as const;
|
||||||
|
|
||||||
@ -70,6 +71,12 @@ export const appBannerSchema = z
|
|||||||
if (value.bannerType === "h5" && /\s/.test(value.param)) {
|
if (value.bannerType === "h5" && /\s/.test(value.param)) {
|
||||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "H5 链接不能包含空白字符", path: ["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) {
|
if (value.displayScopes.includes("room") && !value.roomSmallImageUrl) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
@ -84,6 +91,89 @@ export const appBannerSchema = z
|
|||||||
|
|
||||||
export type AppBannerForm = z.infer<typeof appBannerSchema>;
|
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({
|
export const appVersionSchema = z.object({
|
||||||
buildNumber: z.coerce.number().int("编译版本号必须是整数").positive("编译版本号必须大于 0"),
|
buildNumber: z.coerce.number().int("编译版本号必须是整数").positive("编译版本号必须大于 0"),
|
||||||
description: z.string().trim().max(1000, "更新描述不能超过 1000 个字符").default(""),
|
description: z.string().trim().max(1000, "更新描述不能超过 1000 个字符").default(""),
|
||||||
|
|||||||
@ -1,12 +1,65 @@
|
|||||||
import { afterEach, expect, test, vi } from "vitest";
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
import { setAccessToken } from "@/shared/api/request";
|
import { setAccessToken } from "@/shared/api/request";
|
||||||
import { listAppUsers } from "./api";
|
import { getAppUser, listAppUsers, listPrettyDisplayIDs } from "./api";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
setAccessToken("");
|
setAccessToken("");
|
||||||
vi.unstubAllGlobals();
|
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 () => {
|
test("listAppUsers sends country region and time filters", async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
@ -33,3 +86,76 @@ test("listAppUsers sends country region and time filters", async () => {
|
|||||||
expect(String(url)).toContain("end_ms=2000");
|
expect(String(url)).toContain("end_ms=2000");
|
||||||
expect(init?.method).toBe("GET");
|
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,
|
AppUserDto,
|
||||||
AppUserPasswordPayload,
|
AppUserPasswordPayload,
|
||||||
AppUserUpdatePayload,
|
AppUserUpdatePayload,
|
||||||
|
AdminGrantPrettyDisplayIDResultDto,
|
||||||
EntityId,
|
EntityId,
|
||||||
|
GeneratePrettyDisplayIDsPayload,
|
||||||
|
GrantPrettyDisplayIDPayload,
|
||||||
PageQuery,
|
PageQuery,
|
||||||
|
PrettyDisplayIDDto,
|
||||||
|
PrettyDisplayIDGenerationBatchDto,
|
||||||
|
PrettyDisplayIDPoolDto,
|
||||||
|
PrettyDisplayIDPoolPayload,
|
||||||
|
SetPrettyDisplayIDStatusPayload,
|
||||||
} from "@/shared/api/types";
|
} 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;
|
const endpoint = API_ENDPOINTS.appListUsers;
|
||||||
return apiRequest<ApiPage<AppUserDto>>(apiEndpointPath(API_OPERATIONS.appListUsers), {
|
const data = await apiRequest<ApiPage<RawAppUser> & { page_size?: number }>(
|
||||||
|
apiEndpointPath(API_OPERATIONS.appListUsers),
|
||||||
|
{
|
||||||
method: endpoint.method,
|
method: endpoint.method,
|
||||||
query,
|
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;
|
const endpoint = API_ENDPOINTS.appListLoginLogs;
|
||||||
return apiRequest<ApiPage<AppUserLoginLogDto>>(apiEndpointPath(API_OPERATIONS.appListLoginLogs), {
|
const data = await apiRequest<ApiPage<RawAppUserLoginLog> & { page_size?: number }>(
|
||||||
|
apiEndpointPath(API_OPERATIONS.appListLoginLogs),
|
||||||
|
{
|
||||||
method: endpoint.method,
|
method: endpoint.method,
|
||||||
query,
|
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;
|
const endpoint = API_ENDPOINTS.appListBannedUsers;
|
||||||
return apiRequest<ApiPage<AppUserBanRecordDto>>(apiEndpointPath(API_OPERATIONS.appListBannedUsers), {
|
const data = await apiRequest<ApiPage<RawAppUserBanRecord> & { page_size?: number }>(
|
||||||
|
apiEndpointPath(API_OPERATIONS.appListBannedUsers),
|
||||||
|
{
|
||||||
method: endpoint.method,
|
method: endpoint.method,
|
||||||
query,
|
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;
|
const endpoint = API_ENDPOINTS.appUpdateUser;
|
||||||
return apiRequest<AppUserDto, AppUserUpdatePayload>(apiEndpointPath(API_OPERATIONS.appUpdateUser, { id: userId }), {
|
const data = await apiRequest<RawAppUser, AppUserUpdatePayload>(
|
||||||
|
apiEndpointPath(API_OPERATIONS.appUpdateUser, { id: userId }),
|
||||||
|
{
|
||||||
body: payload,
|
body: payload,
|
||||||
method: endpoint.method,
|
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;
|
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,
|
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;
|
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,
|
method: endpoint.method,
|
||||||
});
|
});
|
||||||
|
return normalizeAppUser(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setAppUserPassword(
|
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 ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -30,6 +30,100 @@
|
|||||||
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 {
|
.toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -49,6 +143,13 @@
|
|||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbarActions {
|
||||||
|
display: inline-flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
.selectedMeta {
|
.selectedMeta {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: var(--admin-font-size);
|
font-size: var(--admin-font-size);
|
||||||
@ -120,7 +221,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.search {
|
.search {
|
||||||
flex: 0 1 320px;
|
flex: 1 1 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compactControl {
|
||||||
|
flex: 0 1 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userIDControl {
|
||||||
|
flex: 0 1 130px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statusSelect {
|
.statusSelect {
|
||||||
@ -167,6 +276,19 @@
|
|||||||
object-fit: cover;
|
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 {
|
.identityText {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@ -215,11 +337,74 @@
|
|||||||
.meta {
|
.meta {
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
font-size: var(--admin-font-size);
|
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 {
|
.stack {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 2px;
|
gap: 1px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.primaryText {
|
.primaryText {
|
||||||
@ -256,18 +441,47 @@
|
|||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.formGridTwo {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
|
.prettyWorkspace {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prettyPoolPane {
|
||||||
|
min-height: 42dvh;
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prettyIDPane {
|
||||||
|
min-height: 50dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prettyPaneHeader,
|
||||||
.toolbar,
|
.toolbar,
|
||||||
.toolbarLeft,
|
.toolbarLeft,
|
||||||
.filters {
|
.toolbarActions,
|
||||||
|
.filters,
|
||||||
|
.prettyFilters {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search,
|
.search,
|
||||||
|
.compactControl,
|
||||||
|
.userIDControl,
|
||||||
.statusSelect,
|
.statusSelect,
|
||||||
.timeFilter {
|
.timeFilter {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
width: 100%;
|
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 = [
|
export const appUserStatusFilters = [
|
||||||
["", "全部"],
|
["", "全部"],
|
||||||
["active", "正常"],
|
["active", "正常"],
|
||||||
["banned", "封禁"]
|
["banned", "封禁"],
|
||||||
];
|
];
|
||||||
|
|
||||||
export const appUserStatusLabels = {
|
export const appUserStatusLabels = {
|
||||||
active: "正常",
|
active: "正常",
|
||||||
banned: "封禁",
|
banned: "封禁",
|
||||||
disabled: "封禁"
|
disabled: "封禁",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const genderOptions = [
|
export const genderOptions = [
|
||||||
["", "未设置"],
|
["", "未设置"],
|
||||||
["male", "男"],
|
["male", "男"],
|
||||||
["female", "女"],
|
["female", "女"],
|
||||||
["unknown", "未知"]
|
["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 { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
|
||||||
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
||||||
import { useToast } from "@/shared/ui/ToastProvider.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 { useAppUserAbilities } from "@/features/app-users/permissions.js";
|
||||||
import { appUserCountrySchema, appUserPasswordSchema, appUserUpdateSchema } from "@/features/app-users/schema";
|
import { appUserCountrySchema, appUserPasswordSchema, appUserUpdateSchema } from "@/features/app-users/schema";
|
||||||
import { useCountryOptions } from "@/features/host-org/hooks/useCountryOptions.js";
|
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)),
|
items: items.map((user) => (patchedUsers[user.userId] ? { ...user, ...patchedUsers[user.userId] } : user)),
|
||||||
};
|
};
|
||||||
}, [patchedUsers, queryData]);
|
}, [patchedUsers, queryData]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedUserIds([]);
|
setSelectedUserIds([]);
|
||||||
setPatchedUsers({});
|
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 { useCallback, useMemo, useState } from "react";
|
||||||
|
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
import { createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
import { createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
@ -122,14 +122,7 @@ export function AppUserBanListPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TargetIdentity({ target }) {
|
function TargetIdentity({ target }) {
|
||||||
return (
|
return <AdminUserIdentity openInAppUserDetail rows={[target?.displayUserId, target?.userId]} user={target} />;
|
||||||
<UserIdentity
|
|
||||||
avatar={target?.avatar}
|
|
||||||
name={target?.username}
|
|
||||||
primaryFallback={target?.displayUserId || target?.userId}
|
|
||||||
rows={[target?.displayUserId, target?.userId]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function OperatorIdentity({ operator }) {
|
function OperatorIdentity({ operator }) {
|
||||||
@ -138,52 +131,26 @@ function OperatorIdentity({ operator }) {
|
|||||||
}
|
}
|
||||||
if (operator.type === "admin") {
|
if (operator.type === "admin") {
|
||||||
return (
|
return (
|
||||||
<UserIdentity
|
<AdminUserIdentity
|
||||||
name={operator.account}
|
name={operator.account}
|
||||||
primaryFallback={operator.adminId}
|
|
||||||
rows={[operator.name, operator.adminId ? `Admin ID ${operator.adminId}` : "后台管理员"]}
|
rows={[operator.name, operator.adminId ? `Admin ID ${operator.adminId}` : "后台管理员"]}
|
||||||
|
user={operator}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (operator.type === "app_user") {
|
if (operator.type === "app_user") {
|
||||||
return (
|
return (
|
||||||
<UserIdentity
|
<AdminUserIdentity
|
||||||
avatar={operator.avatar}
|
openInAppUserDetail
|
||||||
name={operator.name}
|
name={operator.name}
|
||||||
primaryFallback={operator.displayUserId || operator.userId}
|
|
||||||
rows={[operator.displayUserId, operator.userId]}
|
rows={[operator.displayUserId, operator.userId]}
|
||||||
|
user={operator}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <span className="muted">system</span>;
|
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 }) {
|
function UserStatus({ status }) {
|
||||||
return (
|
return (
|
||||||
<span className="status-badge status-badge--danger">
|
<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 KeyboardArrowUpOutlined from "@mui/icons-material/KeyboardArrowUpOutlined";
|
||||||
import LockOpenOutlined from "@mui/icons-material/LockOpenOutlined";
|
import LockOpenOutlined from "@mui/icons-material/LockOpenOutlined";
|
||||||
import PasswordOutlined from "@mui/icons-material/PasswordOutlined";
|
import PasswordOutlined from "@mui/icons-material/PasswordOutlined";
|
||||||
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
|
|
||||||
import UnfoldMoreOutlined from "@mui/icons-material/UnfoldMoreOutlined";
|
import UnfoldMoreOutlined from "@mui/icons-material/UnfoldMoreOutlined";
|
||||||
import Autocomplete from "@mui/material/Autocomplete";
|
import Autocomplete from "@mui/material/Autocomplete";
|
||||||
import Checkbox from "@mui/material/Checkbox";
|
import Checkbox from "@mui/material/Checkbox";
|
||||||
@ -12,6 +11,7 @@ import MenuItem from "@mui/material/MenuItem";
|
|||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import Tooltip from "@mui/material/Tooltip";
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
import { AdminFormDialog, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
|
import { AdminFormDialog, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
|
||||||
|
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
||||||
import { Button } from "@/shared/ui/Button.jsx";
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { DataTable } from "@/shared/ui/DataTable.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 { useAppUsersPage } from "@/features/app-users/hooks/useAppUsersPage.js";
|
||||||
import styles from "@/features/app-users/app-users.module.css";
|
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() {
|
export function AppUserListPage() {
|
||||||
const page = useAppUsersPage();
|
const page = useAppUsersPage();
|
||||||
const items = page.data.items || [];
|
const items = page.data.items || [];
|
||||||
@ -49,7 +40,9 @@ export function AppUserListPage() {
|
|||||||
);
|
);
|
||||||
return;
|
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 = [
|
const tableColumns = [
|
||||||
{
|
{
|
||||||
@ -85,12 +78,15 @@ export function AppUserListPage() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
...columns[0],
|
key: "identity",
|
||||||
|
label: "用户",
|
||||||
|
width: "minmax(240px, 1.5fr)",
|
||||||
filter: createTextColumnFilter({
|
filter: createTextColumnFilter({
|
||||||
placeholder: "搜索名称、短 ID、用户 ID",
|
placeholder: "搜索名称、短 ID、用户 ID",
|
||||||
value: page.query,
|
value: page.query,
|
||||||
onChange: page.changeQuery,
|
onChange: page.changeQuery,
|
||||||
}),
|
}),
|
||||||
|
render: (user) => <AdminUserIdentity openInAppUserDetail user={user} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "location",
|
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 }) {
|
function UserActions({ page, user }) {
|
||||||
const banned = user.status === "banned" || user.status === "disabled";
|
const banned = user.status === "banned" || user.status === "disabled";
|
||||||
return (
|
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) {
|
function formatNumber(value) {
|
||||||
return Number(value || 0).toLocaleString("zh-CN");
|
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 { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { toPageQuery } from "@/shared/api/query";
|
import { toPageQuery } from "@/shared/api/query";
|
||||||
|
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
||||||
@ -45,7 +45,12 @@ export function AppUserLoginLogsPage() {
|
|||||||
[page, query, regionId, result, userFilter],
|
[page, query, regionId, result, userFilter],
|
||||||
);
|
);
|
||||||
const loadLogs = useCallback(() => listAppUserLoginLogs(requestQuery), [requestQuery]);
|
const loadLogs = useCallback(() => listAppUserLoginLogs(requestQuery), [requestQuery]);
|
||||||
const { data, error, loading: queryLoading, reload } = useAdminQuery(loadLogs, {
|
const {
|
||||||
|
data,
|
||||||
|
error,
|
||||||
|
loading: queryLoading,
|
||||||
|
reload,
|
||||||
|
} = useAdminQuery(loadLogs, {
|
||||||
errorMessage: "加载用户登录日志失败",
|
errorMessage: "加载用户登录日志失败",
|
||||||
initialData: emptyData,
|
initialData: emptyData,
|
||||||
keepPreviousData: true,
|
keepPreviousData: true,
|
||||||
@ -149,7 +154,7 @@ export function AppUserLoginLogsPage() {
|
|||||||
value: query,
|
value: query,
|
||||||
onChange: changeIdentityFilter,
|
onChange: changeIdentityFilter,
|
||||||
}),
|
}),
|
||||||
render: (log) => <UserIdentity log={log} />,
|
render: (log) => <AdminUserIdentity openInAppUserDetail user={log} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "ip",
|
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 }) {
|
function ResultBadge({ log }) {
|
||||||
const result = String(log.result || "").toLowerCase();
|
const result = String(log.result || "").toLowerCase();
|
||||||
const blocked = log.blocked || result === "blocked";
|
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;
|
||||||
|
}
|
||||||
@ -6,9 +6,13 @@ export function useAppUserAbilities() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
canPassword: can(PERMISSIONS.appUserPassword),
|
canPassword: can(PERMISSIONS.appUserPassword),
|
||||||
|
canPrettyGenerate: can(PERMISSIONS.prettyIdGenerate),
|
||||||
|
canPrettyGrant: can(PERMISSIONS.prettyIdGrant),
|
||||||
|
canPrettyUpdate: can(PERMISSIONS.prettyIdUpdate),
|
||||||
|
canPrettyView: can(PERMISSIONS.prettyIdView),
|
||||||
canStatus: can(PERMISSIONS.appUserStatus),
|
canStatus: can(PERMISSIONS.appUserStatus),
|
||||||
canUpdate: can(PERMISSIONS.appUserUpdate),
|
canUpdate: can(PERMISSIONS.appUserUpdate),
|
||||||
canUpload: can(PERMISSIONS.uploadCreate),
|
canUpload: can(PERMISSIONS.uploadCreate),
|
||||||
canView: can(PERMISSIONS.appUserView)
|
canView: can(PERMISSIONS.appUserView),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,4 +25,12 @@ export const appUserRoutes = [
|
|||||||
path: "/app/users/bans",
|
path: "/app/users/bans",
|
||||||
permission: PERMISSIONS.appUserView,
|
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,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -3,17 +3,118 @@ import { z } from "zod";
|
|||||||
export const appUserUpdateSchema = z.object({
|
export const appUserUpdateSchema = z.object({
|
||||||
avatar: z.string().trim().max(512, "头像不能超过 512 个字符").optional().default(""),
|
avatar: z.string().trim().max(512, "头像不能超过 512 个字符").optional().default(""),
|
||||||
gender: z.string().trim().max(32, "性别不能超过 32 个字符").optional().default(""),
|
gender: z.string().trim().max(32, "性别不能超过 32 个字符").optional().default(""),
|
||||||
username: z.string().trim().max(64, "用户名称不能超过 64 个字符").optional().default("")
|
username: z.string().trim().max(64, "用户名称不能超过 64 个字符").optional().default(""),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const appUserCountrySchema = z.object({
|
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({
|
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 AppUserUpdateForm = z.infer<typeof appUserUpdateSchema>;
|
||||||
export type AppUserCountryForm = z.infer<typeof appUserCountrySchema>;
|
export type AppUserCountryForm = z.infer<typeof appUserCountrySchema>;
|
||||||
export type AppUserPasswordForm = z.infer<typeof appUserPasswordSchema>;
|
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 {
|
export interface CumulativeRechargeRewardGrantUserDto {
|
||||||
userId?: number;
|
userId?: number;
|
||||||
displayUserId?: string;
|
displayUserId?: string;
|
||||||
|
prettyDisplayUserId?: string;
|
||||||
|
prettyId?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
}
|
}
|
||||||
@ -145,6 +147,8 @@ function normalizeGrant(item: RawGrant): CumulativeRechargeRewardGrantDto {
|
|||||||
user: {
|
user: {
|
||||||
userId: numberValue(rawUser.userId ?? rawUser.user_id),
|
userId: numberValue(rawUser.userId ?? rawUser.user_id),
|
||||||
displayUserId: stringValue(rawUser.displayUserId ?? rawUser.display_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),
|
username: stringValue(rawUser.username),
|
||||||
avatar: stringValue(rawUser.avatar),
|
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 { CumulativeRechargeRewardConfigDrawer } from "@/features/cumulative-recharge-reward/components/CumulativeRechargeRewardConfigDrawer.jsx";
|
||||||
import { CumulativeRechargeRewardConfigSummary } from "@/features/cumulative-recharge-reward/components/CumulativeRechargeRewardConfigSummary.jsx";
|
import { CumulativeRechargeRewardConfigSummary } from "@/features/cumulative-recharge-reward/components/CumulativeRechargeRewardConfigSummary.jsx";
|
||||||
import { useCumulativeRechargeRewardPage } from "@/features/cumulative-recharge-reward/hooks/useCumulativeRechargeRewardPage.js";
|
import { useCumulativeRechargeRewardPage } from "@/features/cumulative-recharge-reward/hooks/useCumulativeRechargeRewardPage.js";
|
||||||
import styles from "@/features/cumulative-recharge-reward/cumulative-recharge-reward.module.css";
|
import styles from "@/features/cumulative-recharge-reward/cumulative-recharge-reward.module.css";
|
||||||
import { AdminListBody, AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
|
import { AdminListBody, AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
|
||||||
|
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
@ -179,21 +179,7 @@ export function CumulativeRechargeRewardPage() {
|
|||||||
|
|
||||||
function GrantUser({ grant }) {
|
function GrantUser({ grant }) {
|
||||||
const user = grant.user || {};
|
const user = grant.user || {};
|
||||||
return (
|
return <AdminUserIdentity openInAppUserDetail user={{ ...user, userId: user.userId || grant.userId }} />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function grantStatusLabel(status) {
|
function grantStatusLabel(status) {
|
||||||
|
|||||||
@ -9,6 +9,13 @@ export interface TaskDefinitionDto {
|
|||||||
metricType: string;
|
metricType: string;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
audienceType: string;
|
||||||
|
iconKey: string;
|
||||||
|
iconUrl: string;
|
||||||
|
actionType: string;
|
||||||
|
actionParam: string;
|
||||||
|
actionPayloadJson: string;
|
||||||
|
dimensionFilterJson: string;
|
||||||
targetValue: number;
|
targetValue: number;
|
||||||
targetUnit: string;
|
targetUnit: string;
|
||||||
rewardCoinAmount: number;
|
rewardCoinAmount: number;
|
||||||
@ -29,6 +36,13 @@ export interface TaskDefinitionPayload {
|
|||||||
metric_type: string;
|
metric_type: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: 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_value: number;
|
||||||
target_unit: string;
|
target_unit: string;
|
||||||
reward_coin_amount: number;
|
reward_coin_amount: number;
|
||||||
@ -100,6 +114,13 @@ function normalizeTaskDefinition(task: RawTaskDefinitionDto): TaskDefinitionDto
|
|||||||
metricType: stringValue(task.metricType ?? task.metric_type),
|
metricType: stringValue(task.metricType ?? task.metric_type),
|
||||||
title: stringValue(task.title),
|
title: stringValue(task.title),
|
||||||
description: stringValue(task.description),
|
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),
|
targetValue: numberValue(task.targetValue ?? task.target_value),
|
||||||
targetUnit: stringValue(task.targetUnit ?? task.target_unit),
|
targetUnit: stringValue(task.targetUnit ?? task.target_unit),
|
||||||
rewardCoinAmount: numberValue(task.rewardCoinAmount ?? task.reward_coin_amount),
|
rewardCoinAmount: numberValue(task.rewardCoinAmount ?? task.reward_coin_amount),
|
||||||
|
|||||||
@ -29,3 +29,47 @@ export const taskUnitLabels = {
|
|||||||
count: "次数",
|
count: "次数",
|
||||||
minute: "分钟",
|
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;
|
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 {
|
.statusCell {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-1);
|
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,14 +10,23 @@ import {
|
|||||||
} from "@/features/daily-tasks/api";
|
} from "@/features/daily-tasks/api";
|
||||||
import { useDailyTaskAbilities } from "@/features/daily-tasks/permissions.js";
|
import { useDailyTaskAbilities } from "@/features/daily-tasks/permissions.js";
|
||||||
import { dailyTaskFormSchema } from "@/features/daily-tasks/schema";
|
import { dailyTaskFormSchema } from "@/features/daily-tasks/schema";
|
||||||
|
import { buildAppJumpParam } from "@/shared/app-jump/appJumpConfig.js";
|
||||||
|
|
||||||
const pageSize = 50;
|
const pageSize = 50;
|
||||||
const emptyData = { items: [], page: 1, pageSize, total: 0 };
|
const emptyData = { items: [], page: 1, pageSize, total: 0 };
|
||||||
const emptyForm = (task = {}) => ({
|
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 || "",
|
category: task.category || "",
|
||||||
description: task.description || "",
|
description: task.description || "",
|
||||||
effectiveFrom: msToDatetimeLocal(task.effectiveFromMs),
|
effectiveFrom: msToDatetimeLocal(task.effectiveFromMs),
|
||||||
effectiveTo: msToDatetimeLocal(task.effectiveToMs),
|
effectiveTo: msToDatetimeLocal(task.effectiveToMs),
|
||||||
|
iconUrl: task.iconUrl || "",
|
||||||
metricType: task.metricType || "",
|
metricType: task.metricType || "",
|
||||||
rewardCoinAmount: task.rewardCoinAmount === 0 || task.rewardCoinAmount ? String(task.rewardCoinAmount) : "",
|
rewardCoinAmount: task.rewardCoinAmount === 0 || task.rewardCoinAmount ? String(task.rewardCoinAmount) : "",
|
||||||
sortOrder: task.sortOrder === 0 || task.sortOrder ? String(task.sortOrder) : "0",
|
sortOrder: task.sortOrder === 0 || task.sortOrder ? String(task.sortOrder) : "0",
|
||||||
@ -26,7 +35,8 @@ const emptyForm = (task = {}) => ({
|
|||||||
targetValue: task.targetValue === 0 || task.targetValue ? String(task.targetValue) : "",
|
targetValue: task.targetValue === 0 || task.targetValue ? String(task.targetValue) : "",
|
||||||
taskType: task.taskType || "daily",
|
taskType: task.taskType || "daily",
|
||||||
title: task.title || "",
|
title: task.title || "",
|
||||||
});
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export function useDailyTasksPage() {
|
export function useDailyTasksPage() {
|
||||||
const abilities = useDailyTaskAbilities();
|
const abilities = useDailyTaskAbilities();
|
||||||
@ -34,7 +44,6 @@ export function useDailyTasksPage() {
|
|||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [status, setStatus] = useState("");
|
const [status, setStatus] = useState("");
|
||||||
const [taskType, setTaskType] = useState("");
|
const [taskType, setTaskType] = useState("");
|
||||||
const [category, setCategory] = useState("");
|
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [activeAction, setActiveAction] = useState("");
|
const [activeAction, setActiveAction] = useState("");
|
||||||
const [selectedTask, setSelectedTask] = useState(null);
|
const [selectedTask, setSelectedTask] = useState(null);
|
||||||
@ -42,12 +51,11 @@ export function useDailyTasksPage() {
|
|||||||
const [loadingAction, setLoadingAction] = useState("");
|
const [loadingAction, setLoadingAction] = useState("");
|
||||||
const filters = useMemo(
|
const filters = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
category,
|
|
||||||
keyword: query,
|
keyword: query,
|
||||||
status,
|
status,
|
||||||
task_type: taskType,
|
task_type: taskType,
|
||||||
}),
|
}),
|
||||||
[category, query, status, taskType],
|
[query, status, taskType],
|
||||||
);
|
);
|
||||||
const {
|
const {
|
||||||
data = emptyData,
|
data = emptyData,
|
||||||
@ -66,13 +74,11 @@ export function useDailyTasksPage() {
|
|||||||
const changeQuery = resetSetter(setQuery, setPage);
|
const changeQuery = resetSetter(setQuery, setPage);
|
||||||
const changeStatus = resetSetter(setStatus, setPage);
|
const changeStatus = resetSetter(setStatus, setPage);
|
||||||
const changeTaskType = resetSetter(setTaskType, setPage);
|
const changeTaskType = resetSetter(setTaskType, setPage);
|
||||||
const changeCategory = resetSetter(setCategory, setPage);
|
|
||||||
|
|
||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
setQuery("");
|
setQuery("");
|
||||||
setStatus("");
|
setStatus("");
|
||||||
setTaskType("");
|
setTaskType("");
|
||||||
setCategory("");
|
|
||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -96,6 +102,16 @@ export function useDailyTasksPage() {
|
|||||||
setSelectedTask(null);
|
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) => {
|
const submitTask = async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const editing = activeAction === "edit";
|
const editing = activeAction === "edit";
|
||||||
@ -144,8 +160,8 @@ export function useDailyTasksPage() {
|
|||||||
return {
|
return {
|
||||||
abilities,
|
abilities,
|
||||||
activeAction,
|
activeAction,
|
||||||
category,
|
appJumpTypeFromParam,
|
||||||
changeCategory,
|
changeFormActionType,
|
||||||
changeQuery,
|
changeQuery,
|
||||||
changeStatus,
|
changeStatus,
|
||||||
changeTaskType,
|
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 {
|
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(),
|
description: form.description.trim(),
|
||||||
|
dimension_filter_json: "{}",
|
||||||
effective_from_ms: datetimeLocalToMs(form.effectiveFrom),
|
effective_from_ms: datetimeLocalToMs(form.effectiveFrom),
|
||||||
effective_to_ms: datetimeLocalToMs(form.effectiveTo),
|
effective_to_ms: datetimeLocalToMs(form.effectiveTo),
|
||||||
|
icon_key: "",
|
||||||
|
icon_url: form.iconUrl.trim(),
|
||||||
metric_type: form.metricType.trim(),
|
metric_type: form.metricType.trim(),
|
||||||
reward_coin_amount: Number(form.rewardCoinAmount),
|
reward_coin_amount: Number(form.rewardCoinAmount),
|
||||||
sort_order: Number(form.sortOrder || 0),
|
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) {
|
function resetSetter(setter, setPage) {
|
||||||
return (value) => {
|
return (value) => {
|
||||||
setter(value);
|
setter(value);
|
||||||
|
|||||||
@ -21,8 +21,13 @@ import {
|
|||||||
AdminRowActions,
|
AdminRowActions,
|
||||||
} from "@/shared/ui/AdminListLayout.jsx";
|
} from "@/shared/ui/AdminListLayout.jsx";
|
||||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
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 { formatMillis } from "@/shared/utils/time.js";
|
||||||
import {
|
import {
|
||||||
|
taskActionLabels,
|
||||||
|
taskActionOptions,
|
||||||
|
taskMetricOptions,
|
||||||
taskStatusLabels,
|
taskStatusLabels,
|
||||||
taskStatusOptions,
|
taskStatusOptions,
|
||||||
taskTypeOptions,
|
taskTypeOptions,
|
||||||
@ -46,7 +51,20 @@ const baseColumns = [
|
|||||||
render: (task) => (
|
render: (task) => (
|
||||||
<div className={styles.stack}>
|
<div className={styles.stack}>
|
||||||
<span>{task.metricType}</span>
|
<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>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -122,14 +140,7 @@ export function DailyTaskListPage() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (column.key === "metric") {
|
if (column.key === "metric") {
|
||||||
return {
|
return column;
|
||||||
...column,
|
|
||||||
filter: createTextColumnFilter({
|
|
||||||
placeholder: "搜索分类",
|
|
||||||
value: page.category,
|
|
||||||
onChange: page.changeCategory,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if (column.key === "status") {
|
if (column.key === "status") {
|
||||||
return {
|
return {
|
||||||
@ -164,7 +175,7 @@ export function DailyTaskListPage() {
|
|||||||
onChange={page.changeTaskType}
|
onChange={page.changeTaskType}
|
||||||
/>
|
/>
|
||||||
<AdminFilterResetButton
|
<AdminFilterResetButton
|
||||||
disabled={!page.query && !page.status && !page.taskType && !page.category}
|
disabled={!page.query && !page.status && !page.taskType}
|
||||||
onClick={page.resetFilters}
|
onClick={page.resetFilters}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@ -182,7 +193,7 @@ export function DailyTaskListPage() {
|
|||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
items={items}
|
items={items}
|
||||||
minWidth="1340px"
|
minWidth="1560px"
|
||||||
pagination={
|
pagination={
|
||||||
total > 0
|
total > 0
|
||||||
? {
|
? {
|
||||||
@ -214,6 +225,7 @@ export function DailyTaskListPage() {
|
|||||||
function TaskIdentity({ task }) {
|
function TaskIdentity({ task }) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.identity}>
|
<div className={styles.identity}>
|
||||||
|
<TaskIconPreview task={task} />
|
||||||
<span className={styles.typeBadge}>{taskTypeLabel(task.taskType)}</span>
|
<span className={styles.typeBadge}>{taskTypeLabel(task.taskType)}</span>
|
||||||
<div className={styles.identityText}>
|
<div className={styles.identityText}>
|
||||||
<span className={styles.name}>{task.title || task.taskId}</span>
|
<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 }) {
|
function TaskStatusSwitch({ page, task }) {
|
||||||
const checked = task.status === "active";
|
const checked = task.status === "active";
|
||||||
const disabled =
|
const disabled =
|
||||||
@ -299,13 +320,6 @@ function TaskFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
|
|||||||
value={form.title}
|
value={form.title}
|
||||||
onChange={(event) => page.setForm({ ...form, title: event.target.value })}
|
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>
|
</AdminFormFieldGrid>
|
||||||
</AdminFormSection>
|
</AdminFormSection>
|
||||||
<AdminFormSection title="目标与奖励">
|
<AdminFormSection title="目标与奖励">
|
||||||
@ -314,9 +328,16 @@ function TaskFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
label="指标类型"
|
label="指标类型"
|
||||||
required
|
required
|
||||||
|
select
|
||||||
value={form.metricType}
|
value={form.metricType}
|
||||||
onChange={(event) => page.setForm({ ...form, metricType: event.target.value })}
|
onChange={(event) => page.setForm({ ...form, metricType: event.target.value })}
|
||||||
/>
|
>
|
||||||
|
{taskMetricOptions.map(([value, label]) => (
|
||||||
|
<MenuItem key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
<TextField
|
<TextField
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
label="目标单位"
|
label="目标单位"
|
||||||
@ -356,6 +377,54 @@ function TaskFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
|
|||||||
/>
|
/>
|
||||||
</AdminFormFieldGrid>
|
</AdminFormFieldGrid>
|
||||||
</AdminFormSection>
|
</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="有效期">
|
<AdminFormSection title="有效期">
|
||||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||||
<TextField
|
<TextField
|
||||||
@ -398,6 +467,10 @@ function taskStatusLabel(value) {
|
|||||||
return taskStatusLabels[value] || value || "-";
|
return taskStatusLabels[value] || value || "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function taskActionLabel(value) {
|
||||||
|
return taskActionLabels[value] || value || "-";
|
||||||
|
}
|
||||||
|
|
||||||
function taskSwitchUncheckedLabel(status, mode) {
|
function taskSwitchUncheckedLabel(status, mode) {
|
||||||
if (status === "archived") {
|
if (status === "archived") {
|
||||||
return "归档";
|
return "归档";
|
||||||
|
|||||||
@ -8,6 +8,7 @@ export function useDailyTaskAbilities() {
|
|||||||
canCreate: can(PERMISSIONS.dailyTaskCreate),
|
canCreate: can(PERMISSIONS.dailyTaskCreate),
|
||||||
canStatus: can(PERMISSIONS.dailyTaskStatus),
|
canStatus: can(PERMISSIONS.dailyTaskStatus),
|
||||||
canUpdate: can(PERMISSIONS.dailyTaskUpdate),
|
canUpdate: can(PERMISSIONS.dailyTaskUpdate),
|
||||||
|
canUpload: can(PERMISSIONS.uploadCreate),
|
||||||
canView: can(PERMISSIONS.dailyTaskView),
|
canView: can(PERMISSIONS.dailyTaskView),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,30 @@
|
|||||||
import { z } from "zod";
|
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
|
export const dailyTaskFormSchema = z
|
||||||
.object({
|
.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 个字符"),
|
description: z.string().trim().max(512, "描述不能超过 512 个字符"),
|
||||||
effectiveFrom: z.string().optional(),
|
effectiveFrom: z.string().optional(),
|
||||||
effectiveTo: z.string().optional(),
|
effectiveTo: z.string().optional(),
|
||||||
|
iconUrl: z.string().trim().max(512, "图标 URL 不能超过 512 个字符"),
|
||||||
metricType: z.string().trim().min(1, "请输入指标类型").max(64, "指标类型不能超过 64 个字符"),
|
metricType: z.string().trim().min(1, "请输入指标类型").max(64, "指标类型不能超过 64 个字符"),
|
||||||
rewardCoinAmount: z.union([z.string(), z.number()]),
|
rewardCoinAmount: z.union([z.string(), z.number()]),
|
||||||
sortOrder: z.union([z.string(), z.number()]).optional(),
|
sortOrder: z.union([z.string(), z.number()]).optional(),
|
||||||
@ -40,6 +59,23 @@ export const dailyTaskFormSchema = z
|
|||||||
if (effectiveFromMs > 0 && effectiveToMs > 0 && effectiveToMs <= effectiveFromMs) {
|
if (effectiveFromMs > 0 && effectiveToMs > 0 && effectiveToMs <= effectiveFromMs) {
|
||||||
context.addIssue({ code: "custom", message: "结束时间必须晚于开始时间", path: ["effectiveTo"] });
|
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>;
|
export type DailyTaskForm = z.infer<typeof dailyTaskFormSchema>;
|
||||||
@ -52,3 +88,16 @@ function datetimeLocalToMs(value: unknown) {
|
|||||||
const parsed = new Date(trimmed).getTime();
|
const parsed = new Date(trimmed).getTime();
|
||||||
return Number.isFinite(parsed) ? parsed : -1;
|
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;
|
tierName: string;
|
||||||
minCoinAmount: number;
|
minCoinAmount: number;
|
||||||
maxCoinAmount: number;
|
maxCoinAmount: number;
|
||||||
|
usdMinorAmount: number;
|
||||||
|
googleProductId: string;
|
||||||
|
discountPercent: number;
|
||||||
resourceGroupId: number;
|
resourceGroupId: number;
|
||||||
status: string;
|
status: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
@ -32,6 +35,9 @@ export interface FirstRechargeRewardConfigPayload {
|
|||||||
tier_name: string;
|
tier_name: string;
|
||||||
min_coin_amount: number;
|
min_coin_amount: number;
|
||||||
max_coin_amount: number;
|
max_coin_amount: number;
|
||||||
|
usd_minor_amount: number;
|
||||||
|
google_product_id: string;
|
||||||
|
discount_percent: number;
|
||||||
resource_group_id: number;
|
resource_group_id: number;
|
||||||
status: string;
|
status: string;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
@ -41,6 +47,8 @@ export interface FirstRechargeRewardConfigPayload {
|
|||||||
export interface FirstRechargeRewardClaimUserDto {
|
export interface FirstRechargeRewardClaimUserDto {
|
||||||
userId?: number;
|
userId?: number;
|
||||||
displayUserId?: string;
|
displayUserId?: string;
|
||||||
|
prettyDisplayUserId?: string;
|
||||||
|
prettyId?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
}
|
}
|
||||||
@ -58,6 +66,9 @@ export interface FirstRechargeRewardClaimDto {
|
|||||||
rechargeCoinAmount?: number;
|
rechargeCoinAmount?: number;
|
||||||
rechargeSequence?: number;
|
rechargeSequence?: number;
|
||||||
rechargeType?: string;
|
rechargeType?: string;
|
||||||
|
tierUsdMinorAmount?: number;
|
||||||
|
googleProductId?: string;
|
||||||
|
rechargeUsdMinor?: number;
|
||||||
status?: string;
|
status?: string;
|
||||||
walletCommandId?: string;
|
walletCommandId?: string;
|
||||||
walletGrantId?: string;
|
walletGrantId?: string;
|
||||||
@ -122,6 +133,9 @@ function normalizeTier(item: RawTier): FirstRechargeRewardTierDto {
|
|||||||
tierName: stringValue(item.tierName ?? item.tier_name),
|
tierName: stringValue(item.tierName ?? item.tier_name),
|
||||||
minCoinAmount: numberValue(item.minCoinAmount ?? item.min_coin_amount),
|
minCoinAmount: numberValue(item.minCoinAmount ?? item.min_coin_amount),
|
||||||
maxCoinAmount: numberValue(item.maxCoinAmount ?? item.max_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),
|
resourceGroupId: numberValue(item.resourceGroupId ?? item.resource_group_id),
|
||||||
status: stringValue(item.status) || "active",
|
status: stringValue(item.status) || "active",
|
||||||
sortOrder: numberValue(item.sortOrder ?? item.sort_order),
|
sortOrder: numberValue(item.sortOrder ?? item.sort_order),
|
||||||
@ -141,6 +155,8 @@ function normalizeClaim(item: RawClaim): FirstRechargeRewardClaimDto {
|
|||||||
user: {
|
user: {
|
||||||
userId: numberValue(rawUser.userId ?? rawUser.user_id),
|
userId: numberValue(rawUser.userId ?? rawUser.user_id),
|
||||||
displayUserId: stringValue(rawUser.displayUserId ?? rawUser.display_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),
|
username: stringValue(rawUser.username),
|
||||||
avatar: stringValue(rawUser.avatar),
|
avatar: stringValue(rawUser.avatar),
|
||||||
},
|
},
|
||||||
@ -150,6 +166,9 @@ function normalizeClaim(item: RawClaim): FirstRechargeRewardClaimDto {
|
|||||||
rechargeCoinAmount: numberValue(item.rechargeCoinAmount ?? item.recharge_coin_amount),
|
rechargeCoinAmount: numberValue(item.rechargeCoinAmount ?? item.recharge_coin_amount),
|
||||||
rechargeSequence: numberValue(item.rechargeSequence ?? item.recharge_sequence),
|
rechargeSequence: numberValue(item.rechargeSequence ?? item.recharge_sequence),
|
||||||
rechargeType: stringValue(item.rechargeType ?? item.recharge_type),
|
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),
|
status: stringValue(item.status),
|
||||||
walletCommandId: stringValue(item.walletCommandId ?? item.wallet_command_id),
|
walletCommandId: stringValue(item.walletCommandId ?? item.wallet_command_id),
|
||||||
walletGrantId: stringValue(item.walletGrantId ?? item.wallet_grant_id),
|
walletGrantId: stringValue(item.walletGrantId ?? item.wallet_grant_id),
|
||||||
|
|||||||
@ -1,20 +1,13 @@
|
|||||||
import AddOutlined from "@mui/icons-material/AddOutlined";
|
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||||
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
|
||||||
import SaveOutlined from "@mui/icons-material/SaveOutlined";
|
import SaveOutlined from "@mui/icons-material/SaveOutlined";
|
||||||
import Drawer from "@mui/material/Drawer";
|
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 TextField from "@mui/material/TextField";
|
||||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||||
import { Button } from "@/shared/ui/Button.jsx";
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
|
import { RewardTierEditorTable } from "@/shared/ui/RewardTierEditorTable.jsx";
|
||||||
import { ResourceGroupSelectField } from "@/shared/ui/ResourceGroupSelectDrawer.jsx";
|
import { ResourceGroupSelectField } from "@/shared/ui/ResourceGroupSelectDrawer.jsx";
|
||||||
import styles from "@/features/first-recharge-reward/first-recharge-reward.module.css";
|
import styles from "@/features/first-recharge-reward/first-recharge-reward.module.css";
|
||||||
|
|
||||||
const statusOptions = [
|
|
||||||
["active", "启用"],
|
|
||||||
["inactive", "停用"],
|
|
||||||
];
|
|
||||||
|
|
||||||
export function FirstRechargeRewardConfigDrawer({
|
export function FirstRechargeRewardConfigDrawer({
|
||||||
abilities,
|
abilities,
|
||||||
configLoading,
|
configLoading,
|
||||||
@ -35,10 +28,11 @@ export function FirstRechargeRewardConfigDrawer({
|
|||||||
...(current.tiers || []),
|
...(current.tiers || []),
|
||||||
{
|
{
|
||||||
tierId: 0,
|
tierId: 0,
|
||||||
tierCode: "",
|
tierCode: `first_recharge_${Date.now()}_${(current.tiers || []).length + 1}`,
|
||||||
tierName: "",
|
tierName: "",
|
||||||
minCoinAmount: "",
|
usdAmount: "",
|
||||||
maxCoinAmount: "",
|
googleProductId: "",
|
||||||
|
discountPercent: "70",
|
||||||
resourceGroupId: "",
|
resourceGroupId: "",
|
||||||
status: "active",
|
status: "active",
|
||||||
sortOrder: String((current.tiers || []).length),
|
sortOrder: String((current.tiers || []).length),
|
||||||
@ -63,15 +57,16 @@ export function FirstRechargeRewardConfigDrawer({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer anchor="right" open={open} onClose={configSaving ? undefined : onClose}>
|
<Drawer anchor="right" open={open} onClose={configSaving ? undefined : onClose}>
|
||||||
<form className="form-drawer" onSubmit={onSubmit}>
|
<form className="form-drawer form-drawer--activity-config" onSubmit={onSubmit}>
|
||||||
<h2>首冲奖励配置</h2>
|
<h2>首充礼包配置</h2>
|
||||||
|
<div className="form-drawer__content">
|
||||||
<section className="form-drawer__section">
|
<section className="form-drawer__section">
|
||||||
<div className="form-drawer__section-title">发放状态</div>
|
<div className="form-drawer__section-title">发放状态</div>
|
||||||
<AdminSwitch
|
<AdminSwitch
|
||||||
checked={form.enabled}
|
checked={form.enabled}
|
||||||
checkedLabel="开启"
|
checkedLabel="开启"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
label="首冲奖励状态"
|
label="首充礼包状态"
|
||||||
uncheckedLabel="关闭"
|
uncheckedLabel="关闭"
|
||||||
onChange={(event) => setForm((current) => ({ ...current, enabled: event.target.checked }))}
|
onChange={(event) => setForm((current) => ({ ...current, enabled: event.target.checked }))}
|
||||||
/>
|
/>
|
||||||
@ -88,85 +83,100 @@ export function FirstRechargeRewardConfigDrawer({
|
|||||||
添加档位
|
添加档位
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.tierEditorList}>
|
<RewardTierEditorTable
|
||||||
{(form.tiers || []).map((tier, index) => (
|
columns={[
|
||||||
<div className={styles.tierEditor} key={`${tier.tierId || "new"}-${index}`}>
|
{
|
||||||
<div className={styles.tierEditorHeader}>
|
key: "tier",
|
||||||
<span>档位 {index + 1}</span>
|
label: "档位",
|
||||||
<IconButton
|
render: (_tier, index) => (
|
||||||
aria-label="删除档位"
|
<span className="reward-tier-editor-table__index">档位 {index + 1}</span>
|
||||||
disabled={disabled}
|
),
|
||||||
size="small"
|
},
|
||||||
onClick={() => removeTier(index)}
|
{
|
||||||
>
|
key: "usdAmount",
|
||||||
<DeleteOutlineOutlined fontSize="small" />
|
label: "USD",
|
||||||
</IconButton>
|
render: (tier, index) => (
|
||||||
</div>
|
|
||||||
<div className="form-drawer__grid">
|
|
||||||
<TextField
|
<TextField
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
label="档位编码"
|
inputProps={{ "aria-label": "美元档位", min: 0.01, step: 0.01 }}
|
||||||
value={tier.tierCode}
|
placeholder="0.99"
|
||||||
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"
|
type="number"
|
||||||
value={tier.minCoinAmount}
|
value={tier.usdAmount}
|
||||||
onChange={(event) => updateTier(index, { minCoinAmount: event.target.value })}
|
onChange={(event) => updateTier(index, { usdAmount: event.target.value })}
|
||||||
/>
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "googleProductId",
|
||||||
|
label: "Google 商品 ID",
|
||||||
|
render: (tier, index) => (
|
||||||
<TextField
|
<TextField
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
inputProps={{ min: 0 }}
|
inputProps={{ "aria-label": "Google 商品 ID" }}
|
||||||
label="最大金币"
|
placeholder="first_recharge_google_099"
|
||||||
placeholder="0 表示无上限"
|
value={tier.googleProductId}
|
||||||
type="number"
|
onChange={(event) =>
|
||||||
value={tier.maxCoinAmount}
|
updateTier(index, { googleProductId: event.target.value })
|
||||||
onChange={(event) => updateTier(index, { maxCoinAmount: event.target.value })}
|
}
|
||||||
/>
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "resourceGroupId",
|
||||||
|
label: "奖励资源组",
|
||||||
|
render: (tier, index) => (
|
||||||
<ResourceGroupSelectField
|
<ResourceGroupSelectField
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
drawerTitle="选择首冲奖励资源组"
|
drawerTitle="选择首充礼包资源组"
|
||||||
groups={resourceGroups}
|
groups={resourceGroups}
|
||||||
label="奖励资源组"
|
label="奖励资源组"
|
||||||
value={tier.resourceGroupId}
|
value={tier.resourceGroupId}
|
||||||
onChange={(value) => updateTier(index, { resourceGroupId: value })}
|
onChange={(value) => updateTier(index, { resourceGroupId: value })}
|
||||||
/>
|
/>
|
||||||
<TextField
|
),
|
||||||
select
|
},
|
||||||
disabled={disabled}
|
{
|
||||||
label="状态"
|
key: "discountPercent",
|
||||||
value={tier.status}
|
label: "优惠%",
|
||||||
onChange={(event) => updateTier(index, { status: event.target.value })}
|
render: (tier, index) => (
|
||||||
>
|
|
||||||
{statusOptions.map(([value, label]) => (
|
|
||||||
<MenuItem key={value} value={value}>
|
|
||||||
{label}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
|
||||||
<TextField
|
<TextField
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
inputProps={{ min: 0 }}
|
inputProps={{ "aria-label": "优惠百分比", min: 0, max: 100, step: 1 }}
|
||||||
label="排序"
|
|
||||||
type="number"
|
type="number"
|
||||||
value={tier.sortOrder}
|
value={tier.discountPercent}
|
||||||
onChange={(event) => updateTier(index, { sortOrder: event.target.value })}
|
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}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{!form.tiers?.length ? <div className={styles.emptyState}>暂无档位</div> : null}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
<div className="form-drawer__actions">
|
<div className="form-drawer__actions">
|
||||||
<Button disabled={configSaving} type="button" onClick={onClose}>
|
<Button disabled={configSaving} type="button" onClick={onClose}>
|
||||||
取消
|
取消
|
||||||
|
|||||||
@ -36,7 +36,7 @@ export function useFirstRechargeRewardPage() {
|
|||||||
loading: claimsLoading,
|
loading: claimsLoading,
|
||||||
reload: reloadClaims,
|
reload: reloadClaims,
|
||||||
} = usePaginatedQuery({
|
} = usePaginatedQuery({
|
||||||
errorMessage: "加载首冲奖励记录失败",
|
errorMessage: "加载首充礼包记录失败",
|
||||||
fetcher: listFirstRechargeRewardClaims,
|
fetcher: listFirstRechargeRewardClaims,
|
||||||
filters,
|
filters,
|
||||||
page,
|
page,
|
||||||
@ -55,7 +55,7 @@ export function useFirstRechargeRewardPage() {
|
|||||||
setForm(formFromConfig(config));
|
setForm(formFromConfig(config));
|
||||||
setResourceGroups(groups.items || []);
|
setResourceGroups(groups.items || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message || "加载首冲奖励配置失败", "error");
|
showToast(err.message || "加载首充礼包配置失败", "error");
|
||||||
} finally {
|
} finally {
|
||||||
setConfigLoading(false);
|
setConfigLoading(false);
|
||||||
}
|
}
|
||||||
@ -96,10 +96,10 @@ export function useFirstRechargeRewardPage() {
|
|||||||
setConfig(saved);
|
setConfig(saved);
|
||||||
setForm(formFromConfig(saved));
|
setForm(formFromConfig(saved));
|
||||||
setConfigDrawerOpen(false);
|
setConfigDrawerOpen(false);
|
||||||
showToast("首冲奖励配置已保存", "success");
|
showToast("首充礼包配置已保存", "success");
|
||||||
await reloadClaims();
|
await reloadClaims();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message || "保存首冲奖励配置失败", "error");
|
showToast(err.message || "保存首充礼包配置失败", "error");
|
||||||
} finally {
|
} finally {
|
||||||
setConfigSaving(false);
|
setConfigSaving(false);
|
||||||
}
|
}
|
||||||
@ -138,8 +138,9 @@ function formFromConfig(config) {
|
|||||||
tierId: tier.tierId || 0,
|
tierId: tier.tierId || 0,
|
||||||
tierCode: tier.tierCode || "",
|
tierCode: tier.tierCode || "",
|
||||||
tierName: tier.tierName || "",
|
tierName: tier.tierName || "",
|
||||||
minCoinAmount: tier.minCoinAmount ? String(tier.minCoinAmount) : "",
|
usdAmount: tier.usdMinorAmount ? (tier.usdMinorAmount / 100).toFixed(2) : "",
|
||||||
maxCoinAmount: tier.maxCoinAmount ? String(tier.maxCoinAmount) : "",
|
googleProductId: tier.googleProductId || "",
|
||||||
|
discountPercent: String(tier.discountPercent || 0),
|
||||||
resourceGroupId: tier.resourceGroupId ? String(tier.resourceGroupId) : "",
|
resourceGroupId: tier.resourceGroupId ? String(tier.resourceGroupId) : "",
|
||||||
status: tier.status || "active",
|
status: tier.status || "active",
|
||||||
sortOrder: String(tier.sortOrder || 0),
|
sortOrder: String(tier.sortOrder || 0),
|
||||||
@ -148,31 +149,40 @@ function formFromConfig(config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function payloadFromForm(form) {
|
function payloadFromForm(form) {
|
||||||
|
const seenGoogleProductIds = new Set();
|
||||||
const tiers = (form.tiers || []).map((tier, index) => {
|
const tiers = (form.tiers || []).map((tier, index) => {
|
||||||
const minCoinAmount = Number(tier.minCoinAmount || 0);
|
const usdMinorAmount = Math.round(Number(tier.usdAmount || 0) * 100);
|
||||||
const maxCoinAmount = Number(tier.maxCoinAmount || 0);
|
const googleProductId = String(tier.googleProductId || "").trim();
|
||||||
|
const discountPercent = Number(tier.discountPercent || 0);
|
||||||
const resourceGroupId = Number(tier.resourceGroupId || 0);
|
const resourceGroupId = Number(tier.resourceGroupId || 0);
|
||||||
if (!tier.tierCode.trim()) {
|
if (usdMinorAmount <= 0) {
|
||||||
throw new Error("档位编码不能为空");
|
throw new Error("美元档位必须大于 0");
|
||||||
}
|
}
|
||||||
if (!tier.tierName.trim()) {
|
if (!googleProductId) {
|
||||||
throw new Error("档位名称不能为空");
|
throw new Error("Google 商品 ID 不能为空");
|
||||||
}
|
}
|
||||||
if (minCoinAmount <= 0) {
|
const normalizedGoogleProductId = googleProductId.toLowerCase();
|
||||||
throw new Error("最小金币必须大于 0");
|
if (seenGoogleProductIds.has(normalizedGoogleProductId)) {
|
||||||
|
throw new Error("Google 商品 ID 不能重复");
|
||||||
}
|
}
|
||||||
if (maxCoinAmount > 0 && maxCoinAmount < minCoinAmount) {
|
seenGoogleProductIds.add(normalizedGoogleProductId);
|
||||||
throw new Error("最大金币不能小于最小金币");
|
if (discountPercent < 0 || discountPercent > 100) {
|
||||||
|
throw new Error("优惠百分比必须在 0 到 100 之间");
|
||||||
}
|
}
|
||||||
if (resourceGroupId <= 0) {
|
if (resourceGroupId <= 0) {
|
||||||
throw new Error("请选择资源组");
|
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 {
|
return {
|
||||||
tier_id: Number(tier.tierId || 0),
|
tier_id: Number(tier.tierId || 0),
|
||||||
tier_code: tier.tierCode.trim(),
|
tier_code: tierCode,
|
||||||
tier_name: tier.tierName.trim(),
|
tier_name: tierName,
|
||||||
min_coin_amount: minCoinAmount,
|
min_coin_amount: 0,
|
||||||
max_coin_amount: maxCoinAmount,
|
max_coin_amount: 0,
|
||||||
|
usd_minor_amount: usdMinorAmount,
|
||||||
|
google_product_id: googleProductId,
|
||||||
|
discount_percent: discountPercent,
|
||||||
resource_group_id: resourceGroupId,
|
resource_group_id: resourceGroupId,
|
||||||
status: tier.status === "inactive" ? "inactive" : "active",
|
status: tier.status === "inactive" ? "inactive" : "active",
|
||||||
sort_order: Number(tier.sortOrder || index),
|
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 { FirstRechargeRewardConfigDrawer } from "@/features/first-recharge-reward/components/FirstRechargeRewardConfigDrawer.jsx";
|
||||||
import { FirstRechargeRewardConfigSummary } from "@/features/first-recharge-reward/components/FirstRechargeRewardConfigSummary.jsx";
|
import { FirstRechargeRewardConfigSummary } from "@/features/first-recharge-reward/components/FirstRechargeRewardConfigSummary.jsx";
|
||||||
import { useFirstRechargeRewardPage } from "@/features/first-recharge-reward/hooks/useFirstRechargeRewardPage.js";
|
import { useFirstRechargeRewardPage } from "@/features/first-recharge-reward/hooks/useFirstRechargeRewardPage.js";
|
||||||
import styles from "@/features/first-recharge-reward/first-recharge-reward.module.css";
|
import styles from "@/features/first-recharge-reward/first-recharge-reward.module.css";
|
||||||
import { AdminListBody, AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
|
import { AdminListBody, AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
|
||||||
|
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
@ -24,12 +24,14 @@ const columnsBase = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "tier",
|
key: "tier",
|
||||||
label: "命中档位",
|
label: "商品档位",
|
||||||
width: "minmax(180px, 0.8fr)",
|
width: "minmax(260px, 1fr)",
|
||||||
render: (claim) => (
|
render: (claim) => (
|
||||||
<div className={styles.stack}>
|
<div className={styles.stack}>
|
||||||
<span>{claim.tierCode || "-"}</span>
|
<span>{claim.googleProductId || claim.tierCode || "-"}</span>
|
||||||
<span className={styles.meta}>资源组 #{claim.resourceGroupId || "-"}</span>
|
<span className={styles.meta}>
|
||||||
|
{formatUsdMinor(claim.tierUsdMinorAmount || claim.rechargeUsdMinor)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -40,7 +42,7 @@ const columnsBase = [
|
|||||||
render: (claim) => (
|
render: (claim) => (
|
||||||
<div className={styles.stack}>
|
<div className={styles.stack}>
|
||||||
<span>{formatNumber(claim.rechargeCoinAmount)} 金币</span>
|
<span>{formatNumber(claim.rechargeCoinAmount)} 金币</span>
|
||||||
<span className={styles.meta}>第 {claim.rechargeSequence || "-"} 笔</span>
|
<span className={styles.meta}>资源组 #{claim.resourceGroupId || "-"}</span>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -150,21 +152,7 @@ export function FirstRechargeRewardPage() {
|
|||||||
|
|
||||||
function ClaimUser({ claim }) {
|
function ClaimUser({ claim }) {
|
||||||
const user = claim.user || {};
|
const user = claim.user || {};
|
||||||
return (
|
return <AdminUserIdentity openInAppUserDetail user={{ ...user, userId: user.userId || claim.userId }} />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function claimStatusLabel(status) {
|
function claimStatusLabel(status) {
|
||||||
@ -174,3 +162,11 @@ function claimStatusLabel(status) {
|
|||||||
function formatNumber(value) {
|
function formatNumber(value) {
|
||||||
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
|
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 = [
|
export const firstRechargeRewardRoutes = [
|
||||||
{
|
{
|
||||||
label: "首冲奖励",
|
label: "首充礼包",
|
||||||
loader: () => import("./pages/FirstRechargeRewardPage.jsx").then((module) => module.FirstRechargeRewardPage),
|
loader: () => import("./pages/FirstRechargeRewardPage.jsx").then((module) => module.FirstRechargeRewardPage),
|
||||||
menuCode: MENU_CODES.firstRechargeReward,
|
menuCode: MENU_CODES.firstRechargeReward,
|
||||||
pageKey: "first-recharge-reward",
|
pageKey: "first-recharge-reward",
|
||||||
|
|||||||
@ -74,6 +74,136 @@ export interface GameSyncResult {
|
|||||||
items: GameCatalogDto[];
|
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<
|
export type GamePlatformPayload = Omit<
|
||||||
GamePlatformDto,
|
GamePlatformDto,
|
||||||
"appCode" | "createdAtMs" | "updatedAtMs" | "callbackSecretSet"
|
"appCode" | "createdAtMs" | "updatedAtMs" | "callbackSecretSet"
|
||||||
@ -87,6 +217,17 @@ export interface GameCatalogQuery {
|
|||||||
cursor?: string;
|
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> {
|
export function listGamePlatforms(status = ""): Promise<GamePlatformPage> {
|
||||||
const endpoint = API_ENDPOINTS.listPlatforms;
|
const endpoint = API_ENDPOINTS.listPlatforms;
|
||||||
return apiRequest<GamePlatformPage>(apiEndpointPath(API_OPERATIONS.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 {
|
function normalizePlatform(platform: Partial<GamePlatformDto>): GamePlatformDto {
|
||||||
return {
|
return {
|
||||||
appCode: stringValue(platform.appCode),
|
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) {
|
function stringValue(value: unknown) {
|
||||||
return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
|
return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,6 +15,17 @@
|
|||||||
object-fit: cover;
|
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,
|
.identityText,
|
||||||
.stack {
|
.stack {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -53,10 +64,29 @@
|
|||||||
width: 100%;
|
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 {
|
.fullField {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.giftTierRow {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.platformList {
|
.platformList {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@ -51,13 +51,16 @@ const defaultPlatformForm = {
|
|||||||
const defaultBridgeScriptForm = {
|
const defaultBridgeScriptForm = {
|
||||||
bridgeScriptUrl: "",
|
bridgeScriptUrl: "",
|
||||||
bridgeScriptVersion: "",
|
bridgeScriptVersion: "",
|
||||||
bridgeScriptSha256: "",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultBridgeHashState = {
|
const writableBridgeAdapterTypes = new Set([
|
||||||
loading: false,
|
"yomi_v4",
|
||||||
error: "",
|
"leadercc_v1",
|
||||||
};
|
"zeeone_v1",
|
||||||
|
"baishun_v1",
|
||||||
|
"vivagames_v1",
|
||||||
|
"reyou_v1",
|
||||||
|
]);
|
||||||
|
|
||||||
const emptyCatalog = { items: [], pageSize: 50 };
|
const emptyCatalog = { items: [], pageSize: 50 };
|
||||||
const emptyPlatforms = { items: [] };
|
const emptyPlatforms = { items: [] };
|
||||||
@ -81,7 +84,6 @@ export function useGamesPage() {
|
|||||||
const [gameForm, setGameForm] = useState(defaultGameForm);
|
const [gameForm, setGameForm] = useState(defaultGameForm);
|
||||||
const [platformForm, setPlatformForm] = useState(defaultPlatformForm);
|
const [platformForm, setPlatformForm] = useState(defaultPlatformForm);
|
||||||
const [bridgeScriptForm, setBridgeScriptForm] = useState(defaultBridgeScriptForm);
|
const [bridgeScriptForm, setBridgeScriptForm] = useState(defaultBridgeScriptForm);
|
||||||
const [bridgeHashState, setBridgeHashState] = useState(defaultBridgeHashState);
|
|
||||||
const [loadingAction, setLoadingAction] = useState("");
|
const [loadingAction, setLoadingAction] = useState("");
|
||||||
const [syncPlatform, setSyncPlatform] = useState(null);
|
const [syncPlatform, setSyncPlatform] = useState(null);
|
||||||
const [syncCandidates, setSyncCandidates] = useState([]);
|
const [syncCandidates, setSyncCandidates] = useState([]);
|
||||||
@ -217,7 +219,6 @@ export function useGamesPage() {
|
|||||||
|
|
||||||
const openBridgeScript = () => {
|
const openBridgeScript = () => {
|
||||||
setBridgeScriptForm(bridgeScriptFormFromPlatforms(platformData.items || []));
|
setBridgeScriptForm(bridgeScriptFormFromPlatforms(platformData.items || []));
|
||||||
setBridgeHashState(defaultBridgeHashState);
|
|
||||||
setActiveAction("bridge-script");
|
setActiveAction("bridge-script");
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -235,78 +236,12 @@ export function useGamesPage() {
|
|||||||
}
|
}
|
||||||
if (activeAction === "bridge-script") {
|
if (activeAction === "bridge-script") {
|
||||||
setBridgeScriptForm(defaultBridgeScriptForm);
|
setBridgeScriptForm(defaultBridgeScriptForm);
|
||||||
setBridgeHashState(defaultBridgeHashState);
|
|
||||||
}
|
}
|
||||||
setActiveAction("");
|
setActiveAction("");
|
||||||
setEditingGameId("");
|
setEditingGameId("");
|
||||||
setEditingPlatformCode("");
|
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) => {
|
const submitGame = async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const payload = gamePayload(gameForm);
|
const payload = gamePayload(gameForm);
|
||||||
@ -361,24 +296,26 @@ export function useGamesPage() {
|
|||||||
showToast("请填写 bridge_script_url 和 bridge_script_version", "error");
|
showToast("请填写 bridge_script_url 和 bridge_script_version", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let bridgeScriptSha256 = bridgeScriptForm.bridgeScriptSha256.trim();
|
const writablePlatforms = platforms.filter(isWritableBridgePlatform);
|
||||||
if (!bridgeScriptSha256) {
|
const skippedPlatforms = platforms.filter((platform) => !isWritableBridgePlatform(platform));
|
||||||
bridgeScriptSha256 = await refreshBridgeScriptHash();
|
if (writablePlatforms.length === 0) {
|
||||||
}
|
showToast("没有可写入的有效游戏平台", "error");
|
||||||
if (!bridgeScriptSha256) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoadingAction("bridge-script");
|
setLoadingAction("bridge-script");
|
||||||
try {
|
try {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
platforms.map((platform) => {
|
writablePlatforms.map((platform) => {
|
||||||
const config = parseJsonObject(platform.adapterConfigJson);
|
const config = parseJsonObject(platform.adapterConfigJson);
|
||||||
config.bridge_script_url = bridgeScriptUrl;
|
config.bridge_script_url = bridgeScriptUrl;
|
||||||
config.bridge_script_version = bridgeScriptVersion;
|
config.bridge_script_version = bridgeScriptVersion;
|
||||||
config.bridge_script_sha256 = bridgeScriptSha256;
|
|
||||||
delete config.bridgeScriptUrl;
|
delete config.bridgeScriptUrl;
|
||||||
delete config.bridgeScriptVersion;
|
delete config.bridgeScriptVersion;
|
||||||
|
delete config.bridge_script_sha256;
|
||||||
delete config.bridgeScriptSha256;
|
delete config.bridgeScriptSha256;
|
||||||
|
delete config.bridge_script_hash;
|
||||||
|
delete config.script_sha256;
|
||||||
|
delete config.scriptSha256;
|
||||||
return upsertGamePlatform(
|
return upsertGamePlatform(
|
||||||
platformUpsertPayload(platform, JSON.stringify(config, null, 2)),
|
platformUpsertPayload(platform, JSON.stringify(config, null, 2)),
|
||||||
platform.platformCode,
|
platform.platformCode,
|
||||||
@ -387,7 +324,14 @@ export function useGamesPage() {
|
|||||||
);
|
);
|
||||||
await reloadPlatforms();
|
await reloadPlatforms();
|
||||||
await reloadCatalog();
|
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();
|
closeAction();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message || "保存 JS 桥接配置失败", "error");
|
showToast(err.message || "保存 JS 桥接配置失败", "error");
|
||||||
@ -518,7 +462,6 @@ export function useGamesPage() {
|
|||||||
abilities,
|
abilities,
|
||||||
activeAction,
|
activeAction,
|
||||||
allSyncGamesSelected,
|
allSyncGamesSelected,
|
||||||
bridgeHashState,
|
|
||||||
bridgeScriptForm,
|
bridgeScriptForm,
|
||||||
changeGameStatus,
|
changeGameStatus,
|
||||||
deleteGame,
|
deleteGame,
|
||||||
@ -550,7 +493,6 @@ export function useGamesPage() {
|
|||||||
platformForm,
|
platformForm,
|
||||||
platformOptions,
|
platformOptions,
|
||||||
loadNextPage,
|
loadNextPage,
|
||||||
refreshBridgeScriptHash,
|
|
||||||
reload: reloadCatalog,
|
reload: reloadCatalog,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
resolveGameURL,
|
resolveGameURL,
|
||||||
@ -592,16 +534,13 @@ function bridgeScriptFormFromPlatform(platform) {
|
|||||||
return {
|
return {
|
||||||
bridgeScriptUrl: readConfigString(config.bridge_script_url ?? config.bridgeScriptUrl),
|
bridgeScriptUrl: readConfigString(config.bridge_script_url ?? config.bridgeScriptUrl),
|
||||||
bridgeScriptVersion: readConfigString(config.bridge_script_version ?? config.bridgeScriptVersion),
|
bridgeScriptVersion: readConfigString(config.bridge_script_version ?? config.bridgeScriptVersion),
|
||||||
bridgeScriptSha256: readConfigString(
|
|
||||||
config.bridge_script_sha256 ?? config.bridgeScriptSha256 ?? config.bridge_script_hash,
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function bridgeScriptFormFromPlatforms(platforms) {
|
function bridgeScriptFormFromPlatforms(platforms) {
|
||||||
for (const platform of platforms) {
|
for (const platform of platforms) {
|
||||||
const form = bridgeScriptFormFromPlatform(platform);
|
const form = bridgeScriptFormFromPlatform(platform);
|
||||||
if (form.bridgeScriptUrl || form.bridgeScriptVersion || form.bridgeScriptSha256) {
|
if (form.bridgeScriptUrl || form.bridgeScriptVersion) {
|
||||||
return form;
|
return form;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -615,6 +554,14 @@ function readConfigString(value) {
|
|||||||
return String(value).trim();
|
return String(value).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isWritableBridgePlatform(platform) {
|
||||||
|
return writableBridgeAdapterTypes.has(
|
||||||
|
String(platform?.adapterType || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function platformUpsertPayload(platform, adapterConfigJson) {
|
function platformUpsertPayload(platform, adapterConfigJson) {
|
||||||
return {
|
return {
|
||||||
platformCode: platform.platformCode,
|
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) {
|
async function saveGameUrl(platforms, game, gameUrl) {
|
||||||
const platform = platforms.find((item) => item.platformCode === game.platformCode);
|
const platform = platforms.find((item) => item.platformCode === game.platformCode);
|
||||||
if (!platform) {
|
if (!platform) {
|
||||||
|
|||||||
@ -435,15 +435,8 @@ function GameFormDialog({ form, loading, mode, open, page, onClose, onSubmit })
|
|||||||
}
|
}
|
||||||
|
|
||||||
function BridgeScriptDialog({ form, loading, open, page, onClose, onSubmit }) {
|
function BridgeScriptDialog({ form, loading, open, page, onClose, onSubmit }) {
|
||||||
const hashState = page.bridgeHashState || {};
|
|
||||||
const disabled = !page.abilities.canUpdate;
|
const disabled = !page.abilities.canUpdate;
|
||||||
const submitDisabled =
|
const submitDisabled = disabled || loading || !form.bridgeScriptUrl.trim() || !form.bridgeScriptVersion.trim();
|
||||||
disabled ||
|
|
||||||
loading ||
|
|
||||||
hashState.loading ||
|
|
||||||
!form.bridgeScriptUrl.trim() ||
|
|
||||||
!form.bridgeScriptVersion.trim() ||
|
|
||||||
!form.bridgeScriptSha256.trim();
|
|
||||||
return (
|
return (
|
||||||
<AdminFormDialog
|
<AdminFormDialog
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@ -455,17 +448,7 @@ function BridgeScriptDialog({ form, loading, open, page, onClose, onSubmit }) {
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
>
|
>
|
||||||
<AdminFormSection
|
<AdminFormSection title="通用桥接脚本">
|
||||||
title="通用桥接脚本"
|
|
||||||
actions={
|
|
||||||
<Button
|
|
||||||
disabled={disabled || hashState.loading || !form.bridgeScriptUrl.trim()}
|
|
||||||
onClick={page.refreshBridgeScriptHash}
|
|
||||||
>
|
|
||||||
{hashState.loading ? "生成中..." : "重新生成 SHA256"}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||||
<TextField
|
<TextField
|
||||||
label="bridge_script_version"
|
label="bridge_script_version"
|
||||||
@ -484,17 +467,6 @@ function BridgeScriptDialog({ form, loading, open, page, onClose, onSubmit }) {
|
|||||||
value={form.bridgeScriptUrl}
|
value={form.bridgeScriptUrl}
|
||||||
onChange={(event) => page.setBridgeScriptForm({ ...form, bridgeScriptUrl: event.target.value })}
|
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>
|
</AdminFormFieldGrid>
|
||||||
</AdminFormSection>
|
</AdminFormSection>
|
||||||
</AdminFormDialog>
|
</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",
|
path: "/games",
|
||||||
permission: PERMISSIONS.gameView,
|
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 {
|
export interface HostSalarySettlementUserDto {
|
||||||
userId: string;
|
userId: string;
|
||||||
displayUserId?: string;
|
displayUserId?: string;
|
||||||
|
prettyDisplayUserId?: string;
|
||||||
|
prettyId?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
}
|
}
|
||||||
@ -415,6 +417,8 @@ function normalizeSettlementUser(raw: Record<string, unknown>): HostSalarySettle
|
|||||||
return {
|
return {
|
||||||
userId: stringValue(raw.userId ?? raw.user_id),
|
userId: stringValue(raw.userId ?? raw.user_id),
|
||||||
displayUserId: stringValue(raw.displayUserId ?? raw.display_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),
|
username: stringValue(raw.username),
|
||||||
avatar: stringValue(raw.avatar),
|
avatar: stringValue(raw.avatar),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import PlayArrowOutlined from "@mui/icons-material/PlayArrowOutlined";
|
import PlayArrowOutlined from "@mui/icons-material/PlayArrowOutlined";
|
||||||
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
|
|
||||||
import Checkbox from "@mui/material/Checkbox";
|
import Checkbox from "@mui/material/Checkbox";
|
||||||
import Tab from "@mui/material/Tab";
|
import Tab from "@mui/material/Tab";
|
||||||
import Tabs from "@mui/material/Tabs";
|
import Tabs from "@mui/material/Tabs";
|
||||||
@ -18,6 +17,7 @@ import {
|
|||||||
AdminListPage,
|
AdminListPage,
|
||||||
AdminListToolbar,
|
AdminListToolbar,
|
||||||
} from "@/shared/ui/AdminListLayout.jsx";
|
} from "@/shared/ui/AdminListLayout.jsx";
|
||||||
|
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
||||||
import { Button } from "@/shared/ui/Button.jsx";
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
@ -49,20 +49,14 @@ const policyTypeOptions = [
|
|||||||
["admin", "Admin"],
|
["admin", "Admin"],
|
||||||
];
|
];
|
||||||
|
|
||||||
const policyTypeAllOptions = [
|
const policyTypeAllOptions = [["", "全部角色"], ...policyTypeOptions];
|
||||||
["", "全部角色"],
|
|
||||||
...policyTypeOptions,
|
|
||||||
];
|
|
||||||
|
|
||||||
const triggerOptions = [
|
const triggerOptions = [
|
||||||
["automatic", "自动政策"],
|
["automatic", "自动政策"],
|
||||||
["manual", "手动政策"],
|
["manual", "手动政策"],
|
||||||
];
|
];
|
||||||
|
|
||||||
const triggerAllOptions = [
|
const triggerAllOptions = [["", "全部触发"], ...triggerOptions];
|
||||||
["", "全部触发"],
|
|
||||||
...triggerOptions,
|
|
||||||
];
|
|
||||||
|
|
||||||
export function HostSalarySettlementPage() {
|
export function HostSalarySettlementPage() {
|
||||||
const [activeTab, setActiveTab] = useState("team-pending");
|
const [activeTab, setActiveTab] = useState("team-pending");
|
||||||
@ -184,13 +178,7 @@ function HostRecordsPanel() {
|
|||||||
return column;
|
return column;
|
||||||
});
|
});
|
||||||
const hasFilters =
|
const hasFilters =
|
||||||
agencyOwnerUserId ||
|
agencyOwnerUserId || cycleKey || settlementType || status || timeRange.startMs || timeRange.endMs || userId;
|
||||||
cycleKey ||
|
|
||||||
settlementType ||
|
|
||||||
status ||
|
|
||||||
timeRange.startMs ||
|
|
||||||
timeRange.endMs ||
|
|
||||||
userId;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -360,7 +348,8 @@ function TeamPendingPanel() {
|
|||||||
...teamPendingColumns,
|
...teamPendingColumns,
|
||||||
];
|
];
|
||||||
// 默认值也纳入 hasFilters,这样运营能一键回到“BD + 自动政策 + 上月周期”的标准入口。
|
// 默认值也纳入 hasFilters,这样运营能一键回到“BD + 自动政策 + 上月周期”的标准入口。
|
||||||
const hasFilters = policyType !== "bd" || triggerMode !== "automatic" || cycleKey !== defaultCycleKey() || countryCode || regionId;
|
const hasFilters =
|
||||||
|
policyType !== "bd" || triggerMode !== "automatic" || cycleKey !== defaultCycleKey() || countryCode || regionId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -377,11 +366,36 @@ function TeamPendingPanel() {
|
|||||||
}
|
}
|
||||||
filters={
|
filters={
|
||||||
<div className={styles.toolbarFilters}>
|
<div className={styles.toolbarFilters}>
|
||||||
<AdminFilterSelect label="角色" options={policyTypeOptions} value={policyType} onChange={changeFilter(setPolicyType)} />
|
<AdminFilterSelect
|
||||||
<AdminFilterSelect label="政策触发" options={triggerOptions} value={triggerMode} onChange={changeFilter(setTriggerMode)} />
|
label="角色"
|
||||||
<InlineFilter label="周期" placeholder="YYYY-MM" value={cycleKey} onChange={changeFilter(setCycleKey)} />
|
options={policyTypeOptions}
|
||||||
<InlineFilter label="国家" placeholder="国家代码" value={countryCode} onChange={changeFilter(setCountryCode)} />
|
value={policyType}
|
||||||
<InlineFilter label="区域" placeholder="区域 ID" value={regionId} onChange={changeFilter(setRegionId)} />
|
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} />
|
<AdminFilterResetButton disabled={!hasFilters} onClick={resetFilters} />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@ -502,10 +516,31 @@ function TeamRecordsPanel() {
|
|||||||
<AdminListToolbar
|
<AdminListToolbar
|
||||||
filters={
|
filters={
|
||||||
<div className={styles.toolbarFilters}>
|
<div className={styles.toolbarFilters}>
|
||||||
<TimeRangeFilter value={timeRange} onChange={(value) => { setTimeRange(value); setPage(1); }} />
|
<TimeRangeFilter
|
||||||
<AdminFilterSelect label="角色" options={policyTypeAllOptions} value={policyType} onChange={changeFilter(setPolicyType)} />
|
value={timeRange}
|
||||||
<AdminFilterSelect label="触发" options={triggerAllOptions} value={triggerMode} onChange={changeFilter(setTriggerMode)} />
|
onChange={(value) => {
|
||||||
<InlineFilter label="国家" placeholder="国家代码" value={countryCode} onChange={changeFilter(setCountryCode)} />
|
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} />
|
<AdminFilterResetButton disabled={!hasFilters} onClick={resetFilters} />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@ -591,7 +626,9 @@ const teamPendingColumns = [
|
|||||||
width: "minmax(180px, 0.8fr)",
|
width: "minmax(180px, 0.8fr)",
|
||||||
render: (item) => (
|
render: (item) => (
|
||||||
<div className={styles.stack}>
|
<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>
|
<span className={styles.meta}>{item.regionName || `区域 ${item.regionId || "-"}`}</span>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@ -652,7 +689,9 @@ const teamRecordColumns = [
|
|||||||
render: (item) => (
|
render: (item) => (
|
||||||
<div className={styles.stack}>
|
<div className={styles.stack}>
|
||||||
<span>{item.cycleKey || "-"}</span>
|
<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>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -704,17 +743,7 @@ function UserCell({ fallbackId, user }) {
|
|||||||
if (!idText) {
|
if (!idText) {
|
||||||
return "-";
|
return "-";
|
||||||
}
|
}
|
||||||
return (
|
return <AdminUserIdentity openInAppUserDetail user={{ ...(user || {}), userId: user?.userId || fallbackId }} />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CycleCell 展示 Host/Agency 周期和结算类型。
|
// CycleCell 展示 Host/Agency 周期和结算类型。
|
||||||
@ -820,7 +849,9 @@ function settlementTypeLabel(value) {
|
|||||||
daily: "日结",
|
daily: "日结",
|
||||||
half_month: "半月结算",
|
half_month: "半月结算",
|
||||||
month_end: "月底清算",
|
month_end: "月底清算",
|
||||||
}[value] || value || "-"
|
}[value] ||
|
||||||
|
value ||
|
||||||
|
"-"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -831,7 +862,9 @@ function statusLabel(value) {
|
|||||||
failed: "失败",
|
failed: "失败",
|
||||||
skipped: "跳过",
|
skipped: "跳过",
|
||||||
succeeded: "成功",
|
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";
|
import styles from "@/features/host-org/host-org.module.css";
|
||||||
|
|
||||||
export function HostOrgPerson({ avatar, displayUserId, fallbackId, username }) {
|
export function HostOrgPerson({ avatar, displayUserId, fallbackId, prettyDisplayUserId, prettyId, username }) {
|
||||||
const name = username || "-";
|
|
||||||
const meta = displayUserId || fallbackId || "-";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.sellerIdentity}>
|
<AdminUserIdentity
|
||||||
{avatar ? (
|
avatar={avatar}
|
||||||
<img alt="" className={styles.sellerAvatar} src={avatar} />
|
displayUserId={displayUserId}
|
||||||
) : (
|
name={username}
|
||||||
<span className={styles.sellerAvatar}>
|
openInAppUserDetail
|
||||||
{String(name || meta)
|
prettyDisplayUserId={prettyDisplayUserId}
|
||||||
.slice(0, 1)
|
prettyId={prettyId}
|
||||||
.toUpperCase()}
|
userId={fallbackId}
|
||||||
</span>
|
/>
|
||||||
)}
|
|
||||||
<span className={styles.sellerText}>
|
|
||||||
<span className={styles.sellerName}>{name}</span>
|
|
||||||
<span className={styles.sellerMeta}>{meta}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -126,7 +126,7 @@ export function useHostAgenciesPage() {
|
|||||||
commandId: makeCommandId("agency-close"),
|
commandId: makeCommandId("agency-close"),
|
||||||
reason: "close agency from list switch",
|
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;
|
return data;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -141,7 +141,7 @@ export function useHostAgenciesPage() {
|
|||||||
joinEnabled,
|
joinEnabled,
|
||||||
reason: "set agency join enabled from list switch",
|
reason: "set agency join enabled from list switch",
|
||||||
});
|
});
|
||||||
patchAgencyRow(agency.agencyId, data || { joinEnabled });
|
patchAgencyRow(agency.agencyId, agencyActionPatch(data, { joinEnabled }));
|
||||||
return data;
|
return data;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -221,3 +221,13 @@ export function useHostAgenciesPage() {
|
|||||||
function makeCommandId(prefix) {
|
function makeCommandId(prefix) {
|
||||||
return `${prefix}-${Date.now()}`;
|
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 { formatMillis } from "@/shared/utils/time.js";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
|
import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
|
||||||
|
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
||||||
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
|
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
|
||||||
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
|
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
|
||||||
import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx";
|
import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx";
|
||||||
@ -211,25 +212,7 @@ export function HostBdLeadersPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PersonIdentity({ item }) {
|
function PersonIdentity({ item }) {
|
||||||
const name = item.username || "-";
|
return <AdminUserIdentity openInAppUserDetail user={item} />;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CreatorIdentity({ item }) {
|
function CreatorIdentity({ item }) {
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import {
|
|||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
|
import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
|
||||||
|
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
||||||
import { InlineEditableCell } from "@/shared/ui/InlineEditableCell.jsx";
|
import { InlineEditableCell } from "@/shared/ui/InlineEditableCell.jsx";
|
||||||
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
|
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
|
||||||
import { coinSellerStatusFilters } from "@/features/host-org/constants.js";
|
import { coinSellerStatusFilters } from "@/features/host-org/constants.js";
|
||||||
@ -157,10 +158,7 @@ export function HostCoinSellersPage() {
|
|||||||
return (
|
return (
|
||||||
<section className={styles.root}>
|
<section className={styles.root}>
|
||||||
<div className={styles.contentPanel}>
|
<div className={styles.contentPanel}>
|
||||||
<HostOrgToolbar
|
<HostOrgToolbar actions={toolbarActions} leadingActions={leadingActions} />
|
||||||
actions={toolbarActions}
|
|
||||||
leadingActions={leadingActions}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||||
<div className={styles.listBlock}>
|
<div className={styles.listBlock}>
|
||||||
@ -314,7 +312,10 @@ export function HostCoinSellersPage() {
|
|||||||
onClose={page.closeAction}
|
onClose={page.closeAction}
|
||||||
>
|
>
|
||||||
{page.selectedSeller ? (
|
{page.selectedSeller ? (
|
||||||
<CoinSellerLedgerTable lockedSeller={page.selectedSeller} sellerUserId={page.selectedSeller.userId} />
|
<CoinSellerLedgerTable
|
||||||
|
lockedSeller={page.selectedSeller}
|
||||||
|
sellerUserId={page.selectedSeller.userId}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</SideDrawer>
|
</SideDrawer>
|
||||||
|
|
||||||
@ -377,9 +378,7 @@ export function HostCoinSellersPage() {
|
|||||||
label="coin per USD"
|
label="coin per USD"
|
||||||
required
|
required
|
||||||
value={tier.coinPerUsd}
|
value={tier.coinPerUsd}
|
||||||
onChange={(event) =>
|
onChange={(event) => page.updateRateTier(index, { coinPerUsd: event.target.value })}
|
||||||
page.updateRateTier(index, { coinPerUsd: event.target.value })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<div className={styles.rateTierSwitch}>
|
<div className={styles.rateTierSwitch}>
|
||||||
<AdminSwitch
|
<AdminSwitch
|
||||||
@ -387,7 +386,9 @@ export function HostCoinSellersPage() {
|
|||||||
checkedLabel="启用"
|
checkedLabel="启用"
|
||||||
label="状态"
|
label="状态"
|
||||||
uncheckedLabel="停用"
|
uncheckedLabel="停用"
|
||||||
onChange={(event) => page.updateRateTier(index, { enabled: event.target.checked })}
|
onChange={(event) =>
|
||||||
|
page.updateRateTier(index, { enabled: event.target.checked })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<AdminActionIconButton
|
<AdminActionIconButton
|
||||||
@ -407,22 +408,7 @@ export function HostCoinSellersPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SellerIdentity({ item }) {
|
function SellerIdentity({ item }) {
|
||||||
const name = item.username || "-";
|
return <AdminUserIdentity openInAppUserDetail user={item} />;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function SellerStatusSwitch({ item, page }) {
|
function SellerStatusSwitch({ item, page }) {
|
||||||
@ -460,7 +446,11 @@ function SellerActions({ item, page }) {
|
|||||||
return (
|
return (
|
||||||
<AdminRowActions>
|
<AdminRowActions>
|
||||||
{page.abilities.canLedger ? (
|
{page.abilities.canLedger ? (
|
||||||
<AdminActionIconButton label="币商流水" sx={coinLedgerActionSx} onClick={() => page.openSellerLedger(item)}>
|
<AdminActionIconButton
|
||||||
|
label="币商流水"
|
||||||
|
sx={coinLedgerActionSx}
|
||||||
|
onClick={() => page.openSellerLedger(item)}
|
||||||
|
>
|
||||||
<ReceiptLongOutlined fontSize="small" />
|
<ReceiptLongOutlined fontSize="small" />
|
||||||
</AdminActionIconButton>
|
</AdminActionIconButton>
|
||||||
) : null}
|
) : 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 FileDownloadOutlined from "@mui/icons-material/FileDownloadOutlined";
|
||||||
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { exportCoinSellerLedger, listCoinSellerLedger } from "@/features/operations/api";
|
import { exportCoinSellerLedger, listCoinSellerLedger } from "@/features/operations/api";
|
||||||
import styles from "@/features/operations/operations.module.css";
|
import styles from "@/features/operations/operations.module.css";
|
||||||
import { downloadCsv } from "@/shared/api/download";
|
import { downloadCsv } from "@/shared/api/download";
|
||||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||||
import {
|
import { AdminFilterResetButton, AdminListBody, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx";
|
||||||
AdminFilterResetButton,
|
|
||||||
AdminListBody,
|
|
||||||
AdminListToolbar,
|
|
||||||
} from "@/shared/ui/AdminListLayout.jsx";
|
|
||||||
import { Button } from "@/shared/ui/Button.jsx";
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
|
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
||||||
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
@ -182,14 +178,15 @@ export function CoinSellerLedgerTable({ lockedSeller = null, sellerUserId = "" }
|
|||||||
filters={
|
filters={
|
||||||
<>
|
<>
|
||||||
<TimeRangeFilter value={timeRange} onChange={changeTimeRange} />
|
<TimeRangeFilter value={timeRange} onChange={changeTimeRange} />
|
||||||
<AdminFilterResetButton
|
<AdminFilterResetButton disabled={!hasActiveFilters} onClick={resetFilters} />
|
||||||
disabled={!hasActiveFilters}
|
|
||||||
onClick={resetFilters}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
<Button disabled={exporting} startIcon={<FileDownloadOutlined fontSize="small" />} onClick={downloadLedger}>
|
<Button
|
||||||
|
disabled={exporting}
|
||||||
|
startIcon={<FileDownloadOutlined fontSize="small" />}
|
||||||
|
onClick={downloadLedger}
|
||||||
|
>
|
||||||
{exporting ? "导出中" : "导出"}
|
{exporting ? "导出中" : "导出"}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
@ -235,18 +232,7 @@ function OperatorCell({ entry }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function UserCell({ user = {} }) {
|
function UserCell({ user = {} }) {
|
||||||
const name = user.username || "-";
|
return <AdminUserIdentity openInAppUserDetail user={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}>{name}</span>
|
|
||||||
<span className={styles.meta}>{userIdText(user)}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function AmountValue({ entry }) {
|
function AmountValue({ entry }) {
|
||||||
@ -261,16 +247,9 @@ function AmountValue({ entry }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ledgerTypeLabel(entry) {
|
function ledgerTypeLabel(entry) {
|
||||||
return ledgerTypeLabels[entry.ledgerType] || bizTypeLabels[entry.bizType] || entry.ledgerType || entry.bizType || "-";
|
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 || "-";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatNumber(value) {
|
function formatNumber(value) {
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
|
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
|
||||||
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
|
|
||||||
import SearchOutlined from "@mui/icons-material/SearchOutlined";
|
import SearchOutlined from "@mui/icons-material/SearchOutlined";
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
@ -24,6 +23,7 @@ import {
|
|||||||
import { Button } from "@/shared/ui/Button.jsx";
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
|
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
||||||
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
import { createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
import { createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
@ -320,33 +320,11 @@ export function CoinAdjustmentPage() {
|
|||||||
|
|
||||||
function AdjustmentUserCell({ item }) {
|
function AdjustmentUserCell({ item }) {
|
||||||
const user = item.user || {};
|
const user = item.user || {};
|
||||||
const name = user.username || "-";
|
return <AdminUserIdentity openInAppUserDetail user={{ ...user, userId: user.userId || item.userId }} />;
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function TargetUserPreview({ user }) {
|
function TargetUserPreview({ user }) {
|
||||||
return (
|
return <AdminUserIdentity openInAppUserDetail className={styles.targetPreview} user={user} />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function AdjustmentAmount({ item }) {
|
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) {
|
function operatorName(item) {
|
||||||
const operator = item.operator || {};
|
const operator = item.operator || {};
|
||||||
return operator.name || operator.username || item.operatorUserId || "-";
|
return operator.name || operator.username || item.operatorUserId || "-";
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { listCoinLedger } from "@/features/operations/api";
|
import { listCoinLedger } from "@/features/operations/api";
|
||||||
import {
|
import {
|
||||||
@ -7,6 +6,7 @@ import {
|
|||||||
AdminListPage,
|
AdminListPage,
|
||||||
AdminListToolbar,
|
AdminListToolbar,
|
||||||
} from "@/shared/ui/AdminListLayout.jsx";
|
} from "@/shared/ui/AdminListLayout.jsx";
|
||||||
|
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
|
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
|
||||||
@ -232,16 +232,12 @@ function CoinLedgerDetailDrawer({ entry, onClose, open }) {
|
|||||||
{entry ? (
|
{entry ? (
|
||||||
<>
|
<>
|
||||||
<header className={styles.detailHero}>
|
<header className={styles.detailHero}>
|
||||||
<span className={styles.detailAvatar}>
|
<AdminUserIdentity
|
||||||
{user.avatar ? <img alt="" src={user.avatar} /> : <PersonOutlineOutlined fontSize="medium" />}
|
openInAppUserDetail
|
||||||
</span>
|
user={{ ...user, userId: user.userId || entry.userId }}
|
||||||
<div className={styles.detailHeroText}>
|
size="large"
|
||||||
<div className={styles.detailTitleRow}>
|
/>
|
||||||
<h3>{user.username || "-"}</h3>
|
|
||||||
<AmountValue entry={entry} />
|
<AmountValue entry={entry} />
|
||||||
</div>
|
|
||||||
<span className={styles.detailMeta}>{userIdText(entry)}</span>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<DetailSection
|
<DetailSection
|
||||||
@ -276,17 +272,7 @@ function CoinLedgerDetailDrawer({ entry, onClose, open }) {
|
|||||||
|
|
||||||
function UserCell({ entry }) {
|
function UserCell({ entry }) {
|
||||||
const user = entry.user || {};
|
const user = entry.user || {};
|
||||||
return (
|
return <AdminUserIdentity openInAppUserDetail user={{ ...user, userId: user.userId || entry.userId }} />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function AmountValue({ entry }) {
|
function AmountValue({ entry }) {
|
||||||
@ -366,7 +352,12 @@ function businessContextRows(entry, metadata) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return compactRows([
|
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, ["room_id", "roomId"], entry.roomId),
|
||||||
metadataRow("任务 ID", metadata, ["task_id", "taskId"]),
|
metadataRow("任务 ID", metadata, ["task_id", "taskId"]),
|
||||||
metadataRow("任务类型", metadata, ["task_type", "taskType"]),
|
metadataRow("任务类型", metadata, ["task_type", "taskType"]),
|
||||||
@ -440,16 +431,6 @@ function displayDetailValue(value) {
|
|||||||
return String(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) {
|
function hasDetailValue(value) {
|
||||||
return value === 0 || value === false || Boolean(value);
|
return value === 0 || value === false || Boolean(value);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
AdminListPage,
|
AdminListPage,
|
||||||
AdminListToolbar,
|
AdminListToolbar,
|
||||||
} from "@/shared/ui/AdminListLayout.jsx";
|
} from "@/shared/ui/AdminListLayout.jsx";
|
||||||
|
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||||
@ -219,19 +220,7 @@ function TargetCell({ report }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const user = target.user || {};
|
const user = target.user || {};
|
||||||
return (
|
return <AdminUserIdentity openInAppUserDetail user={{ ...user, userId: user.userId || report.userId }} />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function EvidenceImages({ imageUrls = [] }) {
|
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 { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||||
import type {
|
import type {
|
||||||
ApiPage,
|
ApiPage,
|
||||||
|
ApiList,
|
||||||
EntityId,
|
EntityId,
|
||||||
PageQuery,
|
PageQuery,
|
||||||
RechargeBillDto,
|
RechargeBillDto,
|
||||||
RechargeProductDto,
|
RechargeProductDto,
|
||||||
RechargeProductPayload,
|
RechargeProductPayload,
|
||||||
|
ThirdPartyPaymentChannelDto,
|
||||||
|
ThirdPartyPaymentMethodDto,
|
||||||
} from "@/shared/api/types";
|
} from "@/shared/api/types";
|
||||||
|
|
||||||
export function listRechargeBills(query: PageQuery = {}): Promise<ApiPage<RechargeBillDto>> {
|
export function listRechargeBills(query: PageQuery = {}): Promise<ApiPage<RechargeBillDto>> {
|
||||||
@ -56,3 +59,41 @@ export function deleteRechargeProduct(productId: EntityId): Promise<unknown> {
|
|||||||
method: endpoint.method,
|
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;
|
const pageSize = 50;
|
||||||
|
|
||||||
|
// 新建默认走 H5 web 档位和普通用户档位;币商档位必须由运营显式选择,避免误配商品受众。
|
||||||
const emptyForm = () => ({
|
const emptyForm = () => ({
|
||||||
amountUsdt: "",
|
amountUsdt: "",
|
||||||
|
audienceType: "normal",
|
||||||
coinAmount: "",
|
coinAmount: "",
|
||||||
description: "",
|
description: "",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
platform: "android",
|
platform: "web",
|
||||||
productName: "",
|
productName: "",
|
||||||
regionIds: [],
|
regionIds: [],
|
||||||
});
|
});
|
||||||
@ -33,6 +35,7 @@ export function useRechargeProductsPage() {
|
|||||||
const [keyword, setKeyword] = useState("");
|
const [keyword, setKeyword] = useState("");
|
||||||
const [status, setStatus] = useState("");
|
const [status, setStatus] = useState("");
|
||||||
const [platform, setPlatform] = useState("");
|
const [platform, setPlatform] = useState("");
|
||||||
|
const [audienceType, setAudienceType] = useState("");
|
||||||
const [regionId, setRegionId] = useState("");
|
const [regionId, setRegionId] = useState("");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [activeAction, setActiveAction] = useState("");
|
const [activeAction, setActiveAction] = useState("");
|
||||||
@ -40,14 +43,16 @@ export function useRechargeProductsPage() {
|
|||||||
const [form, setFormState] = useState(emptyForm);
|
const [form, setFormState] = useState(emptyForm);
|
||||||
const [loadingAction, setLoadingAction] = useState("");
|
const [loadingAction, setLoadingAction] = useState("");
|
||||||
|
|
||||||
|
// audienceType 直接透传给 admin-server,后台再转 wallet-service 过滤 normal / coin_seller 档位。
|
||||||
const filters = useMemo(
|
const filters = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
audienceType,
|
||||||
keyword,
|
keyword,
|
||||||
platform,
|
platform,
|
||||||
regionId,
|
regionId,
|
||||||
status,
|
status,
|
||||||
}),
|
}),
|
||||||
[keyword, platform, regionId, status],
|
[audienceType, keyword, platform, regionId, status],
|
||||||
);
|
);
|
||||||
const result = usePaginatedQuery({
|
const result = usePaginatedQuery({
|
||||||
errorMessage: "加载内购配置失败",
|
errorMessage: "加载内购配置失败",
|
||||||
@ -88,6 +93,7 @@ export function useRechargeProductsPage() {
|
|||||||
setKeyword("");
|
setKeyword("");
|
||||||
setStatus("");
|
setStatus("");
|
||||||
setPlatform("");
|
setPlatform("");
|
||||||
|
setAudienceType("");
|
||||||
setRegionId("");
|
setRegionId("");
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -107,12 +113,36 @@ export function useRechargeProductsPage() {
|
|||||||
if (editingItem) {
|
if (editingItem) {
|
||||||
await updateRechargeProduct(editingItem.productId, payload);
|
await updateRechargeProduct(editingItem.productId, payload);
|
||||||
showToast("内购配置已更新", "success");
|
showToast("内购配置已更新", "success");
|
||||||
} else {
|
|
||||||
await createRechargeProduct(payload);
|
|
||||||
showToast("内购配置已新增", "success");
|
|
||||||
}
|
|
||||||
closeDialog();
|
closeDialog();
|
||||||
await result.reload();
|
await result.reload();
|
||||||
|
} else {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message || "操作失败", "error");
|
showToast(err.message || "操作失败", "error");
|
||||||
} finally {
|
} finally {
|
||||||
@ -158,6 +188,7 @@ export function useRechargeProductsPage() {
|
|||||||
return {
|
return {
|
||||||
abilities,
|
abilities,
|
||||||
activeAction,
|
activeAction,
|
||||||
|
audienceType,
|
||||||
closeDialog,
|
closeDialog,
|
||||||
data: result.data,
|
data: result.data,
|
||||||
editingItem,
|
editingItem,
|
||||||
@ -178,6 +209,7 @@ export function useRechargeProductsPage() {
|
|||||||
removeProduct,
|
removeProduct,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
setForm,
|
setForm,
|
||||||
|
setAudienceType,
|
||||||
setKeyword,
|
setKeyword,
|
||||||
setPage,
|
setPage,
|
||||||
setPlatform,
|
setPlatform,
|
||||||
@ -193,35 +225,60 @@ function normalizeForm(form) {
|
|||||||
return {
|
return {
|
||||||
...form,
|
...form,
|
||||||
amountUsdt: form.amountUsdt || "",
|
amountUsdt: form.amountUsdt || "",
|
||||||
|
audienceType: form.audienceType || "normal",
|
||||||
coinAmount: form.coinAmount || "",
|
coinAmount: form.coinAmount || "",
|
||||||
regionIds: form.regionIds || [],
|
regionIds: form.regionIds || [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function formFromProduct(item) {
|
function formFromProduct(item) {
|
||||||
|
// 后端老数据可能没有 audienceType;前端按普通用户兜底,保证旧档位不会被误认为币商档位。
|
||||||
return {
|
return {
|
||||||
amountUsdt: item.amountUsdt || "",
|
amountUsdt: item.amountUsdt || "",
|
||||||
|
audienceType: item.audienceType === "coin_seller" ? "coin_seller" : "normal",
|
||||||
coinAmount: item.coinAmount === 0 || item.coinAmount ? String(item.coinAmount) : "",
|
coinAmount: item.coinAmount === 0 || item.coinAmount ? String(item.coinAmount) : "",
|
||||||
description: item.description || "",
|
description: item.description || "",
|
||||||
enabled: item.enabled ?? item.status === "active",
|
enabled: item.enabled ?? item.status === "active",
|
||||||
platform: item.platform || "android",
|
platform: item.platform || "web",
|
||||||
productName: item.productName || "",
|
productName: item.productName || "",
|
||||||
regionIds: (item.regionIds || []).map(String),
|
regionIds: (item.regionIds || []).map(String),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function productPayloadFromItem(item, enabled) {
|
function productPayloadFromItem(item, enabled) {
|
||||||
|
// 行内上下架只改变 enabled,其他字段必须按订单快照原样回传,避免一次快捷开关丢失受众或平台。
|
||||||
return {
|
return {
|
||||||
amountUsdt: item.amountUsdt || formatMicroAmount(item.amountUsdtMicro),
|
amountUsdt: item.amountUsdt || formatMicroAmount(item.amountUsdtMicro),
|
||||||
|
audienceType: item.audienceType === "coin_seller" ? "coin_seller" : "normal",
|
||||||
coinAmount: item.coinAmount,
|
coinAmount: item.coinAmount,
|
||||||
description: item.description || "",
|
description: item.description || "",
|
||||||
enabled,
|
enabled,
|
||||||
platform: item.platform === "ios" ? "ios" : "android",
|
platform: item.platform === "android" || item.platform === "ios" ? item.platform : "web",
|
||||||
productName: item.productName || "",
|
productName: item.productName || "",
|
||||||
regionIds: item.regionIds || [],
|
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) {
|
function formatMicroAmount(value) {
|
||||||
const numberValue = Number(value || 0);
|
const numberValue = Number(value || 0);
|
||||||
if (!Number.isFinite(numberValue) || numberValue <= 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 ContentCopyOutlined from "@mui/icons-material/ContentCopyOutlined";
|
||||||
import DownloadOutlined from "@mui/icons-material/DownloadOutlined";
|
import DownloadOutlined from "@mui/icons-material/DownloadOutlined";
|
||||||
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
|
|
||||||
import Tooltip from "@mui/material/Tooltip";
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { listRechargeBills } from "@/features/payment/api";
|
import { listRechargeBills } from "@/features/payment/api";
|
||||||
import {
|
import { AdminListBody, AdminListPage, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx";
|
||||||
AdminListBody,
|
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
||||||
AdminListPage,
|
|
||||||
AdminListToolbar,
|
|
||||||
} from "@/shared/ui/AdminListLayout.jsx";
|
|
||||||
import { Button } from "@/shared/ui/Button.jsx";
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
@ -70,10 +66,7 @@ const columns = [
|
|||||||
label: "国家/区域",
|
label: "国家/区域",
|
||||||
width: "minmax(150px, 0.75fr)",
|
width: "minmax(150px, 0.75fr)",
|
||||||
render: (bill, _index, context) => (
|
render: (bill, _index, context) => (
|
||||||
<Stack
|
<Stack primary={billCountryName(bill)} secondary={regionName(bill.targetRegionId, context?.regionNames)} />
|
||||||
primary={billCountryName(bill)}
|
|
||||||
secondary={regionName(bill.targetRegionId, context?.regionNames)}
|
|
||||||
/>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -315,19 +308,7 @@ function BillUser({ bill, type }) {
|
|||||||
return "-";
|
return "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
const shortId = profile.displayUserId || profile.userId || "-";
|
return <AdminUserIdentity openInAppUserDetail user={profile} />;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusBadge({ status }) {
|
function StatusBadge({ status }) {
|
||||||
@ -386,6 +367,8 @@ function normalizeBillUser(profile, fallbackId) {
|
|||||||
countryDisplayName: source.countryDisplayName || source.country_display_name || "",
|
countryDisplayName: source.countryDisplayName || source.country_display_name || "",
|
||||||
countryName: source.countryName || source.country_name || "",
|
countryName: source.countryName || source.country_name || "",
|
||||||
displayUserId: source.displayUserId || source.display_user_id || "",
|
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 || ""),
|
userId: String(source.userId || source.user_id || fallbackId || ""),
|
||||||
username: source.username || source.name || "",
|
username: source.username || source.name || "",
|
||||||
};
|
};
|
||||||
|
|||||||
@ -36,10 +36,17 @@ const statusOptions = [
|
|||||||
|
|
||||||
const platformOptions = [
|
const platformOptions = [
|
||||||
["", "全部平台"],
|
["", "全部平台"],
|
||||||
|
["web", "Web"],
|
||||||
["android", "Android"],
|
["android", "Android"],
|
||||||
["ios", "iOS"],
|
["ios", "iOS"],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const audienceOptions = [
|
||||||
|
["", "全部用户类型"],
|
||||||
|
["normal", "普通用户"],
|
||||||
|
["coin_seller", "币商"],
|
||||||
|
];
|
||||||
|
|
||||||
export function RechargeProductConfigPage() {
|
export function RechargeProductConfigPage() {
|
||||||
const page = useRechargeProductsPage();
|
const page = useRechargeProductsPage();
|
||||||
const items = page.data?.items || [];
|
const items = page.data?.items || [];
|
||||||
@ -107,9 +114,21 @@ export function RechargeProductConfigPage() {
|
|||||||
value={page.form.platform}
|
value={page.form.platform}
|
||||||
onChange={(event) => page.setForm({ platform: event.target.value })}
|
onChange={(event) => page.setForm({ platform: event.target.value })}
|
||||||
>
|
>
|
||||||
|
<MenuItem value="web">Web</MenuItem>
|
||||||
<MenuItem value="android">Android</MenuItem>
|
<MenuItem value="android">Android</MenuItem>
|
||||||
<MenuItem value="ios">iOS</MenuItem>
|
<MenuItem value="ios">iOS</MenuItem>
|
||||||
</TextField>
|
</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>
|
</AdminFormFieldGrid>
|
||||||
</AdminFormSection>
|
</AdminFormSection>
|
||||||
<AdminFormSection title="价格配置">
|
<AdminFormSection title="价格配置">
|
||||||
@ -205,6 +224,18 @@ function productColumns(page, regionLabels) {
|
|||||||
}),
|
}),
|
||||||
width: "minmax(120px, 0.55fr)",
|
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",
|
key: "regionIds",
|
||||||
label: "支持区域",
|
label: "支持区域",
|
||||||
@ -309,6 +340,9 @@ function Stack({ primary, secondary }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function platformLabel(value) {
|
function platformLabel(value) {
|
||||||
|
if (value === "web") {
|
||||||
|
return "Web";
|
||||||
|
}
|
||||||
if (value === "android") {
|
if (value === "android") {
|
||||||
return "Android";
|
return "Android";
|
||||||
}
|
}
|
||||||
@ -318,6 +352,16 @@ function platformLabel(value) {
|
|||||||
return value || "-";
|
return value || "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function audienceLabel(value) {
|
||||||
|
if (value === "coin_seller") {
|
||||||
|
return "币商";
|
||||||
|
}
|
||||||
|
if (value === "normal") {
|
||||||
|
return "普通用户";
|
||||||
|
}
|
||||||
|
return value || "普通用户";
|
||||||
|
}
|
||||||
|
|
||||||
function regionLabelMap(options) {
|
function regionLabelMap(options) {
|
||||||
return options.reduce((labels, option) => {
|
return options.reduce((labels, option) => {
|
||||||
labels[String(option.value)] = option.name || option.label;
|
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);
|
color: var(--text-tertiary);
|
||||||
font-size: var(--admin-font-size);
|
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),
|
canUpdateProduct: can(PERMISSIONS.paymentProductUpdate),
|
||||||
canViewBill: can(PERMISSIONS.paymentBillView),
|
canViewBill: can(PERMISSIONS.paymentBillView),
|
||||||
canViewProduct: can(PERMISSIONS.paymentProductView),
|
canViewProduct: can(PERMISSIONS.paymentProductView),
|
||||||
|
canUpdateThirdParty: can(PERMISSIONS.paymentThirdPartyUpdate),
|
||||||
|
canViewThirdParty: can(PERMISSIONS.paymentThirdPartyView),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,5 +8,22 @@ export const paymentRoutes = [
|
|||||||
pageKey: "payment-bill-list",
|
pageKey: "payment-bill-list",
|
||||||
path: "/payment/bills",
|
path: "/payment/bills",
|
||||||
permission: PERMISSIONS.paymentBillView,
|
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({
|
export const rechargeProductSchema = z.object({
|
||||||
amountUsdt: usdtAmountSchema,
|
amountUsdt: usdtAmountSchema,
|
||||||
|
audienceType: z.enum(["normal", "coin_seller"]),
|
||||||
coinAmount: z.coerce.number().int("获得金币数必须是整数").positive("获得金币数必须大于 0"),
|
coinAmount: z.coerce.number().int("获得金币数必须是整数").positive("获得金币数必须大于 0"),
|
||||||
description: z.string().trim().max(512, "描述不能超过 512 个字符").default(""),
|
description: z.string().trim().max(512, "描述不能超过 512 个字符").default(""),
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
platform: z.enum(["android", "ios"]),
|
platform: z.enum(["android", "ios", "web"]),
|
||||||
productName: z.string().trim().min(1, "请填写产品名称").max(128, "产品名称不能超过 128 个字符"),
|
productName: z.string().trim().min(1, "请填写产品名称").max(128, "产品名称不能超过 128 个字符"),
|
||||||
regionIds: z.array(z.coerce.number().int().positive()).min(1, "请选择支持区域"),
|
regionIds: z.array(z.coerce.number().int().positive()).min(1, "请选择支持区域"),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -25,6 +25,8 @@ export interface RegistrationRewardConfigPayload {
|
|||||||
export interface RegistrationRewardClaimUserDto {
|
export interface RegistrationRewardClaimUserDto {
|
||||||
userId?: number;
|
userId?: number;
|
||||||
displayUserId?: string;
|
displayUserId?: string;
|
||||||
|
prettyDisplayUserId?: string;
|
||||||
|
prettyId?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
}
|
}
|
||||||
@ -111,6 +113,8 @@ function normalizeClaim(item: RawClaim): RegistrationRewardClaimDto {
|
|||||||
user: {
|
user: {
|
||||||
userId: numberValue(rawUser.userId ?? rawUser.user_id),
|
userId: numberValue(rawUser.userId ?? rawUser.user_id),
|
||||||
displayUserId: stringValue(rawUser.displayUserId ?? rawUser.display_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),
|
username: stringValue(rawUser.username),
|
||||||
avatar: stringValue(rawUser.avatar),
|
avatar: stringValue(rawUser.avatar),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import Avatar from "@mui/material/Avatar";
|
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
import { AdminListBody, AdminListPage } from "@/shared/ui/AdminListLayout.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 { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
import { RegistrationRewardConfigDrawer } from "@/features/registration-reward/components/RegistrationRewardConfigDrawer.jsx";
|
import { RegistrationRewardConfigDrawer } from "@/features/registration-reward/components/RegistrationRewardConfigDrawer.jsx";
|
||||||
@ -132,17 +132,7 @@ export function RegistrationRewardPage() {
|
|||||||
|
|
||||||
function ClaimUser({ claim }) {
|
function ClaimUser({ claim }) {
|
||||||
const user = claim.user || {};
|
const user = claim.user || {};
|
||||||
const name = user.username || `用户 ${claim.userId}`;
|
return <AdminUserIdentity openInAppUserDetail user={{ ...user, userId: user.userId || 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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function RewardSummary({ claim }) {
|
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("今日已领")).toBeInTheDocument();
|
||||||
expect(screen.getByText("7 份")).toBeInTheDocument();
|
expect(screen.getByText("7 份")).toBeInTheDocument();
|
||||||
expect(screen.getAllByRole("button", { name: /领取时间/ }).some((node) => node.textContent.includes("领取时间"))).toBe(
|
expect(
|
||||||
true,
|
screen.getAllByRole("button", { name: /领取时间/ }).some((node) => node.textContent.includes("领取时间")),
|
||||||
);
|
).toBe(true);
|
||||||
expect(screen.getByText("jayCiE (165121)")).toBeInTheDocument();
|
expect(screen.getByText("jayCiE")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("短号 165121")).not.toBeInTheDocument();
|
expect(screen.getByText("165121")).toBeInTheDocument();
|
||||||
expect(screen.getAllByText("1,000 金币").length).toBeGreaterThanOrEqual(1);
|
expect(screen.getAllByText("1,000 金币").length).toBeGreaterThanOrEqual(1);
|
||||||
expect(screen.queryByText(/^注册奖励$/)).not.toBeInTheDocument();
|
expect(screen.queryByText(/^注册奖励$/)).not.toBeInTheDocument();
|
||||||
expect(screen.getByText("rclaim_1780684209628_899e9717839e0109")).toBeInTheDocument();
|
expect(screen.getByText("rclaim_1780684209628_899e9717839e0109")).toBeInTheDocument();
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
disableGift,
|
disableGift,
|
||||||
disableResource,
|
disableResource,
|
||||||
disableResourceGroup,
|
disableResourceGroup,
|
||||||
|
deleteGift,
|
||||||
enableResourceShopItem,
|
enableResourceShopItem,
|
||||||
enableGift,
|
enableGift,
|
||||||
enableResource,
|
enableResource,
|
||||||
@ -26,6 +27,7 @@ import {
|
|||||||
updateGift,
|
updateGift,
|
||||||
updateGiftTypes,
|
updateGiftTypes,
|
||||||
updateResource,
|
updateResource,
|
||||||
|
updateResourceMp4Layouts,
|
||||||
updateResourceGroup,
|
updateResourceGroup,
|
||||||
upsertResourceShopItems,
|
upsertResourceShopItems,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
@ -149,6 +151,7 @@ test("resource APIs use generated admin paths", async () => {
|
|||||||
});
|
});
|
||||||
await enableGift("rose");
|
await enableGift("rose");
|
||||||
await disableGift("rose");
|
await disableGift("rose");
|
||||||
|
await deleteGift("rose");
|
||||||
await listResourceGrants({ page: 1, page_size: 10, target_user_id: 1001, status: "succeeded" });
|
await listResourceGrants({ page: 1, page_size: 10, target_user_id: 1001, status: "succeeded" });
|
||||||
await lookupResourceGrantTarget("163000");
|
await lookupResourceGrantTarget("163000");
|
||||||
await grantResource({
|
await grantResource({
|
||||||
@ -180,10 +183,11 @@ test("resource APIs use generated admin paths", async () => {
|
|||||||
const [updateGiftUrl, updateGiftInit] = vi.mocked(fetch).mock.calls[11];
|
const [updateGiftUrl, updateGiftInit] = vi.mocked(fetch).mock.calls[11];
|
||||||
const [enableGiftUrl, enableGiftInit] = vi.mocked(fetch).mock.calls[12];
|
const [enableGiftUrl, enableGiftInit] = vi.mocked(fetch).mock.calls[12];
|
||||||
const [disableGiftUrl, disableGiftInit] = vi.mocked(fetch).mock.calls[13];
|
const [disableGiftUrl, disableGiftInit] = vi.mocked(fetch).mock.calls[13];
|
||||||
const [grantListUrl, grantListInit] = vi.mocked(fetch).mock.calls[14];
|
const [deleteGiftUrl, deleteGiftInit] = vi.mocked(fetch).mock.calls[14];
|
||||||
const [grantTargetUrl, grantTargetInit] = vi.mocked(fetch).mock.calls[15];
|
const [grantListUrl, grantListInit] = vi.mocked(fetch).mock.calls[15];
|
||||||
const [grantResourceUrl, grantResourceInit] = vi.mocked(fetch).mock.calls[16];
|
const [grantTargetUrl, grantTargetInit] = vi.mocked(fetch).mock.calls[16];
|
||||||
const [grantGroupUrl, grantGroupInit] = vi.mocked(fetch).mock.calls[17];
|
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("/api/v1/admin/resources?");
|
||||||
expect(String(listUrl)).toContain("resource_type=gift");
|
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(enableGiftInit?.method).toBe("POST");
|
||||||
expect(String(disableGiftUrl)).toContain("/api/v1/admin/gifts/rose/disable");
|
expect(String(disableGiftUrl)).toContain("/api/v1/admin/gifts/rose/disable");
|
||||||
expect(disableGiftInit?.method).toBe("POST");
|
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("/api/v1/admin/resource-grants?");
|
||||||
expect(String(grantListUrl)).toContain("target_user_id=1001");
|
expect(String(grantListUrl)).toContain("target_user_id=1001");
|
||||||
expect(grantListInit?.method).toBe("GET");
|
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 });
|
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 () => {
|
test("emoji pack APIs use generated admin paths", async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
|
|||||||
@ -48,6 +48,15 @@ export interface ResourcePayload {
|
|||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ResourceMp4LayoutUpdatePayload {
|
||||||
|
metadataJson: string;
|
||||||
|
resourceId: EntityId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourceMp4LayoutsPayload {
|
||||||
|
items: ResourceMp4LayoutUpdatePayload[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface EmojiPackDto {
|
export interface EmojiPackDto {
|
||||||
appCode?: string;
|
appCode?: string;
|
||||||
resourceId: number;
|
resourceId: number;
|
||||||
@ -196,6 +205,8 @@ export interface ResourceGrantItemDto {
|
|||||||
export interface ResourceGrantOperatorDto {
|
export interface ResourceGrantOperatorDto {
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
displayUserId?: string;
|
displayUserId?: string;
|
||||||
|
prettyDisplayUserId?: string;
|
||||||
|
prettyId?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
source?: string;
|
source?: string;
|
||||||
userId?: number | string;
|
userId?: number | string;
|
||||||
@ -205,6 +216,8 @@ export interface ResourceGrantOperatorDto {
|
|||||||
export interface ResourceGrantUserDto {
|
export interface ResourceGrantUserDto {
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
displayUserId?: string;
|
displayUserId?: string;
|
||||||
|
prettyDisplayUserId?: string;
|
||||||
|
prettyId?: string;
|
||||||
userId?: number | string;
|
userId?: number | string;
|
||||||
username?: 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> {
|
export function enableResource(resourceId: EntityId): Promise<ResourceDto> {
|
||||||
const endpoint = API_ENDPOINTS.enableResource;
|
const endpoint = API_ENDPOINTS.enableResource;
|
||||||
return apiRequest<ResourceDto>(apiEndpointPath(API_OPERATIONS.enableResource, { resource_id: resourceId }), {
|
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> {
|
export function enableGift(giftId: string): Promise<GiftDto> {
|
||||||
const endpoint = API_ENDPOINTS.enableGift;
|
const endpoint = API_ENDPOINTS.enableGift;
|
||||||
return apiRequest<GiftDto>(apiEndpointPath(API_OPERATIONS.enableGift, { gift_id: giftId }), {
|
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;
|
export const resourceBatchUploadSize = 10;
|
||||||
|
|
||||||
@ -109,7 +109,7 @@ export function resourcePlanToPayload(item) {
|
|||||||
badgeKind: item.resourceType === "badge" ? "normal" : undefined,
|
badgeKind: item.resourceType === "badge" ? "normal" : undefined,
|
||||||
coinPrice,
|
coinPrice,
|
||||||
managerGrantEnabled: true,
|
managerGrantEnabled: true,
|
||||||
metadataJson: item.resourceType === "profile_card" ? profileCardMetadataJSON(item.metadataJson) : undefined,
|
metadataJson: resourceMetadataJSON(item.resourceType, item.metadataJson),
|
||||||
name: item.name,
|
name: item.name,
|
||||||
previewUrl: item.coverUrl,
|
previewUrl: item.coverUrl,
|
||||||
priceType: coinPrice > 0 ? "coin" : "free",
|
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" });
|
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 () => {
|
test("translates chinese names to english resource codes with API fallback", async () => {
|
||||||
vi.spyOn(Math, "random")
|
vi.spyOn(Math, "random")
|
||||||
.mockReturnValueOnce(0)
|
.mockReturnValueOnce(0)
|
||||||
@ -170,3 +230,15 @@ test("reports translate progress after suffix is added", async () => {
|
|||||||
function file(name) {
|
function file(name) {
|
||||||
return new File(["payload"], name, { type: "image/png" });
|
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 { parseForm } from "@/shared/forms/validation";
|
||||||
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
|
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
|
||||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||||
|
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
||||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
import {
|
import {
|
||||||
createEmojiPack,
|
createEmojiPack,
|
||||||
createGift,
|
createGift,
|
||||||
createResource,
|
createResource,
|
||||||
createResourceGroup,
|
createResourceGroup,
|
||||||
|
deleteGift as requestDeleteGift,
|
||||||
disableResourceShopItem,
|
disableResourceShopItem,
|
||||||
disableGift,
|
disableGift,
|
||||||
disableResource,
|
disableResource,
|
||||||
@ -40,7 +42,7 @@ import {
|
|||||||
resourceShopSellableTypes,
|
resourceShopSellableTypes,
|
||||||
} from "@/features/resources/constants.js";
|
} from "@/features/resources/constants.js";
|
||||||
import { useResourceAbilities } from "@/features/resources/permissions.js";
|
import { useResourceAbilities } from "@/features/resources/permissions.js";
|
||||||
import { profileCardMetadataJSON } from "@/features/resources/profileCardLayout.js";
|
import { resourceMetadataJSON } from "@/features/resources/resourceMetadata.js";
|
||||||
import {
|
import {
|
||||||
emojiPackFormSchema,
|
emojiPackFormSchema,
|
||||||
giftFormSchema,
|
giftFormSchema,
|
||||||
@ -73,7 +75,10 @@ const emptyResourceForm = (resource = {}) => ({
|
|||||||
: "normal",
|
: "normal",
|
||||||
coinPrice: resource.coinPrice === 0 || resource.coinPrice ? String(resource.coinPrice) : "",
|
coinPrice: resource.coinPrice === 0 || resource.coinPrice ? String(resource.coinPrice) : "",
|
||||||
enabled: resource.status ? resource.status === "active" : true,
|
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:
|
managerGrantEnabled:
|
||||||
resource.managerGrantEnabled === undefined || resource.managerGrantEnabled === null
|
resource.managerGrantEnabled === undefined || resource.managerGrantEnabled === null
|
||||||
? true
|
? true
|
||||||
@ -166,7 +171,11 @@ export function applyGiftPriceDefaults(form, coinPrice) {
|
|||||||
export function cpRelationTypeFromPresentationJson(presentationJson) {
|
export function cpRelationTypeFromPresentationJson(presentationJson) {
|
||||||
const presentation = parseGiftPresentationObject(presentationJson);
|
const presentation = parseGiftPresentationObject(presentationJson);
|
||||||
const relationType = cpPresentationRelationKeys
|
const relationType = cpPresentationRelationKeys
|
||||||
.map((key) => String(presentation?.[key] || "").trim().toLowerCase())
|
.map((key) =>
|
||||||
|
String(presentation?.[key] || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase(),
|
||||||
|
)
|
||||||
.find((value) => cpRelationTypeValues.has(value));
|
.find((value) => cpRelationTypeValues.has(value));
|
||||||
return relationType || "";
|
return relationType || "";
|
||||||
}
|
}
|
||||||
@ -252,7 +261,9 @@ function normalizedCPRelationType(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function validCPRelationType(value) {
|
function validCPRelationType(value) {
|
||||||
const relationType = String(value || "").trim().toLowerCase();
|
const relationType = String(value || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
return cpRelationTypeValues.has(relationType) ? relationType : "";
|
return cpRelationTypeValues.has(relationType) ? relationType : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -877,6 +888,7 @@ export function useResourceGroupListPage() {
|
|||||||
|
|
||||||
export function useGiftListPage() {
|
export function useGiftListPage() {
|
||||||
const abilities = useResourceAbilities();
|
const abilities = useResourceAbilities();
|
||||||
|
const confirm = useConfirm();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [status, setStatus] = 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) => {
|
const saveGiftSortOrder = async (gift, rawSortOrder) => {
|
||||||
if (!abilities.canUpdateGift || !gift?.giftId) {
|
if (!abilities.canUpdateGift || !gift?.giftId) {
|
||||||
return false;
|
return false;
|
||||||
@ -1191,6 +1238,7 @@ export function useGiftListPage() {
|
|||||||
changeGiftTypeCode: resetSetter(setGiftTypeCode, setPage),
|
changeGiftTypeCode: resetSetter(setGiftTypeCode, setPage),
|
||||||
closeAction,
|
closeAction,
|
||||||
closeGiftTypeDialog,
|
closeGiftTypeDialog,
|
||||||
|
deleteGifts,
|
||||||
form,
|
form,
|
||||||
giftSortSavingIds,
|
giftSortSavingIds,
|
||||||
giftTypeCode,
|
giftTypeCode,
|
||||||
@ -1519,7 +1567,7 @@ function buildResourcePayload(form) {
|
|||||||
coinPrice: form.priceType === "coin" ? Number(form.coinPrice) : 0,
|
coinPrice: form.priceType === "coin" ? Number(form.coinPrice) : 0,
|
||||||
levelTrack: resourceType === "badge" && form.badgeKind === "level" ? form.levelTrack : undefined,
|
levelTrack: resourceType === "badge" && form.badgeKind === "level" ? form.levelTrack : undefined,
|
||||||
managerGrantEnabled: Boolean(form.managerGrantEnabled),
|
managerGrantEnabled: Boolean(form.managerGrantEnabled),
|
||||||
metadataJson: resourceType === "profile_card" ? profileCardMetadataJSON(form.metadataJson) : undefined,
|
metadataJson: resourceMetadataJSON(resourceType, form.metadataJson),
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
previewUrl: form.previewUrl.trim(),
|
previewUrl: form.previewUrl.trim(),
|
||||||
priceType: form.priceType,
|
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 CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
|
||||||
import ChevronLeftOutlined from "@mui/icons-material/ChevronLeftOutlined";
|
import ChevronLeftOutlined from "@mui/icons-material/ChevronLeftOutlined";
|
||||||
import ChevronRightOutlined from "@mui/icons-material/ChevronRightOutlined";
|
import ChevronRightOutlined from "@mui/icons-material/ChevronRightOutlined";
|
||||||
|
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||||
|
import PublicOutlined from "@mui/icons-material/PublicOutlined";
|
||||||
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
|
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 IconButton from "@mui/material/IconButton";
|
||||||
import InputAdornment from "@mui/material/InputAdornment";
|
import InputAdornment from "@mui/material/InputAdornment";
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import Popover from "@mui/material/Popover";
|
import Popover from "@mui/material/Popover";
|
||||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
AdminFormDialog,
|
AdminFormDialog,
|
||||||
AdminFormFieldGrid,
|
AdminFormFieldGrid,
|
||||||
@ -97,7 +101,16 @@ const baseColumns = (giftTypeOptions) => [
|
|||||||
render: (gift) => (
|
render: (gift) => (
|
||||||
<div className={styles.stack}>
|
<div className={styles.stack}>
|
||||||
{giftEffectDetails(gift).map((item, index) => (
|
{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}
|
{item}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@ -118,7 +131,7 @@ const baseColumns = (giftTypeOptions) => [
|
|||||||
{
|
{
|
||||||
key: "actions",
|
key: "actions",
|
||||||
label: "操作",
|
label: "操作",
|
||||||
width: "minmax(76px, 0.4fr)",
|
width: "minmax(112px, 0.5fr)",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -126,11 +139,84 @@ export function GiftListPage() {
|
|||||||
const page = useGiftListPage();
|
const page = useGiftListPage();
|
||||||
const items = page.data.items || [];
|
const items = page.data.items || [];
|
||||||
const total = page.data.total || 0;
|
const total = page.data.total || 0;
|
||||||
|
const [selectedGiftIds, setSelectedGiftIds] = useState([]);
|
||||||
const isEditing = page.activeAction === "edit";
|
const isEditing = page.activeAction === "edit";
|
||||||
const giftRegionOptions = [{ label: "全局 · 0", value: "0" }, ...page.regionOptions];
|
const giftRegionOptions = [{ label: "全局 · 0", value: "0" }, ...page.regionOptions];
|
||||||
const regionLabelById = buildRegionLabelMap(giftRegionOptions);
|
const regionLabelById = buildRegionLabelMap(giftRegionOptions);
|
||||||
const giftTypeOptions = page.giftTypeOptions?.length ? page.giftTypeOptions : defaultGiftTypeOptions;
|
const giftTypeOptions = page.giftTypeOptions?.length ? page.giftTypeOptions : defaultGiftTypeOptions;
|
||||||
const tableColumns = baseColumns(giftTypeOptions).map((column) => {
|
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([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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") {
|
if (column.key === "regions") {
|
||||||
return {
|
return {
|
||||||
...column,
|
...column,
|
||||||
@ -194,7 +280,8 @@ export function GiftListPage() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
return column;
|
return column;
|
||||||
});
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminListPage>
|
<AdminListPage>
|
||||||
@ -206,6 +293,19 @@ export function GiftListPage() {
|
|||||||
<SettingsOutlined fontSize="small" />
|
<SettingsOutlined fontSize="small" />
|
||||||
</AdminActionIconButton>
|
</AdminActionIconButton>
|
||||||
) : null}
|
) : 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 ? (
|
{page.abilities.canCreateGift ? (
|
||||||
<AdminActionIconButton label="添加礼物" primary onClick={page.openCreateGift}>
|
<AdminActionIconButton label="添加礼物" primary onClick={page.openCreateGift}>
|
||||||
<Add fontSize="small" />
|
<Add fontSize="small" />
|
||||||
@ -219,7 +319,7 @@ export function GiftListPage() {
|
|||||||
<DataTable
|
<DataTable
|
||||||
columns={tableColumns}
|
columns={tableColumns}
|
||||||
items={items}
|
items={items}
|
||||||
minWidth="1440px"
|
minWidth="1500px"
|
||||||
pagination={
|
pagination={
|
||||||
total > 0
|
total > 0
|
||||||
? {
|
? {
|
||||||
@ -260,6 +360,9 @@ export function GiftListPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function GiftRowActions({ gift, page }) {
|
function GiftRowActions({ gift, page }) {
|
||||||
|
const deleting = page.loadingAction === `gift-delete-${gift.giftId}`;
|
||||||
|
const deletingBatch = page.loadingAction === "gift-delete-batch";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminRowActions>
|
<AdminRowActions>
|
||||||
<AdminActionIconButton
|
<AdminActionIconButton
|
||||||
@ -269,6 +372,15 @@ function GiftRowActions({ gift, page }) {
|
|||||||
>
|
>
|
||||||
<EditOutlined fontSize="small" />
|
<EditOutlined fontSize="small" />
|
||||||
</AdminActionIconButton>
|
</AdminActionIconButton>
|
||||||
|
{page.abilities.canDeleteGift ? (
|
||||||
|
<AdminActionIconButton
|
||||||
|
disabled={deleting || deletingBatch}
|
||||||
|
label={deleting ? "删除中" : "删除礼物"}
|
||||||
|
onClick={() => page.deleteGifts([gift])}
|
||||||
|
>
|
||||||
|
{deleting ? <CircularProgress size={16} /> : <DeleteOutlineOutlined fontSize="small" />}
|
||||||
|
</AdminActionIconButton>
|
||||||
|
) : null}
|
||||||
</AdminRowActions>
|
</AdminRowActions>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -641,10 +753,16 @@ function GiftIdentity({ gift }) {
|
|||||||
const preview = imageURL(gift.resource?.previewUrl || gift.resource?.assetUrl);
|
const preview = imageURL(gift.resource?.previewUrl || gift.resource?.assetUrl);
|
||||||
const resourceCode = String(gift.resource?.resourceCode || "").trim();
|
const resourceCode = String(gift.resource?.resourceCode || "").trim();
|
||||||
const title = `${gift.name || "-"}${resourceCode ? `(${resourceCode})` : ""}`;
|
const title = `${gift.name || "-"}${resourceCode ? `(${resourceCode})` : ""}`;
|
||||||
|
const globalBroadcast = hasGlobalBroadcastEffect(gift);
|
||||||
return (
|
return (
|
||||||
<div className={styles.identity}>
|
<div className={styles.identity}>
|
||||||
<span className={styles.thumb}>
|
<span className={styles.thumb}>
|
||||||
{preview ? <img src={preview} alt="" /> : <CardGiftcardOutlined fontSize="small" />}
|
{preview ? <img src={preview} alt="" /> : <CardGiftcardOutlined fontSize="small" />}
|
||||||
|
{globalBroadcast ? (
|
||||||
|
<span className={styles.giftGlobalBroadcastBadge} title="全局广播">
|
||||||
|
<PublicOutlined fontSize="inherit" />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
<div className={styles.identityText}>
|
<div className={styles.identityText}>
|
||||||
<span className={styles.name}>{title}</span>
|
<span className={styles.name}>{title}</span>
|
||||||
@ -689,6 +807,10 @@ function effectTypesLabel(values) {
|
|||||||
return list.length ? list.map(effectTypeLabel).join("、") : "无特效";
|
return list.length ? list.map(effectTypeLabel).join("、") : "无特效";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasGlobalBroadcastEffect(gift) {
|
||||||
|
return (gift.effectTypes || []).some((item) => String(item || "").trim() === "global_broadcast");
|
||||||
|
}
|
||||||
|
|
||||||
function giftEffectDetails(gift) {
|
function giftEffectDetails(gift) {
|
||||||
const details = [];
|
const details = [];
|
||||||
const effective = effectiveRangeLabel(gift);
|
const effective = effectiveRangeLabel(gift);
|
||||||
|
|||||||
@ -1,16 +1,11 @@
|
|||||||
import Add from "@mui/icons-material/Add";
|
import Add from "@mui/icons-material/Add";
|
||||||
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
|
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import { AdminFormDialog, AdminFormFieldGrid, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
|
import { AdminFormDialog, AdminFormFieldGrid, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
|
||||||
|
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
import {
|
import { AdminActionIconButton, AdminListBody, AdminListPage, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx";
|
||||||
AdminActionIconButton,
|
|
||||||
AdminListBody,
|
|
||||||
AdminListPage,
|
|
||||||
AdminListToolbar,
|
|
||||||
} from "@/shared/ui/AdminListLayout.jsx";
|
|
||||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
import { GiftResourceSelectField } from "@/features/resources/components/GiftResourceSelectDrawer.jsx";
|
import { GiftResourceSelectField } from "@/features/resources/components/GiftResourceSelectDrawer.jsx";
|
||||||
@ -233,7 +228,9 @@ function ResourceGrantDialog({
|
|||||||
required
|
required
|
||||||
resources={resourceOptions}
|
resources={resourceOptions}
|
||||||
selectedResourceIds={form.resourceIds || []}
|
selectedResourceIds={form.resourceIds || []}
|
||||||
onChange={(resourceIds) => setForm({ ...form, resourceId: resourceIds[0] || "", resourceIds })}
|
onChange={(resourceIds) =>
|
||||||
|
setForm({ ...form, resourceId: resourceIds[0] || "", resourceIds })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@ -305,20 +302,12 @@ function GrantItems({ grant }) {
|
|||||||
function GrantTargetUser({ grant }) {
|
function GrantTargetUser({ grant }) {
|
||||||
const user = grant.targetUser || grant.target_user || grant.user || {};
|
const user = grant.targetUser || grant.target_user || grant.user || {};
|
||||||
const userId = user.userId || user.user_id || grant.targetUserId;
|
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 (
|
return (
|
||||||
<div className={styles.identity}>
|
<AdminUserIdentity
|
||||||
<span className={styles.operatorAvatar}>
|
openInAppUserDetail
|
||||||
{user.avatar ? <img alt="" src={user.avatar} /> : <PersonOutlineOutlined fontSize="small" />}
|
rows={[user.displayUserId || user.display_user_id, userId]}
|
||||||
</span>
|
user={{ ...user, userId }}
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -327,22 +316,12 @@ function GrantOperator({ grant }) {
|
|||||||
const source = operator.source || grant.grantSource || "";
|
const source = operator.source || grant.grantSource || "";
|
||||||
const operatorUserId = operator.userId || grant.operatorUserId;
|
const operatorUserId = operator.userId || grant.operatorUserId;
|
||||||
if (source === "manager_center") {
|
if (source === "manager_center") {
|
||||||
const name = operator.username || operator.name || (operatorUserId ? `用户 ${operatorUserId}` : "-");
|
|
||||||
const shortId = operator.displayUserId || operatorUserId || "-";
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.identity}>
|
<AdminUserIdentity
|
||||||
<span className={styles.operatorAvatar}>
|
openInAppUserDetail
|
||||||
{operator.avatar ? (
|
name={operator.username || operator.name}
|
||||||
<img alt="" src={operator.avatar} />
|
user={{ ...operator, userId: operatorUserId }}
|
||||||
) : (
|
/>
|
||||||
<PersonOutlineOutlined fontSize="small" />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span className={styles.identityText}>
|
|
||||||
<span className={styles.name}>{name}</span>
|
|
||||||
<span className={styles.meta}>{shortId}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (source === "admin") {
|
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