Compare commits
3 Commits
64cfa472b8
...
f5d06e1e52
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5d06e1e52 | ||
|
|
7c4bdf8d77 | ||
|
|
e25258f233 |
@ -132,6 +132,28 @@
|
|||||||
"x-permissions": ["user-leaderboard:view"]
|
"x-permissions": ["user-leaderboard:view"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/admin/activity/cp/config": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "getCPConfig",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "cp-config:view",
|
||||||
|
"x-permissions": ["cp-config:view"]
|
||||||
|
},
|
||||||
|
"put": {
|
||||||
|
"operationId": "updateCPConfig",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "cp-config:update",
|
||||||
|
"x-permissions": ["cp-config:update"]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/admin/activity/red-packets": {
|
"/admin/activity/red-packets": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listRedPackets",
|
"operationId": "listRedPackets",
|
||||||
@ -622,6 +644,55 @@
|
|||||||
"x-permissions": ["seven-day-checkin:update"]
|
"x-permissions": ["seven-day-checkin:update"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/admin/managers": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "listManagers",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/parameters/Page"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/parameters/PageSize"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/parameters/Keyword"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/parameters/Status"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/parameters/RegionId"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/ManagerPageResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "bd:view",
|
||||||
|
"x-permissions": ["bd:view"]
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"operationId": "createManager",
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/CreateManagerPayload"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"$ref": "#/components/responses/ManagerResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "bd:create",
|
||||||
|
"x-permissions": ["bd:create"]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/admin/agencies": {
|
"/admin/agencies": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listAgencies",
|
"operationId": "listAgencies",
|
||||||
@ -2333,6 +2404,59 @@
|
|||||||
"x-permissions": ["coin-seller-ledger:view"]
|
"x-permissions": ["coin-seller-ledger:view"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/admin/operations/full-server-notices/fanout": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "createFullServerNoticeFanout",
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["message_type", "target_scope", "title", "summary"],
|
||||||
|
"properties": {
|
||||||
|
"action_param": { "type": "string" },
|
||||||
|
"action_type": { "type": "string" },
|
||||||
|
"aggregate_id": { "type": "string" },
|
||||||
|
"aggregate_type": { "type": "string" },
|
||||||
|
"batch_size": { "type": "integer" },
|
||||||
|
"body": { "type": "string" },
|
||||||
|
"command_id": { "type": "string" },
|
||||||
|
"country": { "type": "string" },
|
||||||
|
"expire_at_ms": { "type": "integer", "format": "int64" },
|
||||||
|
"icon_url": { "type": "string" },
|
||||||
|
"image_url": { "type": "string" },
|
||||||
|
"message_type": { "type": "string", "enum": ["system", "activity"] },
|
||||||
|
"metadata_json": { "type": "string" },
|
||||||
|
"priority": { "type": "integer" },
|
||||||
|
"producer_event_type": { "type": "string" },
|
||||||
|
"region_id": { "type": "integer", "format": "int64" },
|
||||||
|
"sent_at_ms": { "type": "integer", "format": "int64" },
|
||||||
|
"summary": { "type": "string" },
|
||||||
|
"target_scope": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["all_active_users", "single_user", "user_ids", "region", "country"]
|
||||||
|
},
|
||||||
|
"target_user_id": { "type": "integer", "format": "int64" },
|
||||||
|
"title": { "type": "string" },
|
||||||
|
"user_ids": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "integer", "format": "int64" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "full-server-notice:send",
|
||||||
|
"x-permissions": ["full-server-notice:send"]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/admin/operations/reports": {
|
"/admin/operations/reports": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listReports",
|
"operationId": "listReports",
|
||||||
@ -2477,6 +2601,18 @@
|
|||||||
"x-permissions": ["payment-third-party:update"]
|
"x-permissions": ["payment-third-party:update"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/admin/payment/third-party-rates/sync": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "syncThirdPartyPaymentRates",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "payment-third-party:update",
|
||||||
|
"x-permissions": ["payment-third-party:update"]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/admin/regions": {
|
"/admin/regions": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listRegions",
|
"operationId": "listRegions",
|
||||||
@ -3077,6 +3213,84 @@
|
|||||||
"x-permissions": ["room-pin:cancel"]
|
"x-permissions": ["room-pin:cancel"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/admin/rooms/robot-rooms": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "listRobotRooms",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "room-robot:view",
|
||||||
|
"x-permissions": ["room-robot:view"]
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"operationId": "createRobotRoom",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "room-robot:create",
|
||||||
|
"x-permissions": ["room-robot:create"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/admin/rooms/robot-rooms/available-robots": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "listAvailableRoomRobots",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "room-robot:view",
|
||||||
|
"x-permissions": ["room-robot:view"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/admin/rooms/robot-rooms/{room_id}/start": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "startRobotRoom",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "room_id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"x-permission": "room-robot:update",
|
||||||
|
"x-permissions": ["room-robot:update"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/admin/rooms/robot-rooms/{room_id}/stop": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "stopRobotRoom",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "room_id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"x-permission": "room-robot:update",
|
||||||
|
"x-permissions": ["room-robot:update"]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/admin/users/level-config": {
|
"/admin/users/level-config": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listLevelConfig",
|
"operationId": "listLevelConfig",
|
||||||
@ -4283,6 +4497,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"ManagerPageResponse": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiResponseManagerPage"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ManagerResponse": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiResponseManager"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"BDProfilePageResponse": {
|
"BDProfilePageResponse": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
"content": {
|
"content": {
|
||||||
@ -4476,6 +4710,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"ApiPageManager": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["items", "page", "pageSize", "total"],
|
||||||
|
"properties": {
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/Manager"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"pageSize": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"ApiPageBDProfile": {
|
"ApiPageBDProfile": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["items", "page", "pageSize", "total"],
|
"required": ["items", "page", "pageSize", "total"],
|
||||||
@ -4590,6 +4845,36 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"ApiResponseManagerPage": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/Envelope"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"data": {
|
||||||
|
"$ref": "#/components/schemas/ApiPageManager"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ApiResponseManager": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/Envelope"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"data": {
|
||||||
|
"$ref": "#/components/schemas/Manager"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"ApiResponseBDProfilePage": {
|
"ApiResponseBDProfilePage": {
|
||||||
"allOf": [
|
"allOf": [
|
||||||
{
|
{
|
||||||
@ -4908,6 +5193,62 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Manager": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["userId"],
|
||||||
|
"properties": {
|
||||||
|
"avatar": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"bdLeaderCount": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"createdAtMs": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"displayUserId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"lastInvitedAtMs": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"regionId": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"regionName": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["active", "disabled"]
|
||||||
|
},
|
||||||
|
"updatedAtMs": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"userId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"CreateManagerPayload": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["targetUserId"],
|
||||||
|
"properties": {
|
||||||
|
"contact": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 128
|
||||||
|
},
|
||||||
|
"targetUserId": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"BDProfile": {
|
"BDProfile": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["userId"],
|
"required": ["userId"],
|
||||||
|
|||||||
@ -141,6 +141,7 @@ export const fallbackNavigation = [
|
|||||||
routeNavItem("room-list", { icon: BedroomParentOutlined }),
|
routeNavItem("room-list", { icon: BedroomParentOutlined }),
|
||||||
routeNavItem("room-pins", { icon: PushPinOutlined }),
|
routeNavItem("room-pins", { icon: PushPinOutlined }),
|
||||||
routeNavItem("room-config", { icon: SettingsApplicationsOutlined }),
|
routeNavItem("room-config", { icon: SettingsApplicationsOutlined }),
|
||||||
|
routeNavItem("room-robots", { icon: GroupsOutlined }),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -183,6 +184,7 @@ export const fallbackNavigation = [
|
|||||||
routeNavItem("lucky-gift", { icon: RedeemOutlined }),
|
routeNavItem("lucky-gift", { icon: RedeemOutlined }),
|
||||||
routeNavItem("operation-reports", { icon: FlagOutlined }),
|
routeNavItem("operation-reports", { icon: FlagOutlined }),
|
||||||
routeNavItem("operation-gift-diamond", { icon: DiamondOutlined }),
|
routeNavItem("operation-gift-diamond", { icon: DiamondOutlined }),
|
||||||
|
routeNavItem("operation-full-server-notice", { icon: CampaignOutlined }),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -211,6 +213,7 @@ export const fallbackNavigation = [
|
|||||||
routeNavItem("room-rocket", { icon: RedeemOutlined }),
|
routeNavItem("room-rocket", { icon: RedeemOutlined }),
|
||||||
routeNavItem("room-turnover-reward", { icon: PaidOutlined }),
|
routeNavItem("room-turnover-reward", { icon: PaidOutlined }),
|
||||||
routeNavItem("weekly-star", { icon: StarBorderOutlined }),
|
routeNavItem("weekly-star", { icon: StarBorderOutlined }),
|
||||||
|
routeNavItem("agency-opening", { icon: StorefrontOutlined }),
|
||||||
routeNavItem("user-leaderboard", { icon: LeaderboardOutlined }),
|
routeNavItem("user-leaderboard", { icon: LeaderboardOutlined }),
|
||||||
routeNavItem("red-packet", { icon: RedeemOutlined }),
|
routeNavItem("red-packet", { icon: RedeemOutlined }),
|
||||||
routeNavItem("cp-config", { icon: FavoriteBorderOutlined }),
|
routeNavItem("cp-config", { icon: FavoriteBorderOutlined }),
|
||||||
|
|||||||
@ -34,7 +34,7 @@ describe("navigation menu helpers", () => {
|
|||||||
test("keeps only fallback menu items allowed by permissions", () => {
|
test("keeps only fallback menu items allowed by permissions", () => {
|
||||||
const items = filterNavigationByPermission(
|
const items = filterNavigationByPermission(
|
||||||
fallbackNavigation,
|
fallbackNavigation,
|
||||||
(code) => code === "agency:view" || code === "host:view",
|
(code) => code === "agency:view" || code === "bd:view" || code === "host:view",
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(flattenCodes(items)).not.toContain("geo");
|
expect(flattenCodes(items)).not.toContain("geo");
|
||||||
@ -42,7 +42,8 @@ describe("navigation menu helpers", () => {
|
|||||||
expect(flattenCodes(items)).toContain("host-org-managers");
|
expect(flattenCodes(items)).toContain("host-org-managers");
|
||||||
expect(flattenCodes(items)).toContain("host-org-agencies");
|
expect(flattenCodes(items)).toContain("host-org-agencies");
|
||||||
expect(flattenCodes(items)).toContain("host-org-hosts");
|
expect(flattenCodes(items)).toContain("host-org-hosts");
|
||||||
expect(flattenCodes(items)).not.toContain("host-org-bds");
|
expect(flattenCodes(items)).toContain("host-org-bd-leaders");
|
||||||
|
expect(flattenCodes(items)).toContain("host-org-bds");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("places country and region pages under geo menu", () => {
|
test("places country and region pages under geo menu", () => {
|
||||||
@ -77,7 +78,10 @@ describe("navigation menu helpers", () => {
|
|||||||
label: "团队管理",
|
label: "团队管理",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const fallbackMenus = filterNavigationByPermission(fallbackNavigation, (code) => code === "agency:view");
|
const fallbackMenus = filterNavigationByPermission(
|
||||||
|
fallbackNavigation,
|
||||||
|
(code) => code === "agency:view" || code === "bd:view",
|
||||||
|
);
|
||||||
const merged = mergeNavigationItems(backendMenus, fallbackMenus);
|
const merged = mergeNavigationItems(backendMenus, fallbackMenus);
|
||||||
|
|
||||||
expect(flattenCodes(merged)).toContain("host-org-agencies");
|
expect(flattenCodes(merged)).toContain("host-org-agencies");
|
||||||
|
|||||||
@ -45,6 +45,8 @@ export const PERMISSIONS = {
|
|||||||
reportView: "report:view",
|
reportView: "report:view",
|
||||||
giftDiamondView: "gift-diamond:view",
|
giftDiamondView: "gift-diamond:view",
|
||||||
giftDiamondUpdate: "gift-diamond:update",
|
giftDiamondUpdate: "gift-diamond:update",
|
||||||
|
fullServerNoticeView: "full-server-notice:view",
|
||||||
|
fullServerNoticeSend: "full-server-notice:send",
|
||||||
paymentBillView: "payment-bill:view",
|
paymentBillView: "payment-bill:view",
|
||||||
paymentProductView: "payment-product:view",
|
paymentProductView: "payment-product:view",
|
||||||
paymentProductCreate: "payment-product:create",
|
paymentProductCreate: "payment-product:create",
|
||||||
@ -97,6 +99,9 @@ export const PERMISSIONS = {
|
|||||||
roomPinCancel: "room-pin:cancel",
|
roomPinCancel: "room-pin:cancel",
|
||||||
roomConfigView: "room-config:view",
|
roomConfigView: "room-config:view",
|
||||||
roomConfigUpdate: "room-config:update",
|
roomConfigUpdate: "room-config:update",
|
||||||
|
roomRobotView: "room-robot:view",
|
||||||
|
roomRobotCreate: "room-robot:create",
|
||||||
|
roomRobotUpdate: "room-robot:update",
|
||||||
appConfigView: "app-config:view",
|
appConfigView: "app-config:view",
|
||||||
appConfigUpdate: "app-config:update",
|
appConfigUpdate: "app-config:update",
|
||||||
appVersionView: "app-version:view",
|
appVersionView: "app-version:view",
|
||||||
@ -156,6 +161,9 @@ export const PERMISSIONS = {
|
|||||||
weeklyStarCreate: "weekly-star:create",
|
weeklyStarCreate: "weekly-star:create",
|
||||||
weeklyStarUpdate: "weekly-star:update",
|
weeklyStarUpdate: "weekly-star:update",
|
||||||
weeklyStarSettle: "weekly-star:settle",
|
weeklyStarSettle: "weekly-star:settle",
|
||||||
|
agencyOpeningView: "agency-opening:view",
|
||||||
|
agencyOpeningCreate: "agency-opening:create",
|
||||||
|
agencyOpeningUpdate: "agency-opening:update",
|
||||||
uploadCreate: "upload:create",
|
uploadCreate: "upload:create",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@ -183,6 +191,7 @@ export const MENU_CODES = {
|
|||||||
roomList: "room-list",
|
roomList: "room-list",
|
||||||
roomPins: "room-pins",
|
roomPins: "room-pins",
|
||||||
roomConfig: "room-config",
|
roomConfig: "room-config",
|
||||||
|
roomRobots: "room-robots",
|
||||||
appConfig: "app-config",
|
appConfig: "app-config",
|
||||||
appConfigH5: "app-config-h5",
|
appConfigH5: "app-config-h5",
|
||||||
appConfigBanners: "app-config-banners",
|
appConfigBanners: "app-config-banners",
|
||||||
@ -203,6 +212,7 @@ export const MENU_CODES = {
|
|||||||
operationCoinAdjustment: "operation-coin-adjustment",
|
operationCoinAdjustment: "operation-coin-adjustment",
|
||||||
operationReports: "operation-reports",
|
operationReports: "operation-reports",
|
||||||
operationGiftDiamond: "operation-gift-diamond",
|
operationGiftDiamond: "operation-gift-diamond",
|
||||||
|
operationFullServerNotice: "operation-full-server-notice",
|
||||||
luckyGift: "lucky-gift",
|
luckyGift: "lucky-gift",
|
||||||
activities: "activities",
|
activities: "activities",
|
||||||
dailyTaskList: "daily-task-list",
|
dailyTaskList: "daily-task-list",
|
||||||
@ -219,6 +229,7 @@ export const MENU_CODES = {
|
|||||||
cpConfig: "cp-config",
|
cpConfig: "cp-config",
|
||||||
vipConfig: "vip-config",
|
vipConfig: "vip-config",
|
||||||
weeklyStar: "weekly-star",
|
weeklyStar: "weekly-star",
|
||||||
|
agencyOpening: "agency-opening",
|
||||||
geo: "geo",
|
geo: "geo",
|
||||||
hostOrg: "host-org",
|
hostOrg: "host-org",
|
||||||
hostOrgCountries: "host-org-countries",
|
hostOrgCountries: "host-org-countries",
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { authRoutes } from "@/features/auth/routes.js";
|
import { authRoutes } from "@/features/auth/routes.js";
|
||||||
|
import { agencyOpeningRoutes } from "@/features/agency-opening/routes.js";
|
||||||
import { achievementRoutes } from "@/features/achievements/routes.js";
|
import { achievementRoutes } from "@/features/achievements/routes.js";
|
||||||
import { appConfigRoutes } from "@/features/app-config/routes.js";
|
import { appConfigRoutes } from "@/features/app-config/routes.js";
|
||||||
import { appUserRoutes } from "@/features/app-users/routes.js";
|
import { appUserRoutes } from "@/features/app-users/routes.js";
|
||||||
@ -52,6 +53,7 @@ export const adminRoutes: AdminRoute[] = [
|
|||||||
...roomRocketRoutes,
|
...roomRocketRoutes,
|
||||||
...roomTurnoverRewardRoutes,
|
...roomTurnoverRewardRoutes,
|
||||||
...weeklyStarRoutes,
|
...weeklyStarRoutes,
|
||||||
|
...agencyOpeningRoutes,
|
||||||
...userLeaderboardRoutes,
|
...userLeaderboardRoutes,
|
||||||
...redPacketRoutes,
|
...redPacketRoutes,
|
||||||
...cpConfigRoutes,
|
...cpConfigRoutes,
|
||||||
|
|||||||
153
src/features/agency-opening/api.ts
Normal file
153
src/features/agency-opening/api.ts
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
import { apiRequest, type QueryParams } from "@/shared/api/request";
|
||||||
|
|
||||||
|
type Raw = Record<string, unknown>;
|
||||||
|
|
||||||
|
export interface AgencyOpeningRewardDto {
|
||||||
|
rankNo: number;
|
||||||
|
tierNo: number;
|
||||||
|
thresholdCoinSpent: number;
|
||||||
|
rewardCoinAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgencyOpeningCycleDto {
|
||||||
|
cycleId: string;
|
||||||
|
activityCode: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
minHostCount: number;
|
||||||
|
maxAgencyAgeDays: number;
|
||||||
|
rewards: AgencyOpeningRewardDto[];
|
||||||
|
settledAtMs: number;
|
||||||
|
createdAtMs: number;
|
||||||
|
updatedAtMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgencyOpeningCyclePayload {
|
||||||
|
cycle_id?: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
start_ms: number;
|
||||||
|
end_ms: number;
|
||||||
|
min_host_count: number;
|
||||||
|
max_agency_age_days: number;
|
||||||
|
rewards: Array<{ rank_no: number; tier_no?: number; threshold_coin_spent: number; reward_coin_amount: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgencyOpeningApplicationDto {
|
||||||
|
applicationId: string;
|
||||||
|
cycleId: string;
|
||||||
|
agencyId: string;
|
||||||
|
agencyOwnerUserId: string;
|
||||||
|
agencyName: string;
|
||||||
|
hostCount: number;
|
||||||
|
agencyCreatedAtMs: number;
|
||||||
|
status: string;
|
||||||
|
scoreCoinAmount: number;
|
||||||
|
rewardRankNo: number;
|
||||||
|
rewardThresholdCoinSpent: number;
|
||||||
|
rewardCoinAmount: number;
|
||||||
|
walletCommandId: string;
|
||||||
|
walletTransactionId: string;
|
||||||
|
failureReason: string;
|
||||||
|
appliedAtMs: number;
|
||||||
|
grantedAtMs: number;
|
||||||
|
updatedAtMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listAgencyOpeningCycles(query: QueryParams = {}) {
|
||||||
|
return apiRequest<{ items?: Raw[]; total?: number; page?: number; page_size?: number }>(
|
||||||
|
"/v1/admin/activity/agency-opening/cycles",
|
||||||
|
{ method: "GET", query },
|
||||||
|
).then((page) => ({ ...page, items: (page.items || []).map(normalizeCycle) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAgencyOpeningCycle(payload: AgencyOpeningCyclePayload) {
|
||||||
|
return apiRequest<Raw, AgencyOpeningCyclePayload>("/v1/admin/activity/agency-opening/cycles", {
|
||||||
|
body: payload,
|
||||||
|
method: "POST",
|
||||||
|
}).then(normalizeCycle);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateAgencyOpeningCycle(cycleId: string, payload: AgencyOpeningCyclePayload) {
|
||||||
|
return apiRequest<Raw, AgencyOpeningCyclePayload>(
|
||||||
|
`/v1/admin/activity/agency-opening/cycles/${encodeURIComponent(cycleId)}`,
|
||||||
|
{
|
||||||
|
body: payload,
|
||||||
|
method: "PUT",
|
||||||
|
},
|
||||||
|
).then(normalizeCycle);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAgencyOpeningCycleStatus(cycleId: string, status: string) {
|
||||||
|
return apiRequest<Raw, { status: string }>(
|
||||||
|
`/v1/admin/activity/agency-opening/cycles/${encodeURIComponent(cycleId)}/status`,
|
||||||
|
{ body: { status }, method: "PATCH" },
|
||||||
|
).then(normalizeCycle);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listAgencyOpeningApplications(query: QueryParams = {}) {
|
||||||
|
return apiRequest<{ items?: Raw[]; total?: number; page?: number; page_size?: number }>(
|
||||||
|
"/v1/admin/activity/agency-opening/applications",
|
||||||
|
{ method: "GET", query },
|
||||||
|
).then((page) => ({ ...page, items: (page.items || []).map(normalizeApplication) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCycle(item: Raw): AgencyOpeningCycleDto {
|
||||||
|
return {
|
||||||
|
cycleId: stringValue(item.cycleId ?? item.cycle_id),
|
||||||
|
activityCode: stringValue(item.activityCode ?? item.activity_code),
|
||||||
|
title: stringValue(item.title),
|
||||||
|
status: stringValue(item.status),
|
||||||
|
startMs: numberValue(item.startMs ?? item.start_ms),
|
||||||
|
endMs: numberValue(item.endMs ?? item.end_ms),
|
||||||
|
minHostCount: numberValue(item.minHostCount ?? item.min_host_count),
|
||||||
|
maxAgencyAgeDays: numberValue(item.maxAgencyAgeDays ?? item.max_agency_age_days),
|
||||||
|
rewards: Array.isArray(item.rewards) ? item.rewards.map((reward) => normalizeReward(reward as Raw)) : [],
|
||||||
|
settledAtMs: numberValue(item.settledAtMs ?? item.settled_at_ms),
|
||||||
|
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
||||||
|
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeReward(item: Raw): AgencyOpeningRewardDto {
|
||||||
|
return {
|
||||||
|
rankNo: numberValue(item.rankNo ?? item.rank_no),
|
||||||
|
tierNo: numberValue(item.tierNo ?? item.tier_no ?? item.rankNo ?? item.rank_no),
|
||||||
|
thresholdCoinSpent: numberValue(item.thresholdCoinSpent ?? item.threshold_coin_spent),
|
||||||
|
rewardCoinAmount: numberValue(item.rewardCoinAmount ?? item.reward_coin_amount),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeApplication(item: Raw): AgencyOpeningApplicationDto {
|
||||||
|
return {
|
||||||
|
applicationId: stringValue(item.applicationId ?? item.application_id),
|
||||||
|
cycleId: stringValue(item.cycleId ?? item.cycle_id),
|
||||||
|
agencyId: stringValue(item.agencyId ?? item.agency_id),
|
||||||
|
agencyOwnerUserId: stringValue(item.agencyOwnerUserId ?? item.agency_owner_user_id),
|
||||||
|
agencyName: stringValue(item.agencyName ?? item.agency_name),
|
||||||
|
hostCount: numberValue(item.hostCount ?? item.host_count),
|
||||||
|
agencyCreatedAtMs: numberValue(item.agencyCreatedAtMs ?? item.agency_created_at_ms),
|
||||||
|
status: stringValue(item.status),
|
||||||
|
scoreCoinAmount: numberValue(item.scoreCoinAmount ?? item.score_coin_amount),
|
||||||
|
rewardRankNo: numberValue(item.rewardRankNo ?? item.reward_rank_no),
|
||||||
|
rewardThresholdCoinSpent: numberValue(item.rewardThresholdCoinSpent ?? item.reward_threshold_coin_spent),
|
||||||
|
rewardCoinAmount: numberValue(item.rewardCoinAmount ?? item.reward_coin_amount),
|
||||||
|
walletCommandId: stringValue(item.walletCommandId ?? item.wallet_command_id),
|
||||||
|
walletTransactionId: stringValue(item.walletTransactionId ?? item.wallet_transaction_id),
|
||||||
|
failureReason: stringValue(item.failureReason ?? item.failure_reason),
|
||||||
|
appliedAtMs: numberValue(item.appliedAtMs ?? item.applied_at_ms),
|
||||||
|
grantedAtMs: numberValue(item.grantedAtMs ?? item.granted_at_ms),
|
||||||
|
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberValue(value: unknown) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value: unknown) {
|
||||||
|
return value === undefined || value === null ? "" : String(value);
|
||||||
|
}
|
||||||
591
src/features/agency-opening/pages/AgencyOpeningPage.jsx
Normal file
591
src/features/agency-opening/pages/AgencyOpeningPage.jsx
Normal file
@ -0,0 +1,591 @@
|
|||||||
|
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||||
|
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||||
|
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||||
|
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||||
|
import VisibilityOutlined from "@mui/icons-material/VisibilityOutlined";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Chip from "@mui/material/Chip";
|
||||||
|
import Drawer from "@mui/material/Drawer";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import Snackbar from "@mui/material/Snackbar";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
createAgencyOpeningCycle,
|
||||||
|
listAgencyOpeningApplications,
|
||||||
|
listAgencyOpeningCycles,
|
||||||
|
setAgencyOpeningCycleStatus,
|
||||||
|
updateAgencyOpeningCycle,
|
||||||
|
} from "@/features/agency-opening/api";
|
||||||
|
import { useAgencyOpeningAbilities } from "@/features/agency-opening/permissions.js";
|
||||||
|
import { Button } from "@/shared/ui/Button.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 { TimeText } from "@/shared/ui/TimeText.jsx";
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
["draft", "草稿"],
|
||||||
|
["active", "启用"],
|
||||||
|
["disabled", "停用"],
|
||||||
|
["settling", "结算中"],
|
||||||
|
["settled", "已结算"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const emptyForm = {
|
||||||
|
cycleId: "",
|
||||||
|
title: "代理开业活动",
|
||||||
|
status: "draft",
|
||||||
|
startMs: "",
|
||||||
|
endMs: "",
|
||||||
|
minHostCount: "10",
|
||||||
|
maxAgencyAgeDays: "30",
|
||||||
|
rewards: [
|
||||||
|
{ rankNo: 1, thresholdCoinSpent: "500000", rewardCoinAmount: "50000" },
|
||||||
|
{ rankNo: 2, thresholdCoinSpent: "1000000", rewardCoinAmount: "100000" },
|
||||||
|
{ rankNo: 3, thresholdCoinSpent: "3000000", rewardCoinAmount: "300000" },
|
||||||
|
{ rankNo: 4, thresholdCoinSpent: "5000000", rewardCoinAmount: "500000" },
|
||||||
|
{ rankNo: 5, thresholdCoinSpent: "10000000", rewardCoinAmount: "500000" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AgencyOpeningPage() {
|
||||||
|
const abilities = useAgencyOpeningAbilities();
|
||||||
|
const [cycles, setCycles] = useState([]);
|
||||||
|
const [applications, setApplications] = useState([]);
|
||||||
|
const [selectedCycleId, setSelectedCycleId] = useState("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [applicationsLoading, setApplicationsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [form, setForm] = useState(emptyForm);
|
||||||
|
const [toast, setToast] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reloadCycles();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reloadApplications(selectedCycleId);
|
||||||
|
}, [selectedCycleId]);
|
||||||
|
|
||||||
|
async function reloadCycles() {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const result = await listAgencyOpeningCycles({ page_size: 100 });
|
||||||
|
const items = result.items || [];
|
||||||
|
setCycles(items);
|
||||||
|
setSelectedCycleId((current) => current || items[0]?.cycleId || "");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "加载失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadApplications(cycleId) {
|
||||||
|
setApplicationsLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await listAgencyOpeningApplications({
|
||||||
|
cycle_id: cycleId || undefined,
|
||||||
|
page_size: 100,
|
||||||
|
});
|
||||||
|
setApplications(result.items || []);
|
||||||
|
} catch {
|
||||||
|
setApplications([]);
|
||||||
|
} finally {
|
||||||
|
setApplicationsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setForm(emptyForm);
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(cycle) {
|
||||||
|
setForm({
|
||||||
|
cycleId: cycle.cycleId,
|
||||||
|
title: cycle.title,
|
||||||
|
status: cycle.status || "draft",
|
||||||
|
startMs: millisToInput(cycle.startMs),
|
||||||
|
endMs: millisToInput(cycle.endMs),
|
||||||
|
minHostCount: String(cycle.minHostCount || 10),
|
||||||
|
maxAgencyAgeDays: String(cycle.maxAgencyAgeDays || 0),
|
||||||
|
rewards: (cycle.rewards?.length ? cycle.rewards : emptyForm.rewards).map((reward) => ({
|
||||||
|
rankNo: reward.rankNo,
|
||||||
|
thresholdCoinSpent: String(reward.thresholdCoinSpent || ""),
|
||||||
|
rewardCoinAmount: String(reward.rewardCoinAmount || ""),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitForm(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!abilities.canCreate && !form.cycleId) return;
|
||||||
|
if (!abilities.canUpdate && form.cycleId) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
cycle_id: form.cycleId || undefined,
|
||||||
|
title: form.title.trim(),
|
||||||
|
status: form.status,
|
||||||
|
start_ms: inputToMillis(form.startMs),
|
||||||
|
end_ms: inputToMillis(form.endMs),
|
||||||
|
min_host_count: Number(form.minHostCount || 0),
|
||||||
|
max_agency_age_days: Number(form.maxAgencyAgeDays || 0),
|
||||||
|
rewards: normalizeRewardPayload(form.rewards),
|
||||||
|
};
|
||||||
|
if (form.cycleId) {
|
||||||
|
await updateAgencyOpeningCycle(form.cycleId, payload);
|
||||||
|
} else {
|
||||||
|
await createAgencyOpeningCycle(payload);
|
||||||
|
}
|
||||||
|
setToast("已保存代理开业活动周期");
|
||||||
|
setDrawerOpen(false);
|
||||||
|
await reloadCycles();
|
||||||
|
} catch (err) {
|
||||||
|
setToast(err instanceof Error ? err.message : "保存失败");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleStatus(cycle) {
|
||||||
|
if (!abilities.canUpdate) return;
|
||||||
|
const nextStatus = cycle.status === "active" ? "disabled" : "active";
|
||||||
|
try {
|
||||||
|
await setAgencyOpeningCycleStatus(cycle.cycleId, nextStatus);
|
||||||
|
setToast(nextStatus === "active" ? "已启用" : "已停用");
|
||||||
|
await reloadCycles();
|
||||||
|
} catch (err) {
|
||||||
|
setToast(err instanceof Error ? err.message : "状态更新失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateReward(index, field, value) {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
rewards: current.rewards.map((reward, itemIndex) =>
|
||||||
|
itemIndex === index ? { ...reward, [field]: value } : reward,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRewardTier() {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
rewards: [
|
||||||
|
...current.rewards,
|
||||||
|
{
|
||||||
|
rankNo: current.rewards.length + 1,
|
||||||
|
thresholdCoinSpent: "",
|
||||||
|
rewardCoinAmount: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRewardTier(index) {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
rewards: current.rewards
|
||||||
|
.filter((_, itemIndex) => itemIndex !== index)
|
||||||
|
.map((reward, itemIndex) => ({ ...reward, rankNo: itemIndex + 1 })),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminListPage>
|
||||||
|
<AdminListToolbar
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button startIcon={<RefreshOutlined fontSize="small" />} onClick={reloadCycles}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
{abilities.canCreate ? (
|
||||||
|
<Button startIcon={<AddOutlined fontSize="small" />} variant="primary" onClick={openCreate}>
|
||||||
|
新增周期
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DataState error={error} loading={loading} onRetry={reloadCycles}>
|
||||||
|
<AdminListBody>
|
||||||
|
<DataTable
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
key: "cycle",
|
||||||
|
label: "周期",
|
||||||
|
width: "minmax(260px, 1fr)",
|
||||||
|
render: (cycle) => (
|
||||||
|
<div className="weekly-star-list-stack">
|
||||||
|
<span className="weekly-star-list-title">{cycle.title || "-"}</span>
|
||||||
|
<span className="weekly-star-list-meta">{cycle.cycleId || "-"}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "time",
|
||||||
|
label: "时间",
|
||||||
|
width: "minmax(240px, 0.9fr)",
|
||||||
|
render: (cycle) => (
|
||||||
|
<div className="weekly-star-list-stack">
|
||||||
|
<TimeText value={cycle.startMs} />
|
||||||
|
<span className="weekly-star-list-meta">
|
||||||
|
至 <TimeText value={cycle.endMs} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "policy",
|
||||||
|
label: "规则",
|
||||||
|
width: "minmax(200px, 0.8fr)",
|
||||||
|
render: (cycle) => (
|
||||||
|
<div className="weekly-star-list-stack">
|
||||||
|
<span>主播数 > {cycle.minHostCount}</span>
|
||||||
|
<span className="weekly-star-list-meta">
|
||||||
|
新代理 {cycle.maxAgencyAgeDays || 0} 天内
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "rewards",
|
||||||
|
label: "金币奖励",
|
||||||
|
width: "minmax(260px, 1fr)",
|
||||||
|
render: (cycle) => (
|
||||||
|
<div className="weekly-star-list-chip-list">
|
||||||
|
{(cycle.rewards || []).map((reward) => (
|
||||||
|
<Chip
|
||||||
|
className="weekly-star-list-chip"
|
||||||
|
key={reward.rankNo}
|
||||||
|
label={`流水≥${formatNumber(reward.thresholdCoinSpent)}: ${formatNumber(reward.rewardCoinAmount)}`}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
label: "状态",
|
||||||
|
width: "112px",
|
||||||
|
render: (cycle) => (
|
||||||
|
<Chip
|
||||||
|
color={statusColor(cycle.status)}
|
||||||
|
label={statusLabel(cycle.status)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
label: "操作",
|
||||||
|
width: "132px",
|
||||||
|
fixed: "right",
|
||||||
|
resizable: false,
|
||||||
|
render: (cycle) => (
|
||||||
|
<AdminRowActions>
|
||||||
|
<AdminActionIconButton
|
||||||
|
icon={<VisibilityOutlined fontSize="small" />}
|
||||||
|
label="申请记录"
|
||||||
|
onClick={() => setSelectedCycleId(cycle.cycleId)}
|
||||||
|
/>
|
||||||
|
{abilities.canUpdate ? (
|
||||||
|
<AdminActionIconButton
|
||||||
|
icon={<EditOutlined fontSize="small" />}
|
||||||
|
label="编辑"
|
||||||
|
onClick={() => openEdit(cycle)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{abilities.canUpdate ? (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant={cycle.status === "active" ? "secondary" : "primary"}
|
||||||
|
onClick={() => toggleStatus(cycle)}
|
||||||
|
>
|
||||||
|
{cycle.status === "active" ? "停用" : "启用"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</AdminRowActions>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
emptyLabel="暂无代理开业活动周期"
|
||||||
|
items={cycles}
|
||||||
|
minWidth="1220px"
|
||||||
|
rowKey={(cycle) => cycle.cycleId}
|
||||||
|
/>
|
||||||
|
</AdminListBody>
|
||||||
|
</DataState>
|
||||||
|
|
||||||
|
<AdminListBody>
|
||||||
|
<Typography component="h2" variant="h6">
|
||||||
|
申请记录
|
||||||
|
</Typography>
|
||||||
|
<DataTable
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
key: "agency",
|
||||||
|
label: "Agency",
|
||||||
|
width: "minmax(220px, 1fr)",
|
||||||
|
render: (item) => `${item.agencyName || "-"} / ${item.agencyId || "-"}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "owner",
|
||||||
|
label: "Owner",
|
||||||
|
width: "160px",
|
||||||
|
render: (item) => item.agencyOwnerUserId || "-",
|
||||||
|
},
|
||||||
|
{ key: "hosts", label: "主播数", width: "100px", render: (item) => item.hostCount },
|
||||||
|
{
|
||||||
|
key: "score",
|
||||||
|
label: "开业流水",
|
||||||
|
width: "120px",
|
||||||
|
render: (item) => formatNumber(item.scoreCoinAmount),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "reward",
|
||||||
|
label: "奖励",
|
||||||
|
width: "180px",
|
||||||
|
render: (item) =>
|
||||||
|
item.rewardCoinAmount
|
||||||
|
? `流水≥${formatNumber(item.rewardThresholdCoinSpent)} / ${formatNumber(item.rewardCoinAmount)}`
|
||||||
|
: "-",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
label: "状态",
|
||||||
|
width: "112px",
|
||||||
|
render: (item) => <Chip label={applicationStatusLabel(item.status)} size="small" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "applied",
|
||||||
|
label: "申请时间",
|
||||||
|
width: "180px",
|
||||||
|
render: (item) => <TimeText value={item.appliedAtMs} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "granted",
|
||||||
|
label: "发奖时间",
|
||||||
|
width: "180px",
|
||||||
|
render: (item) => (item.grantedAtMs ? <TimeText value={item.grantedAtMs} /> : "-"),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
emptyLabel={applicationsLoading ? "加载中..." : "暂无申请记录"}
|
||||||
|
items={applications}
|
||||||
|
minWidth="1210px"
|
||||||
|
rowKey={(item) => item.applicationId}
|
||||||
|
/>
|
||||||
|
</AdminListBody>
|
||||||
|
|
||||||
|
<Drawer anchor="right" open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
||||||
|
<Box className="weekly-star-config-drawer" component="form" onSubmit={submitForm}>
|
||||||
|
<div className="weekly-star-config-drawer__header">
|
||||||
|
<h2 className="weekly-star-config-drawer__title">
|
||||||
|
{form.cycleId ? "编辑代理开业活动" : "新增代理开业活动"}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="weekly-star-config-drawer__body">
|
||||||
|
<section className="weekly-star-config-section">
|
||||||
|
<div className="weekly-star-config-grid weekly-star-config-grid--basic">
|
||||||
|
<TextField
|
||||||
|
label="标题"
|
||||||
|
required
|
||||||
|
value={form.title}
|
||||||
|
onChange={(event) => setForm({ ...form, title: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="状态"
|
||||||
|
select
|
||||||
|
value={form.status}
|
||||||
|
onChange={(event) => setForm({ ...form, status: event.target.value })}
|
||||||
|
>
|
||||||
|
{statusOptions.slice(0, 3).map(([value, label]) => (
|
||||||
|
<MenuItem key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
label="开始时间"
|
||||||
|
required
|
||||||
|
type="datetime-local"
|
||||||
|
value={form.startMs}
|
||||||
|
onChange={(event) => setForm({ ...form, startMs: event.target.value })}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="结束时间"
|
||||||
|
required
|
||||||
|
type="datetime-local"
|
||||||
|
value={form.endMs}
|
||||||
|
onChange={(event) => setForm({ ...form, endMs: event.target.value })}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="主播数必须大于"
|
||||||
|
required
|
||||||
|
type="number"
|
||||||
|
value={form.minHostCount}
|
||||||
|
onChange={(event) => setForm({ ...form, minHostCount: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="代理创建天数上限"
|
||||||
|
type="number"
|
||||||
|
value={form.maxAgencyAgeDays}
|
||||||
|
onChange={(event) => setForm({ ...form, maxAgencyAgeDays: event.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="weekly-star-config-section">
|
||||||
|
<Typography component="h3" variant="subtitle1">
|
||||||
|
开业流水档位奖励
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<Button
|
||||||
|
startIcon={<AddOutlined fontSize="small" />}
|
||||||
|
type="button"
|
||||||
|
onClick={addRewardTier}
|
||||||
|
>
|
||||||
|
添加档位
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
<div className="weekly-star-config-card-grid">
|
||||||
|
{form.rewards.map((reward, index) => (
|
||||||
|
<div className="weekly-star-config-card" key={reward.rankNo}>
|
||||||
|
<div className="weekly-star-config-card__header">
|
||||||
|
<span
|
||||||
|
className={`weekly-star-rank-badge weekly-star-rank-badge--${index + 1}`}
|
||||||
|
>
|
||||||
|
档位 {index + 1}
|
||||||
|
</span>
|
||||||
|
{form.rewards.length > 1 ? (
|
||||||
|
<Button
|
||||||
|
startIcon={<DeleteOutlineOutlined fontSize="small" />}
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeRewardTier(index)}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="开业流水"
|
||||||
|
required
|
||||||
|
type="number"
|
||||||
|
value={reward.thresholdCoinSpent}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateReward(index, "thresholdCoinSpent", event.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="奖励金币"
|
||||||
|
required
|
||||||
|
type="number"
|
||||||
|
value={reward.rewardCoinAmount}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateReward(index, "rewardCoinAmount", event.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<div className="weekly-star-config-drawer__actions">
|
||||||
|
<Button onClick={() => setDrawerOpen(false)}>取消</Button>
|
||||||
|
<Button disabled={saving} type="submit" variant="primary">
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
<Snackbar autoHideDuration={2600} message={toast} open={Boolean(toast)} onClose={() => setToast("")} />
|
||||||
|
</AdminListPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status) {
|
||||||
|
return statusOptions.find(([value]) => value === status)?.[1] || status || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusColor(status) {
|
||||||
|
if (status === "active") return "success";
|
||||||
|
if (status === "settled") return "info";
|
||||||
|
if (status === "settling") return "warning";
|
||||||
|
if (status === "disabled") return "default";
|
||||||
|
return "secondary";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applicationStatusLabel(status) {
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
applied: "已申请",
|
||||||
|
pending: "待发奖",
|
||||||
|
granted: "已发奖",
|
||||||
|
failed: "发奖失败",
|
||||||
|
}[status] ||
|
||||||
|
status ||
|
||||||
|
"-"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function millisToInput(ms) {
|
||||||
|
if (!ms) return "";
|
||||||
|
const local = new Date(ms - new Date(ms).getTimezoneOffset() * 60000);
|
||||||
|
return local.toISOString().slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inputToMillis(value) {
|
||||||
|
return value ? new Date(value).getTime() : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRewardPayload(rewards) {
|
||||||
|
const payload = (rewards || []).map((reward, index) => {
|
||||||
|
const thresholdCoinSpent = Number(reward.thresholdCoinSpent || 0);
|
||||||
|
const rewardCoinAmount = Number(reward.rewardCoinAmount || 0);
|
||||||
|
if (!Number.isInteger(thresholdCoinSpent) || thresholdCoinSpent <= 0) {
|
||||||
|
throw new Error("开业流水必须是大于 0 的整数");
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(rewardCoinAmount) || rewardCoinAmount < 0) {
|
||||||
|
throw new Error("奖励金币不能小于 0");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
rank_no: index + 1,
|
||||||
|
tier_no: index + 1,
|
||||||
|
threshold_coin_spent: thresholdCoinSpent,
|
||||||
|
reward_coin_amount: rewardCoinAmount,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
for (let index = 1; index < payload.length; index += 1) {
|
||||||
|
if (payload[index].threshold_coin_spent <= payload[index - 1].threshold_coin_spent) {
|
||||||
|
throw new Error("开业流水档位必须递增");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!payload.length) {
|
||||||
|
throw new Error("至少需要配置一个开业流水档位");
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value) {
|
||||||
|
return Number(value || 0).toLocaleString();
|
||||||
|
}
|
||||||
11
src/features/agency-opening/permissions.js
Normal file
11
src/features/agency-opening/permissions.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { PERMISSIONS } from "@/app/permissions";
|
||||||
|
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||||
|
|
||||||
|
export function useAgencyOpeningAbilities() {
|
||||||
|
const { can } = useAuth();
|
||||||
|
return {
|
||||||
|
canCreate: can(PERMISSIONS.agencyOpeningCreate),
|
||||||
|
canUpdate: can(PERMISSIONS.agencyOpeningUpdate),
|
||||||
|
canView: can(PERMISSIONS.agencyOpeningView),
|
||||||
|
};
|
||||||
|
}
|
||||||
12
src/features/agency-opening/routes.js
Normal file
12
src/features/agency-opening/routes.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
|
||||||
|
|
||||||
|
export const agencyOpeningRoutes = [
|
||||||
|
{
|
||||||
|
label: "代理开业活动",
|
||||||
|
loader: () => import("./pages/AgencyOpeningPage.jsx").then((module) => module.AgencyOpeningPage),
|
||||||
|
menuCode: MENU_CODES.agencyOpening,
|
||||||
|
pageKey: "agency-opening",
|
||||||
|
path: "/activities/agency-opening",
|
||||||
|
permission: PERMISSIONS.agencyOpeningView,
|
||||||
|
},
|
||||||
|
];
|
||||||
@ -25,9 +25,25 @@ export interface CPRelationConfigDto {
|
|||||||
export interface CPConfigDto {
|
export interface CPConfigDto {
|
||||||
appCode: string;
|
appCode: string;
|
||||||
relations: CPRelationConfigDto[];
|
relations: CPRelationConfigDto[];
|
||||||
|
weeklyRank: CPWeeklyRankDto;
|
||||||
serverTimeMs?: number;
|
serverTimeMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CPWeeklyRankRewardDto {
|
||||||
|
rankNo: number;
|
||||||
|
resourceGroupId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CPWeeklyRankDto {
|
||||||
|
enabled: boolean;
|
||||||
|
relationType: CPRelationType;
|
||||||
|
topCount: number;
|
||||||
|
rewards: CPWeeklyRankRewardDto[];
|
||||||
|
updatedByAdminId?: number;
|
||||||
|
createdAtMs?: number;
|
||||||
|
updatedAtMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CPConfigPayload {
|
export interface CPConfigPayload {
|
||||||
relations: Array<{
|
relations: Array<{
|
||||||
relationType: CPRelationType;
|
relationType: CPRelationType;
|
||||||
@ -43,6 +59,11 @@ export interface CPConfigPayload {
|
|||||||
status: string;
|
status: string;
|
||||||
}>;
|
}>;
|
||||||
}>;
|
}>;
|
||||||
|
weeklyRank: {
|
||||||
|
enabled: boolean;
|
||||||
|
topCount: number;
|
||||||
|
rewards: CPWeeklyRankRewardDto[];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const path = "/v1/admin/activity/cp/config";
|
const path = "/v1/admin/activity/cp/config";
|
||||||
@ -58,12 +79,14 @@ export function updateCPConfig(payload: CPConfigPayload): Promise<CPConfigDto> {
|
|||||||
type RawConfig = CPConfigDto & Record<string, unknown>;
|
type RawConfig = CPConfigDto & Record<string, unknown>;
|
||||||
type RawRelation = CPRelationConfigDto & Record<string, unknown>;
|
type RawRelation = CPRelationConfigDto & Record<string, unknown>;
|
||||||
type RawLevel = CPLevelDto & Record<string, unknown>;
|
type RawLevel = CPLevelDto & Record<string, unknown>;
|
||||||
|
type RawWeeklyRank = CPWeeklyRankDto & Record<string, unknown>;
|
||||||
|
|
||||||
function normalizeConfig(raw: RawConfig): CPConfigDto {
|
function normalizeConfig(raw: RawConfig): CPConfigDto {
|
||||||
const item = asRecord(raw) as RawConfig;
|
const item = asRecord(raw) as RawConfig;
|
||||||
return {
|
return {
|
||||||
appCode: stringValue(item.appCode ?? item.app_code),
|
appCode: stringValue(item.appCode ?? item.app_code),
|
||||||
relations: arrayValue(item.relations).map(normalizeRelation),
|
relations: arrayValue(item.relations).map(normalizeRelation),
|
||||||
|
weeklyRank: normalizeWeeklyRank(item.weeklyRank ?? item.weekly_rank),
|
||||||
serverTimeMs: numberValue(item.serverTimeMs ?? item.server_time_ms),
|
serverTimeMs: numberValue(item.serverTimeMs ?? item.server_time_ms),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -83,6 +106,25 @@ function normalizeRelation(raw: unknown): CPRelationConfigDto {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeWeeklyRank(raw: unknown): CPWeeklyRankDto {
|
||||||
|
const item = asRecord(raw) as RawWeeklyRank;
|
||||||
|
return {
|
||||||
|
enabled: item.enabled !== false,
|
||||||
|
relationType: normalizeRelationType(item.relationType ?? item.relation_type),
|
||||||
|
topCount: numberValue(item.topCount ?? item.top_count) || 3,
|
||||||
|
rewards: arrayValue(item.rewards).map((reward) => {
|
||||||
|
const rawReward = asRecord(reward);
|
||||||
|
return {
|
||||||
|
rankNo: numberValue(rawReward.rankNo ?? rawReward.rank_no),
|
||||||
|
resourceGroupId: numberValue(rawReward.resourceGroupId ?? rawReward.resource_group_id),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
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 normalizeLevel(raw: unknown): CPLevelDto {
|
function normalizeLevel(raw: unknown): CPLevelDto {
|
||||||
const item = asRecord(raw) as RawLevel;
|
const item = asRecord(raw) as RawLevel;
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -10,6 +10,7 @@ export const cpRelationLabels = {
|
|||||||
brother: "兄弟",
|
brother: "兄弟",
|
||||||
sister: "姐妹",
|
sister: "姐妹",
|
||||||
};
|
};
|
||||||
|
export const cpWeeklyRankRanks = [1, 2, 3];
|
||||||
|
|
||||||
const defaultExpireHours = 24;
|
const defaultExpireHours = 24;
|
||||||
const defaultThresholds = [0, 10000, 30000, 60000, 100000];
|
const defaultThresholds = [0, 10000, 30000, 60000, 100000];
|
||||||
@ -17,8 +18,11 @@ const defaultThresholds = [0, 10000, 30000, 60000, 100000];
|
|||||||
export function useCPConfigPage() {
|
export function useCPConfigPage() {
|
||||||
const abilities = useCPConfigAbilities();
|
const abilities = useCPConfigAbilities();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [config, setConfig] = useState({ relations: defaultRelations() });
|
const [config, setConfig] = useState({ relations: defaultRelations(), weeklyRank: defaultWeeklyRank() });
|
||||||
const [form, setForm] = useState({ relations: defaultRelations().map(relationToForm) });
|
const [form, setForm] = useState({
|
||||||
|
relations: defaultRelations().map(relationToForm),
|
||||||
|
weeklyRank: weeklyRankToForm(defaultWeeklyRank()),
|
||||||
|
});
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [resourceGroups, setResourceGroups] = useState([]);
|
const [resourceGroups, setResourceGroups] = useState([]);
|
||||||
@ -39,8 +43,9 @@ export function useCPConfigPage() {
|
|||||||
if (configResult.status === "fulfilled") {
|
if (configResult.status === "fulfilled") {
|
||||||
const remoteConfig = configResult.value;
|
const remoteConfig = configResult.value;
|
||||||
const relations = completeRelations(remoteConfig.relations || []);
|
const relations = completeRelations(remoteConfig.relations || []);
|
||||||
setConfig({ ...remoteConfig, relations });
|
const weeklyRank = completeWeeklyRank(remoteConfig.weeklyRank);
|
||||||
setForm({ relations: relations.map(relationToForm) });
|
setConfig({ ...remoteConfig, relations, weeklyRank });
|
||||||
|
setForm({ relations: relations.map(relationToForm), weeklyRank: weeklyRankToForm(weeklyRank) });
|
||||||
} else {
|
} else {
|
||||||
showToast(configResult.reason?.message || "加载 CP 配置失败", "error");
|
showToast(configResult.reason?.message || "加载 CP 配置失败", "error");
|
||||||
}
|
}
|
||||||
@ -61,7 +66,10 @@ export function useCPConfigPage() {
|
|||||||
}, [reload]);
|
}, [reload]);
|
||||||
|
|
||||||
const openDrawer = () => {
|
const openDrawer = () => {
|
||||||
setForm({ relations: completeRelations(config.relations || []).map(relationToForm) });
|
setForm({
|
||||||
|
relations: completeRelations(config.relations || []).map(relationToForm),
|
||||||
|
weeklyRank: weeklyRankToForm(completeWeeklyRank(config.weeklyRank)),
|
||||||
|
});
|
||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -69,7 +77,10 @@ export function useCPConfigPage() {
|
|||||||
if (saving) {
|
if (saving) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setForm({ relations: completeRelations(config.relations || []).map(relationToForm) });
|
setForm({
|
||||||
|
relations: completeRelations(config.relations || []).map(relationToForm),
|
||||||
|
weeklyRank: weeklyRankToForm(completeWeeklyRank(config.weeklyRank)),
|
||||||
|
});
|
||||||
setDrawerOpen(false);
|
setDrawerOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -98,6 +109,25 @@ export function useCPConfigPage() {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateWeeklyRank = (patch) => {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
weeklyRank: { ...(current.weeklyRank || weeklyRankToForm(defaultWeeklyRank())), ...patch },
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateWeeklyRankReward = (rankNo, patch) => {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
weeklyRank: {
|
||||||
|
...(current.weeklyRank || weeklyRankToForm(defaultWeeklyRank())),
|
||||||
|
rewards: completeWeeklyRankRewardForms(current.weeklyRank?.rewards || []).map((reward) =>
|
||||||
|
Number(reward.rankNo) === Number(rankNo) ? { ...reward, ...patch } : reward,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const submit = async (event) => {
|
const submit = async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!abilities.canUpdate) {
|
if (!abilities.canUpdate) {
|
||||||
@ -114,8 +144,9 @@ export function useCPConfigPage() {
|
|||||||
try {
|
try {
|
||||||
const saved = await updateCPConfig(payload);
|
const saved = await updateCPConfig(payload);
|
||||||
const relations = completeRelations(saved.relations || []);
|
const relations = completeRelations(saved.relations || []);
|
||||||
setConfig({ ...saved, relations });
|
const weeklyRank = completeWeeklyRank(saved.weeklyRank);
|
||||||
setForm({ relations: relations.map(relationToForm) });
|
setConfig({ ...saved, relations, weeklyRank });
|
||||||
|
setForm({ relations: relations.map(relationToForm), weeklyRank: weeklyRankToForm(weeklyRank) });
|
||||||
setDrawerOpen(false);
|
setDrawerOpen(false);
|
||||||
showToast("CP 配置已保存", "success");
|
showToast("CP 配置已保存", "success");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -140,6 +171,8 @@ export function useCPConfigPage() {
|
|||||||
submit,
|
submit,
|
||||||
updateLevel,
|
updateLevel,
|
||||||
updateRelation,
|
updateRelation,
|
||||||
|
updateWeeklyRank,
|
||||||
|
updateWeeklyRankReward,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -167,6 +200,16 @@ function defaultLevels() {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function defaultWeeklyRank() {
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
relationType: "cp",
|
||||||
|
topCount: 3,
|
||||||
|
rewards: cpWeeklyRankRanks.map((rankNo) => ({ rankNo, resourceGroupId: 0 })),
|
||||||
|
updatedAtMs: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function completeRelations(relations) {
|
function completeRelations(relations) {
|
||||||
const byType = new Map((relations || []).map((relation) => [relation.relationType, relation]));
|
const byType = new Map((relations || []).map((relation) => [relation.relationType, relation]));
|
||||||
return defaultRelations().map((fallback) => {
|
return defaultRelations().map((fallback) => {
|
||||||
@ -211,6 +254,44 @@ function levelToForm(level) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function completeWeeklyRank(weeklyRank) {
|
||||||
|
const fallback = defaultWeeklyRank();
|
||||||
|
const normalized = { ...fallback, ...(weeklyRank || {}) };
|
||||||
|
normalized.topCount = 3;
|
||||||
|
normalized.relationType = "cp";
|
||||||
|
normalized.rewards = completeWeeklyRankRewards(normalized.rewards || []);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function completeWeeklyRankRewards(rewards) {
|
||||||
|
const byRank = new Map((rewards || []).map((reward) => [Number(reward.rankNo), reward]));
|
||||||
|
return cpWeeklyRankRanks.map((rankNo) => ({
|
||||||
|
rankNo,
|
||||||
|
resourceGroupId: Number(byRank.get(rankNo)?.resourceGroupId || 0),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function weeklyRankToForm(weeklyRank) {
|
||||||
|
const completed = completeWeeklyRank(weeklyRank);
|
||||||
|
return {
|
||||||
|
enabled: completed.enabled !== false,
|
||||||
|
relationType: "cp",
|
||||||
|
topCount: "3",
|
||||||
|
rewards: completeWeeklyRankRewards(completed.rewards).map((reward) => ({
|
||||||
|
rankNo: reward.rankNo,
|
||||||
|
resourceGroupId: reward.resourceGroupId ? String(reward.resourceGroupId) : "",
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function completeWeeklyRankRewardForms(rewards) {
|
||||||
|
const byRank = new Map((rewards || []).map((reward) => [Number(reward.rankNo), reward]));
|
||||||
|
return cpWeeklyRankRanks.map((rankNo) => ({
|
||||||
|
rankNo,
|
||||||
|
resourceGroupId: byRank.get(rankNo)?.resourceGroupId || "",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
function payloadFromForm(form) {
|
function payloadFromForm(form) {
|
||||||
const relations = completeFormRelations(form.relations || []).map((relation) => {
|
const relations = completeFormRelations(form.relations || []).map((relation) => {
|
||||||
const relationType = relation.relationType;
|
const relationType = relation.relationType;
|
||||||
@ -236,7 +317,29 @@ function payloadFromForm(form) {
|
|||||||
levels: completeFormLevels(relation.levels || [], relationType),
|
levels: completeFormLevels(relation.levels || [], relationType),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return { relations };
|
const weeklyRank = weeklyRankPayloadFromForm(form.weeklyRank || weeklyRankToForm(defaultWeeklyRank()));
|
||||||
|
return { relations, weeklyRank };
|
||||||
|
}
|
||||||
|
|
||||||
|
function weeklyRankPayloadFromForm(weeklyRank) {
|
||||||
|
const enabled = weeklyRank.enabled !== false;
|
||||||
|
const rewards = completeWeeklyRankRewardForms(weeklyRank.rewards || []).map((reward) => ({
|
||||||
|
rankNo: Number(reward.rankNo),
|
||||||
|
resourceGroupId: Number(reward.resourceGroupId || 0),
|
||||||
|
}));
|
||||||
|
if (enabled) {
|
||||||
|
const missing = rewards.find(
|
||||||
|
(reward) => !Number.isInteger(reward.resourceGroupId) || reward.resourceGroupId <= 0,
|
||||||
|
);
|
||||||
|
if (missing) {
|
||||||
|
throw new Error(`CP周榜 Top${missing.rankNo} 奖励资源组必须配置`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
topCount: 3,
|
||||||
|
rewards,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function completeFormRelations(relations) {
|
function completeFormRelations(relations) {
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import SaveOutlined from "@mui/icons-material/SaveOutlined";
|
|||||||
import Drawer from "@mui/material/Drawer";
|
import Drawer from "@mui/material/Drawer";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { cpRelationLabels, useCPConfigPage } from "@/features/cp-config/hooks/useCPConfigPage.js";
|
import { cpRelationLabels, cpWeeklyRankRanks, useCPConfigPage } from "@/features/cp-config/hooks/useCPConfigPage.js";
|
||||||
import styles from "@/features/cp-config/cp-config.module.css";
|
import styles from "@/features/cp-config/cp-config.module.css";
|
||||||
import { AdminListBody, AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
|
import { AdminListBody, AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
|
||||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||||
@ -28,6 +28,9 @@ export function CPConfigPage() {
|
|||||||
<SummaryItem label="兄弟上限">{relationMax(relations, "brother")}</SummaryItem>
|
<SummaryItem label="兄弟上限">{relationMax(relations, "brother")}</SummaryItem>
|
||||||
<SummaryItem label="姐妹上限">{relationMax(relations, "sister")}</SummaryItem>
|
<SummaryItem label="姐妹上限">{relationMax(relations, "sister")}</SummaryItem>
|
||||||
<SummaryItem label="CP解除费">{relationBreakupCost(relations, "cp")}</SummaryItem>
|
<SummaryItem label="CP解除费">{relationBreakupCost(relations, "cp")}</SummaryItem>
|
||||||
|
<SummaryItem label="CP周榜">
|
||||||
|
{page.config.weeklyRank?.enabled === false ? "停用" : "启用"}
|
||||||
|
</SummaryItem>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.summaryActions}>
|
<div className={styles.summaryActions}>
|
||||||
<Button
|
<Button
|
||||||
@ -92,6 +95,16 @@ function CPConfigDrawer({ page }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section className="form-drawer__section">
|
||||||
|
<div className="form-drawer__section-title">CP周榜奖励</div>
|
||||||
|
<WeeklyRankEditor
|
||||||
|
disabled={disabled}
|
||||||
|
resourceGroups={page.resourceGroups}
|
||||||
|
weeklyRank={page.form.weeklyRank}
|
||||||
|
updateWeeklyRank={page.updateWeeklyRank}
|
||||||
|
updateWeeklyRankReward={page.updateWeeklyRankReward}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<div className={["form-drawer__actions", styles.configDrawerActions].join(" ")}>
|
<div className={["form-drawer__actions", styles.configDrawerActions].join(" ")}>
|
||||||
<Button disabled={page.saving} type="button" onClick={page.closeDrawer}>
|
<Button disabled={page.saving} type="button" onClick={page.closeDrawer}>
|
||||||
@ -111,6 +124,64 @@ function CPConfigDrawer({ page }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function WeeklyRankEditor({ disabled, resourceGroups, weeklyRank, updateWeeklyRank, updateWeeklyRankReward }) {
|
||||||
|
const enabled = weeklyRank?.enabled !== false;
|
||||||
|
const rewards = weeklyRank?.rewards || [];
|
||||||
|
return (
|
||||||
|
<div className={styles.relationEditor}>
|
||||||
|
<div className={styles.relationEditorHeader}>
|
||||||
|
<div className={styles.relationTitleGroup}>
|
||||||
|
<span className={styles.relationTitle}>Last Weekly CP</span>
|
||||||
|
<span className={styles.relationSubtitle}>Top 1-3 资源组会发给获奖 CP 双方</span>
|
||||||
|
</div>
|
||||||
|
<AdminSwitch
|
||||||
|
checked={enabled}
|
||||||
|
checkedLabel="启用"
|
||||||
|
disabled={disabled}
|
||||||
|
label="CP周榜状态"
|
||||||
|
uncheckedLabel="停用"
|
||||||
|
onChange={(event) => updateWeeklyRank({ enabled: event.target.checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.levelEditorList}>
|
||||||
|
{cpWeeklyRankRanks.map((rankNo) => {
|
||||||
|
const reward = rewards.find((item) => Number(item.rankNo) === Number(rankNo)) || {
|
||||||
|
rankNo,
|
||||||
|
resourceGroupId: "",
|
||||||
|
};
|
||||||
|
const selectedGroup = resourceGroups.find(
|
||||||
|
(group) => String(group.groupId) === String(reward.resourceGroupId),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className={styles.levelEditor} key={rankNo}>
|
||||||
|
<div className={styles.levelEditorHeader}>
|
||||||
|
<div className={styles.levelTitleGroup}>
|
||||||
|
<span className={styles.levelTitle}>Top {rankNo}</span>
|
||||||
|
<span className={styles.levelMeta}>
|
||||||
|
{resourceGroupLabel(selectedGroup, reward.resourceGroupId)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.levelFields}>
|
||||||
|
<ResourceGroupSelectField
|
||||||
|
disabled={disabled || !enabled}
|
||||||
|
drawerTitle={`CP周榜 Top ${rankNo} 奖励资源组`}
|
||||||
|
emptyLabel="未配置奖励"
|
||||||
|
groups={resourceGroups}
|
||||||
|
label="奖励资源组"
|
||||||
|
placeholder="点击选择奖励资源组"
|
||||||
|
value={reward.resourceGroupId}
|
||||||
|
onChange={(value) => updateWeeklyRankReward(rankNo, { resourceGroupId: value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function RelationEditor({ disabled, relation, resourceGroups, updateLevel, updateRelation }) {
|
function RelationEditor({ disabled, relation, resourceGroups, updateLevel, updateRelation }) {
|
||||||
const relationType = relation.relationType;
|
const relationType = relation.relationType;
|
||||||
const maxCountDisabled = disabled || relationType === "cp";
|
const maxCountDisabled = disabled || relationType === "cp";
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import {
|
|||||||
createBDLeader,
|
createBDLeader,
|
||||||
createCountry,
|
createCountry,
|
||||||
createCoinSeller,
|
createCoinSeller,
|
||||||
|
createManager,
|
||||||
createRegion,
|
createRegion,
|
||||||
creditCoinSellerStock,
|
creditCoinSellerStock,
|
||||||
debitCoinSellerStock,
|
debitCoinSellerStock,
|
||||||
@ -14,6 +15,7 @@ import {
|
|||||||
listCoinSellers,
|
listCoinSellers,
|
||||||
listCountries,
|
listCountries,
|
||||||
listHosts,
|
listHosts,
|
||||||
|
listManagers,
|
||||||
listRegions,
|
listRegions,
|
||||||
replaceRegionCountries,
|
replaceRegionCountries,
|
||||||
setCoinSellerStatus,
|
setCoinSellerStatus,
|
||||||
@ -51,6 +53,14 @@ test("host org list APIs use generated admin paths and filters", async () => {
|
|||||||
sort_by: "salary_wallet",
|
sort_by: "salary_wallet",
|
||||||
sort_direction: "desc",
|
sort_direction: "desc",
|
||||||
});
|
});
|
||||||
|
await listManagers({
|
||||||
|
keyword: "manager",
|
||||||
|
page: 1,
|
||||||
|
page_size: 10,
|
||||||
|
region_id: 7,
|
||||||
|
status: "active",
|
||||||
|
});
|
||||||
|
await createManager({ contact: "+63", targetUserId: "1003" });
|
||||||
await listHosts({ agency_id: 41, page: 1, page_size: 10, sort_by: "diamond", sort_direction: "desc" });
|
await listHosts({ agency_id: 41, page: 1, page_size: 10, sort_by: "diamond", sort_direction: "desc" });
|
||||||
await listCoinSellers({
|
await listCoinSellers({
|
||||||
page: 1,
|
page: 1,
|
||||||
@ -86,15 +96,17 @@ test("host org list APIs use generated admin paths and filters", async () => {
|
|||||||
|
|
||||||
const [bdUrl, bdInit] = vi.mocked(fetch).mock.calls[0];
|
const [bdUrl, bdInit] = vi.mocked(fetch).mock.calls[0];
|
||||||
const [agencyUrl] = vi.mocked(fetch).mock.calls[1];
|
const [agencyUrl] = vi.mocked(fetch).mock.calls[1];
|
||||||
const [hostUrl] = vi.mocked(fetch).mock.calls[2];
|
const [managerUrl, managerInit] = vi.mocked(fetch).mock.calls[2];
|
||||||
const [coinSellerUrl, coinSellerInit] = vi.mocked(fetch).mock.calls[3];
|
const [managerCreateUrl, managerCreateInit] = vi.mocked(fetch).mock.calls[3];
|
||||||
const [bdLeaderCreateUrl, bdLeaderCreateInit] = vi.mocked(fetch).mock.calls[4];
|
const [hostUrl] = vi.mocked(fetch).mock.calls[4];
|
||||||
const [bdLeaderAliasUrl, bdLeaderAliasInit] = vi.mocked(fetch).mock.calls[5];
|
const [coinSellerUrl, coinSellerInit] = vi.mocked(fetch).mock.calls[5];
|
||||||
const [coinSellerCreateUrl, coinSellerCreateInit] = vi.mocked(fetch).mock.calls[6];
|
const [bdLeaderCreateUrl, bdLeaderCreateInit] = vi.mocked(fetch).mock.calls[6];
|
||||||
const [coinSellerStatusUrl, coinSellerStatusInit] = vi.mocked(fetch).mock.calls[7];
|
const [bdLeaderAliasUrl, bdLeaderAliasInit] = vi.mocked(fetch).mock.calls[7];
|
||||||
const [coinSellerStockUrl, coinSellerStockInit] = vi.mocked(fetch).mock.calls[8];
|
const [coinSellerCreateUrl, coinSellerCreateInit] = vi.mocked(fetch).mock.calls[8];
|
||||||
const [coinSellerStockDebitUrl, coinSellerStockDebitInit] = vi.mocked(fetch).mock.calls[9];
|
const [coinSellerStatusUrl, coinSellerStatusInit] = vi.mocked(fetch).mock.calls[9];
|
||||||
const [agencyDeleteUrl, agencyDeleteInit] = vi.mocked(fetch).mock.calls[10];
|
const [coinSellerStockUrl, coinSellerStockInit] = vi.mocked(fetch).mock.calls[10];
|
||||||
|
const [coinSellerStockDebitUrl, coinSellerStockDebitInit] = vi.mocked(fetch).mock.calls[11];
|
||||||
|
const [agencyDeleteUrl, agencyDeleteInit] = vi.mocked(fetch).mock.calls[12];
|
||||||
|
|
||||||
expect(String(bdUrl)).toContain("/api/v1/admin/bds?");
|
expect(String(bdUrl)).toContain("/api/v1/admin/bds?");
|
||||||
expect(String(bdUrl)).toContain("parent_leader_user_id=21");
|
expect(String(bdUrl)).toContain("parent_leader_user_id=21");
|
||||||
@ -107,6 +119,15 @@ test("host org list APIs use generated admin paths and filters", async () => {
|
|||||||
expect(String(agencyUrl)).toContain("parent_bd_user_id=31");
|
expect(String(agencyUrl)).toContain("parent_bd_user_id=31");
|
||||||
expect(String(agencyUrl)).toContain("sort_by=salary_wallet");
|
expect(String(agencyUrl)).toContain("sort_by=salary_wallet");
|
||||||
expect(String(agencyUrl)).toContain("sort_direction=desc");
|
expect(String(agencyUrl)).toContain("sort_direction=desc");
|
||||||
|
expect(String(managerUrl)).toContain("/api/v1/admin/managers?");
|
||||||
|
expect(String(managerUrl)).toContain("keyword=manager");
|
||||||
|
expect(String(managerUrl)).toContain("region_id=7");
|
||||||
|
expect(String(managerUrl)).toContain("status=active");
|
||||||
|
expect(String(managerUrl)).not.toContain("parent_bd_user_id");
|
||||||
|
expect(managerInit?.method).toBe("GET");
|
||||||
|
expect(String(managerCreateUrl)).toContain("/api/v1/admin/managers");
|
||||||
|
expect(managerCreateInit?.method).toBe("POST");
|
||||||
|
expect(JSON.parse(String(managerCreateInit?.body))).toEqual({ contact: "+63", targetUserId: "1003" });
|
||||||
expect(String(hostUrl)).toContain("/api/v1/admin/hosts?");
|
expect(String(hostUrl)).toContain("/api/v1/admin/hosts?");
|
||||||
expect(String(hostUrl)).toContain("agency_id=41");
|
expect(String(hostUrl)).toContain("agency_id=41");
|
||||||
expect(String(hostUrl)).toContain("sort_by=diamond");
|
expect(String(hostUrl)).toContain("sort_by=diamond");
|
||||||
|
|||||||
@ -25,9 +25,11 @@ import type {
|
|||||||
CreateBDLeaderPayload,
|
CreateBDLeaderPayload,
|
||||||
CreateBDPayload,
|
CreateBDPayload,
|
||||||
CreateCoinSellerPayload,
|
CreateCoinSellerPayload,
|
||||||
|
CreateManagerPayload,
|
||||||
EntityId,
|
EntityId,
|
||||||
HostCommandPayload,
|
HostCommandPayload,
|
||||||
HostProfileDto,
|
HostProfileDto,
|
||||||
|
ManagerDto,
|
||||||
PageQuery,
|
PageQuery,
|
||||||
RegionCountriesPayload,
|
RegionCountriesPayload,
|
||||||
RegionDto,
|
RegionDto,
|
||||||
@ -168,6 +170,22 @@ export function listAgencies(query: PageQuery = {}): Promise<ApiPage<AgencyDto>>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listManagers(query: PageQuery = {}): Promise<ApiPage<ManagerDto>> {
|
||||||
|
const endpoint = API_ENDPOINTS.listManagers;
|
||||||
|
return apiRequest<ApiPage<ManagerDto>>(apiEndpointPath(API_OPERATIONS.listManagers), {
|
||||||
|
method: endpoint.method,
|
||||||
|
query,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createManager(payload: CreateManagerPayload): Promise<ManagerDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.createManager;
|
||||||
|
return apiRequest<ManagerDto, CreateManagerPayload>(apiEndpointPath(API_OPERATIONS.createManager), {
|
||||||
|
body: payload,
|
||||||
|
method: endpoint.method,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function listHosts(query: PageQuery = {}): Promise<ApiPage<HostProfileDto>> {
|
export function listHosts(query: PageQuery = {}): Promise<ApiPage<HostProfileDto>> {
|
||||||
const endpoint = API_ENDPOINTS.listHosts;
|
const endpoint = API_ENDPOINTS.listHosts;
|
||||||
return apiRequest<ApiPage<HostProfileDto>>(apiEndpointPath(API_OPERATIONS.listHosts), {
|
return apiRequest<ApiPage<HostProfileDto>>(apiEndpointPath(API_OPERATIONS.listHosts), {
|
||||||
|
|||||||
@ -22,6 +22,12 @@ export const bdStatusFilters = [
|
|||||||
["disabled", "停用"]
|
["disabled", "停用"]
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const managerStatusFilters = [
|
||||||
|
["", "全部"],
|
||||||
|
["active", "启用"],
|
||||||
|
["disabled", "停用"]
|
||||||
|
];
|
||||||
|
|
||||||
export const hostStatusFilters = [
|
export const hostStatusFilters = [
|
||||||
["", "全部"],
|
["", "全部"],
|
||||||
["active", "启用"],
|
["active", "启用"],
|
||||||
|
|||||||
@ -1,31 +1,34 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import { parseForm } from "@/shared/forms/validation";
|
||||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||||
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
|
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
|
||||||
|
import { createManager, listManagers } from "@/features/host-org/api";
|
||||||
|
import { useBDAbilities } from "@/features/host-org/permissions.js";
|
||||||
|
import { createManagerSchema } from "@/features/host-org/schema";
|
||||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
import { closeAgency, listAgencies } from "@/features/host-org/api";
|
|
||||||
import { useAgencyAbilities } from "@/features/host-org/permissions.js";
|
|
||||||
|
|
||||||
const pageSize = 50;
|
const pageSize = 50;
|
||||||
const emptyData = { items: [], page: 1, pageSize, total: 0 };
|
const emptyData = { items: [], page: 1, pageSize, total: 0 };
|
||||||
|
const emptyManagerForm = () => ({ contact: "", targetUserId: "" });
|
||||||
|
|
||||||
export function useHostManagersPage() {
|
export function useHostManagersPage() {
|
||||||
const abilities = useAgencyAbilities();
|
const abilities = useBDAbilities();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const { loadingRegions, regionOptions } = useRegionOptions();
|
const { loadingRegions, regionOptions } = useRegionOptions();
|
||||||
|
const [activeAction, setActiveAction] = useState("");
|
||||||
|
const [loadingAction, setLoadingAction] = useState("");
|
||||||
|
const [managerForm, setManagerForm] = useState(emptyManagerForm);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [status, setStatus] = useState("");
|
const [status, setStatus] = useState("");
|
||||||
const [regionId, setRegionId] = useState("");
|
const [regionId, setRegionId] = useState("");
|
||||||
const [parentBdUserId, setParentBdUserId] = useState("");
|
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [loadingAction, setLoadingAction] = useState("");
|
|
||||||
const filters = useMemo(
|
const filters = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
keyword: query,
|
keyword: query,
|
||||||
parent_bd_user_id: parentBdUserId,
|
|
||||||
region_id: regionId,
|
region_id: regionId,
|
||||||
status,
|
status,
|
||||||
}),
|
}),
|
||||||
[parentBdUserId, query, regionId, status],
|
[query, regionId, status],
|
||||||
);
|
);
|
||||||
const {
|
const {
|
||||||
data = emptyData,
|
data = emptyData,
|
||||||
@ -34,7 +37,7 @@ export function useHostManagersPage() {
|
|||||||
reload,
|
reload,
|
||||||
} = usePaginatedQuery({
|
} = usePaginatedQuery({
|
||||||
errorMessage: "加载经理列表失败",
|
errorMessage: "加载经理列表失败",
|
||||||
fetcher: listAgencies,
|
fetcher: listManagers,
|
||||||
filters,
|
filters,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@ -56,68 +59,58 @@ export function useHostManagersPage() {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const changeParentBdUserId = (value) => {
|
|
||||||
setParentBdUserId(value);
|
|
||||||
setPage(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
setQuery("");
|
setQuery("");
|
||||||
setStatus("");
|
setStatus("");
|
||||||
setRegionId("");
|
setRegionId("");
|
||||||
setParentBdUserId("");
|
|
||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeManagerAgency = async (agency) => {
|
const submitManager = async (event) => {
|
||||||
if (!abilities.canStatus || !agency?.agencyId || agency.status !== "active") {
|
event.preventDefault();
|
||||||
return;
|
setLoadingAction("manager-create");
|
||||||
}
|
|
||||||
await runAction(`manager-status-${agency.agencyId}`, "经理已停用", async () => {
|
|
||||||
await closeAgency(agency.agencyId, {
|
|
||||||
commandId: makeCommandId("manager-close"),
|
|
||||||
reason: "close manager agency from list switch",
|
|
||||||
});
|
|
||||||
await reload();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const runAction = async (action, successMessage, submitter) => {
|
|
||||||
setLoadingAction(action);
|
|
||||||
try {
|
try {
|
||||||
await submitter();
|
const payload = parseForm(createManagerSchema, managerForm);
|
||||||
showToast(successMessage, "success");
|
await createManager(payload);
|
||||||
|
setManagerForm(emptyManagerForm());
|
||||||
|
setActiveAction("");
|
||||||
|
showToast("经理已添加", "success");
|
||||||
|
await reload();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message || "操作失败", "error");
|
showToast(err.message || "添加经理失败", "error");
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingAction("");
|
setLoadingAction("");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const closeAction = () => {
|
||||||
|
setActiveAction("");
|
||||||
|
setManagerForm(emptyManagerForm());
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
abilities,
|
abilities,
|
||||||
changeParentBdUserId,
|
activeAction,
|
||||||
changeQuery,
|
changeQuery,
|
||||||
changeRegionId,
|
changeRegionId,
|
||||||
changeStatus,
|
changeStatus,
|
||||||
closeManagerAgency,
|
closeAction,
|
||||||
data,
|
data,
|
||||||
error,
|
error,
|
||||||
loading,
|
|
||||||
loadingAction,
|
loadingAction,
|
||||||
|
loading,
|
||||||
loadingRegions,
|
loadingRegions,
|
||||||
|
managerForm,
|
||||||
|
openManagerForm: () => setActiveAction("manager"),
|
||||||
page,
|
page,
|
||||||
parentBdUserId,
|
|
||||||
query,
|
query,
|
||||||
regionId,
|
regionId,
|
||||||
regionOptions,
|
regionOptions,
|
||||||
reload,
|
reload,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
|
setManagerForm,
|
||||||
setPage,
|
setPage,
|
||||||
status,
|
status,
|
||||||
|
submitManager,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeCommandId(prefix) {
|
|
||||||
return `${prefix}-${Date.now()}`;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,55 +1,53 @@
|
|||||||
|
import Add from "@mui/icons-material/Add";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { agencyStatusFilters } from "@/features/host-org/constants.js";
|
import { managerStatusFilters, statusLabels } from "@/features/host-org/constants.js";
|
||||||
import { HostOrgEntity, HostOrgPerson, hostOrgRegionName } from "@/features/host-org/components/HostOrgIdentity.jsx";
|
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
|
||||||
|
import { HostOrgPerson, hostOrgRegionName } from "@/features/host-org/components/HostOrgIdentity.jsx";
|
||||||
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
|
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
|
||||||
|
import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx";
|
||||||
import { useHostManagersPage } from "@/features/host-org/hooks/useHostManagersPage.js";
|
import { useHostManagersPage } from "@/features/host-org/hooks/useHostManagersPage.js";
|
||||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
import styles from "@/features/host-org/host-org.module.css";
|
import styles from "@/features/host-org/host-org.module.css";
|
||||||
|
|
||||||
const managerColumns = [
|
const managerColumns = [
|
||||||
{
|
{
|
||||||
key: "ownerUserId",
|
key: "userId",
|
||||||
label: "经理",
|
label: "经理",
|
||||||
render: (item) => <ManagerUser item={item} />,
|
render: (item) => <ManagerUser item={item} />,
|
||||||
width: "minmax(220px, 1.25fr)",
|
width: "minmax(220px, 1.25fr)",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: "agency",
|
|
||||||
label: "Agency",
|
|
||||||
render: (item) => <HostOrgEntity id={item.agencyId} name={item.name} prefix="Agency " />,
|
|
||||||
width: "minmax(190px, 1fr)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "parentBdUserId",
|
|
||||||
label: "上级 BD",
|
|
||||||
render: (item) => <ParentBD item={item} />,
|
|
||||||
width: "minmax(220px, 1.2fr)",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "regionId",
|
key: "regionId",
|
||||||
label: "区域",
|
label: "区域",
|
||||||
render: (item, _index, context) => hostOrgRegionName(item, context?.regionOptions),
|
render: (item, _index, context) => hostOrgRegionName(item, context?.regionOptions),
|
||||||
width: "minmax(130px, 0.75fr)",
|
width: "minmax(130px, 0.75fr)",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "contact",
|
||||||
|
label: "联系方式",
|
||||||
|
render: (item) => item.contact || "-",
|
||||||
|
width: "minmax(150px, 0.85fr)",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "status",
|
key: "status",
|
||||||
label: "状态",
|
label: "状态",
|
||||||
render: (item, _index, context) => <ManagerStatusSwitch item={item} page={context?.page} />,
|
render: (item) => <ManagerStatus item={item} />,
|
||||||
width: "minmax(90px, 0.7fr)",
|
width: "minmax(90px, 0.7fr)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "joinEnabled",
|
key: "bdLeaderCount",
|
||||||
label: "入会",
|
label: "BD Leader/Admin",
|
||||||
render: (item) => (
|
render: (item) => item.bdLeaderCount || 0,
|
||||||
<span className={item.joinEnabled ? styles.positive : styles.negative}>
|
width: "minmax(140px, 0.8fr)",
|
||||||
{item.joinEnabled ? "允许" : "禁止"}
|
},
|
||||||
</span>
|
{
|
||||||
),
|
key: "lastInvitedAtMs",
|
||||||
width: "minmax(80px, 0.6fr)",
|
label: "最近邀请",
|
||||||
|
render: (item) => formatMillis(item.lastInvitedAtMs),
|
||||||
|
width: "minmax(170px, 1fr)",
|
||||||
},
|
},
|
||||||
{ key: "maxHosts", label: "最大主播数", width: "minmax(100px, 0.7fr)" },
|
|
||||||
{
|
{
|
||||||
key: "updatedAtMs",
|
key: "updatedAtMs",
|
||||||
label: "更新时间",
|
label: "更新时间",
|
||||||
@ -60,34 +58,30 @@ const managerColumns = [
|
|||||||
|
|
||||||
export function HostManagersPage() {
|
export function HostManagersPage() {
|
||||||
const page = useHostManagersPage();
|
const page = useHostManagersPage();
|
||||||
|
const createDisabled = !page.abilities.canCreate;
|
||||||
const items = page.data.items || [];
|
const items = page.data.items || [];
|
||||||
const total = page.data.total || 0;
|
const total = page.data.total || 0;
|
||||||
|
const toolbarActions = [
|
||||||
|
page.abilities.canCreate
|
||||||
|
? { icon: <Add fontSize="small" />, label: "添加经理", onClick: page.openManagerForm, variant: "primary" }
|
||||||
|
: null,
|
||||||
|
].filter(Boolean);
|
||||||
const columns = managerColumns.map((column) => {
|
const columns = managerColumns.map((column) => {
|
||||||
if (column.key === "ownerUserId") {
|
if (column.key === "userId") {
|
||||||
return {
|
return {
|
||||||
...column,
|
...column,
|
||||||
filter: createTextColumnFilter({
|
filter: createTextColumnFilter({
|
||||||
placeholder: "搜索经理、Agency、用户",
|
placeholder: "搜索经理",
|
||||||
value: page.query,
|
value: page.query,
|
||||||
onChange: page.changeQuery,
|
onChange: page.changeQuery,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (column.key === "parentBdUserId") {
|
|
||||||
return {
|
|
||||||
...column,
|
|
||||||
filter: createTextColumnFilter({
|
|
||||||
placeholder: "搜索上级 BD 用户 ID",
|
|
||||||
value: page.parentBdUserId,
|
|
||||||
onChange: page.changeParentBdUserId,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (column.key === "status") {
|
if (column.key === "status") {
|
||||||
return {
|
return {
|
||||||
...column,
|
...column,
|
||||||
filter: createOptionsColumnFilter({
|
filter: createOptionsColumnFilter({
|
||||||
options: agencyStatusFilters,
|
options: managerStatusFilters,
|
||||||
placeholder: "搜索状态",
|
placeholder: "搜索状态",
|
||||||
value: page.status,
|
value: page.status,
|
||||||
onChange: page.changeStatus,
|
onChange: page.changeStatus,
|
||||||
@ -100,13 +94,14 @@ export function HostManagersPage() {
|
|||||||
return (
|
return (
|
||||||
<section className={styles.root}>
|
<section className={styles.root}>
|
||||||
<div className={styles.contentPanel}>
|
<div className={styles.contentPanel}>
|
||||||
|
<HostOrgToolbar leadingActions={toolbarActions} />
|
||||||
<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}>
|
||||||
<HostOrgTable
|
<HostOrgTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
context={{ page, regionOptions: page.regionOptions }}
|
context={{ page, regionOptions: page.regionOptions }}
|
||||||
items={items}
|
items={items}
|
||||||
minWidth="1250px"
|
minWidth="1240px"
|
||||||
pagination={
|
pagination={
|
||||||
total > 0
|
total > 0
|
||||||
? {
|
? {
|
||||||
@ -117,11 +112,35 @@ export function HostManagersPage() {
|
|||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
rowKey={(item) => `manager-${item.ownerUserId || item.agencyId}`}
|
rowKey={(item) => `manager-${item.userId}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</DataState>
|
</DataState>
|
||||||
</div>
|
</div>
|
||||||
|
<HostOrgActionModal
|
||||||
|
disabled={createDisabled}
|
||||||
|
loading={page.loadingAction === "manager-create"}
|
||||||
|
open={page.activeAction === "manager"}
|
||||||
|
sectionTitle="经理信息"
|
||||||
|
onClose={page.closeAction}
|
||||||
|
onSubmit={page.submitManager}
|
||||||
|
title="添加经理"
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
disabled={createDisabled}
|
||||||
|
label="用户短 ID"
|
||||||
|
required
|
||||||
|
type="number"
|
||||||
|
value={page.managerForm.targetUserId}
|
||||||
|
onChange={(event) => page.setManagerForm({ ...page.managerForm, targetUserId: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={createDisabled}
|
||||||
|
label="联系方式"
|
||||||
|
value={page.managerForm.contact}
|
||||||
|
onChange={(event) => page.setManagerForm({ ...page.managerForm, contact: event.target.value })}
|
||||||
|
/>
|
||||||
|
</HostOrgActionModal>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -129,40 +148,19 @@ export function HostManagersPage() {
|
|||||||
function ManagerUser({ item }) {
|
function ManagerUser({ item }) {
|
||||||
return (
|
return (
|
||||||
<HostOrgPerson
|
<HostOrgPerson
|
||||||
avatar={item.ownerAvatar}
|
avatar={item.avatar}
|
||||||
displayUserId={item.ownerDisplayUserId}
|
displayUserId={item.displayUserId}
|
||||||
fallbackId={item.ownerUserId}
|
fallbackId={item.userId}
|
||||||
username={item.ownerUsername}
|
username={item.username}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ParentBD({ item }) {
|
function ManagerStatus({ item }) {
|
||||||
if (!item.parentBdUserId) {
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<HostOrgPerson
|
|
||||||
avatar={item.parentBdAvatar}
|
|
||||||
displayUserId={item.parentBdDisplayUserId}
|
|
||||||
fallbackId={item.parentBdUserId}
|
|
||||||
username={item.parentBdUsername || "BD"}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ManagerStatusSwitch({ item, page }) {
|
|
||||||
const active = item.status === "active";
|
const active = item.status === "active";
|
||||||
return (
|
return (
|
||||||
<AdminSwitch
|
<span className={active ? styles.positive : styles.negative}>
|
||||||
checked={active}
|
{statusLabels[item.status] || item.status || "-"}
|
||||||
disabled={!active || !page?.abilities.canStatus || page.loadingAction === `manager-status-${item.agencyId}`}
|
</span>
|
||||||
inputProps={{ "aria-label": active ? "停用经理" : "经理已停用" }}
|
|
||||||
onChange={(event) => {
|
|
||||||
if (!event.target.checked) {
|
|
||||||
page.closeManagerAgency(item);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,12 +18,12 @@ export const hostOrgRoutes = [
|
|||||||
permission: PERMISSIONS.regionView,
|
permission: PERMISSIONS.regionView,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Manager列表",
|
label: "经理列表",
|
||||||
loader: () => import("./pages/HostManagersPage.jsx").then((module) => module.HostManagersPage),
|
loader: () => import("./pages/HostManagersPage.jsx").then((module) => module.HostManagersPage),
|
||||||
menuCode: MENU_CODES.hostOrgManagers,
|
menuCode: MENU_CODES.hostOrgManagers,
|
||||||
pageKey: "host-org-managers",
|
pageKey: "host-org-managers",
|
||||||
path: "/host/managers",
|
path: "/host/managers",
|
||||||
permission: PERMISSIONS.agencyView,
|
permission: PERMISSIONS.bdView,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Agency 列表",
|
label: "Agency 列表",
|
||||||
|
|||||||
@ -105,6 +105,11 @@ export const createCoinSellerSchema = commandContactSchema.extend({
|
|||||||
targetUserId: userIdSchema,
|
targetUserId: userIdSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const createManagerSchema = z.object({
|
||||||
|
contact: z.string().trim().max(128, "联系方式不能超过 128 个字符").optional().default(""),
|
||||||
|
targetUserId: userIdSchema,
|
||||||
|
});
|
||||||
|
|
||||||
export const coinSellerStatusSchema = commandContactSchema.extend({
|
export const coinSellerStatusSchema = commandContactSchema.extend({
|
||||||
status: z.enum(["active", "disabled"]),
|
status: z.enum(["active", "disabled"]),
|
||||||
targetUserId: entityIdSchema,
|
targetUserId: entityIdSchema,
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { afterEach, expect, test, vi } from "vitest";
|
|||||||
import { setAccessToken } from "@/shared/api/request";
|
import { setAccessToken } from "@/shared/api/request";
|
||||||
import {
|
import {
|
||||||
createCoinAdjustment,
|
createCoinAdjustment,
|
||||||
|
createFullServerNoticeFanout,
|
||||||
exportCoinSellerLedger,
|
exportCoinSellerLedger,
|
||||||
getGiftDiamondRatios,
|
getGiftDiamondRatios,
|
||||||
listCoinAdjustments,
|
listCoinAdjustments,
|
||||||
@ -41,6 +42,52 @@ test("gift diamond ratio APIs use operations paths", async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("full server notice fanout API uses operations path", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
command_id: "admin_notice_test",
|
||||||
|
created: true,
|
||||||
|
job_id: "mfan_test",
|
||||||
|
message_type: "system",
|
||||||
|
status: "pending",
|
||||||
|
target_scope: "all_active_users",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await createFullServerNoticeFanout({
|
||||||
|
action_type: "app_h5",
|
||||||
|
action_param: "https://example.com",
|
||||||
|
batch_size: 500,
|
||||||
|
command_id: "admin_notice_test",
|
||||||
|
message_type: "system",
|
||||||
|
summary: "今晚 23:00 维护",
|
||||||
|
target_scope: "all_active_users",
|
||||||
|
title: "维护通知",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [url, init] = vi.mocked(fetch).mock.calls[0];
|
||||||
|
|
||||||
|
expect(String(url)).toContain("/api/v1/admin/operations/full-server-notices/fanout");
|
||||||
|
expect(init?.method).toBe("POST");
|
||||||
|
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||||||
|
action_param: "https://example.com",
|
||||||
|
action_type: "app_h5",
|
||||||
|
command_id: "admin_notice_test",
|
||||||
|
message_type: "system",
|
||||||
|
target_scope: "all_active_users",
|
||||||
|
title: "维护通知",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("coin adjustment APIs use generated admin paths", async () => {
|
test("coin adjustment APIs use generated admin paths", async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
@ -96,7 +143,10 @@ test("coin seller ledger API uses generated admin path and filters", async () =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("coin seller ledger export API sends current filters without pagination", async () => {
|
test("coin seller ledger export API sends current filters without pagination", async () => {
|
||||||
vi.stubGlobal("fetch", vi.fn(async () => new Response("seller,amount\n", { status: 200 })));
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () => new Response("seller,amount\n", { status: 200 })),
|
||||||
|
);
|
||||||
|
|
||||||
await exportCoinSellerLedger({
|
await exportCoinSellerLedger({
|
||||||
end_at_ms: "2000",
|
end_at_ms: "2000",
|
||||||
|
|||||||
@ -26,6 +26,40 @@ export interface GiftDiamondRatioResponse {
|
|||||||
items: GiftDiamondRatioItem[];
|
items: GiftDiamondRatioItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FullServerNoticeFanoutPayload {
|
||||||
|
action_param?: string;
|
||||||
|
action_type?: string;
|
||||||
|
aggregate_id?: string;
|
||||||
|
aggregate_type?: string;
|
||||||
|
batch_size?: number;
|
||||||
|
body?: string;
|
||||||
|
command_id?: string;
|
||||||
|
country?: string;
|
||||||
|
expire_at_ms?: number;
|
||||||
|
icon_url?: string;
|
||||||
|
image_url?: string;
|
||||||
|
message_type: "system" | "activity";
|
||||||
|
metadata_json?: string;
|
||||||
|
priority?: number;
|
||||||
|
producer_event_type?: string;
|
||||||
|
region_id?: number;
|
||||||
|
sent_at_ms?: number;
|
||||||
|
summary: string;
|
||||||
|
target_scope: "all_active_users" | "single_user" | "user_ids" | "region" | "country";
|
||||||
|
target_user_id?: number;
|
||||||
|
title: string;
|
||||||
|
user_ids?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FullServerNoticeFanoutResponse {
|
||||||
|
command_id: string;
|
||||||
|
created: boolean;
|
||||||
|
job_id: string;
|
||||||
|
message_type: string;
|
||||||
|
status: string;
|
||||||
|
target_scope: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function listCoinLedger(query: PageQuery = {}): Promise<ApiPage<CoinLedgerEntryDto>> {
|
export function listCoinLedger(query: PageQuery = {}): Promise<ApiPage<CoinLedgerEntryDto>> {
|
||||||
const endpoint = API_ENDPOINTS.listCoinLedger;
|
const endpoint = API_ENDPOINTS.listCoinLedger;
|
||||||
return apiRequest<ApiPage<CoinLedgerEntryDto>>(apiEndpointPath(API_OPERATIONS.listCoinLedger), {
|
return apiRequest<ApiPage<CoinLedgerEntryDto>>(apiEndpointPath(API_OPERATIONS.listCoinLedger), {
|
||||||
@ -101,3 +135,16 @@ export function updateGiftDiamondRatios(payload: {
|
|||||||
method: "PUT",
|
method: "PUT",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createFullServerNoticeFanout(
|
||||||
|
payload: FullServerNoticeFanoutPayload,
|
||||||
|
): Promise<FullServerNoticeFanoutResponse> {
|
||||||
|
const endpoint = API_ENDPOINTS.createFullServerNoticeFanout;
|
||||||
|
return apiRequest<FullServerNoticeFanoutResponse, FullServerNoticeFanoutPayload>(
|
||||||
|
apiEndpointPath(API_OPERATIONS.createFullServerNoticeFanout),
|
||||||
|
{
|
||||||
|
body: payload,
|
||||||
|
method: endpoint.method,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -235,10 +235,105 @@
|
|||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.noticeBody {
|
||||||
|
display: grid;
|
||||||
|
min-height: 0;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
|
||||||
|
gap: var(--space-5);
|
||||||
|
padding: var(--space-5);
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.noticePanel {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.noticeHeader {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.noticeHeader h2,
|
||||||
|
.noticePreview h3 {
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noticeGrid,
|
||||||
|
.noticeContentGrid {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
grid-template-columns: repeat(2, minmax(220px, 1fr));
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.noticeContentGrid {
|
||||||
|
grid-template-columns: repeat(2, minmax(260px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.noticeWideField {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noticePreview {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
min-width: 0;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: var(--space-4);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--bg-card-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.noticePreview p {
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noticePreviewMeta {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.noticePreviewMeta span {
|
||||||
|
display: inline-flex;
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: 28px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.ratioGrid {
|
.ratioGrid {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.noticeBody,
|
||||||
|
.noticeGrid,
|
||||||
|
.noticeContentGrid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.reportTargetType {
|
.reportTargetType {
|
||||||
|
|||||||
398
src/features/operations/pages/FullServerNoticePage.jsx
Normal file
398
src/features/operations/pages/FullServerNoticePage.jsx
Normal file
@ -0,0 +1,398 @@
|
|||||||
|
import CampaignOutlined from "@mui/icons-material/CampaignOutlined";
|
||||||
|
import SendOutlined from "@mui/icons-material/SendOutlined";
|
||||||
|
import Alert from "@mui/material/Alert";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { createFullServerNoticeFanout } from "@/features/operations/api";
|
||||||
|
import { useFullServerNoticeAbilities } from "@/features/operations/permissions.js";
|
||||||
|
import styles from "@/features/operations/operations.module.css";
|
||||||
|
import { AdminListPage, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx";
|
||||||
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
|
|
||||||
|
const messageTypes = [
|
||||||
|
{ label: "系统消息", value: "system" },
|
||||||
|
{ label: "活动消息", value: "activity" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const targetScopes = [
|
||||||
|
{ label: "全量活跃用户", value: "all_active_users" },
|
||||||
|
{ label: "指定用户", value: "single_user" },
|
||||||
|
{ label: "用户列表", value: "user_ids" },
|
||||||
|
{ label: "指定区域", value: "region" },
|
||||||
|
{ label: "指定国家", value: "country" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const actionTypes = [
|
||||||
|
{ label: "无跳转", value: "" },
|
||||||
|
{ label: "活动详情", value: "activity_detail" },
|
||||||
|
{ label: "H5 页面", value: "app_h5" },
|
||||||
|
{ label: "房间详情", value: "room_detail" },
|
||||||
|
{ label: "用户主页", value: "user_profile" },
|
||||||
|
{ label: "提现详情", value: "wallet_withdraw_detail" },
|
||||||
|
{ label: "主播申请", value: "host_application_detail" },
|
||||||
|
{ label: "币商转账", value: "coin_seller_transfer_detail" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const initialForm = {
|
||||||
|
actionParam: "",
|
||||||
|
actionType: "",
|
||||||
|
aggregateId: "",
|
||||||
|
aggregateType: "admin_notice",
|
||||||
|
batchSize: "500",
|
||||||
|
body: "",
|
||||||
|
commandId: "",
|
||||||
|
country: "",
|
||||||
|
expireAt: "",
|
||||||
|
messageType: "system",
|
||||||
|
metadataJson: "",
|
||||||
|
priority: "0",
|
||||||
|
producerEventType: "admin_full_server_notice",
|
||||||
|
regionId: "",
|
||||||
|
summary: "",
|
||||||
|
targetScope: "all_active_users",
|
||||||
|
targetUserId: "",
|
||||||
|
title: "",
|
||||||
|
userIds: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FullServerNoticePage() {
|
||||||
|
const abilities = useFullServerNoticeAbilities();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const [form, setForm] = useState(initialForm);
|
||||||
|
const [result, setResult] = useState(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const targetLabel = useMemo(
|
||||||
|
() => targetScopes.find((item) => item.value === form.targetScope)?.label || form.targetScope,
|
||||||
|
[form.targetScope],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateField = (field) => (event) => {
|
||||||
|
setForm((current) => ({ ...current, [field]: event.target.value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
const payload = buildPayload(form);
|
||||||
|
if (!payload.ok) {
|
||||||
|
showToast(payload.error, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const next = await createFullServerNoticeFanout(payload.value);
|
||||||
|
setResult(next);
|
||||||
|
showToast(next.created ? "全服通知任务已创建" : "已返回现有通知任务", "success");
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "创建全服通知失败", "error");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminListPage>
|
||||||
|
<AdminListToolbar
|
||||||
|
actions={
|
||||||
|
abilities.canSend ? (
|
||||||
|
<Button disabled={saving} startIcon={<SendOutlined />} variant="primary" onClick={submit}>
|
||||||
|
发送
|
||||||
|
</Button>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
filters={null}
|
||||||
|
/>
|
||||||
|
<div className={styles.noticeBody}>
|
||||||
|
<section className={styles.noticePanel}>
|
||||||
|
<div className={styles.noticeHeader}>
|
||||||
|
<CampaignOutlined fontSize="small" />
|
||||||
|
<h2>全服通知</h2>
|
||||||
|
</div>
|
||||||
|
<div className={styles.noticeGrid}>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="消息类型"
|
||||||
|
size="small"
|
||||||
|
value={form.messageType}
|
||||||
|
onChange={updateField("messageType")}
|
||||||
|
>
|
||||||
|
{messageTypes.map((item) => (
|
||||||
|
<MenuItem key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="发送范围"
|
||||||
|
size="small"
|
||||||
|
value={form.targetScope}
|
||||||
|
onChange={updateField("targetScope")}
|
||||||
|
>
|
||||||
|
{targetScopes.map((item) => (
|
||||||
|
<MenuItem key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
{form.targetScope === "single_user" ? (
|
||||||
|
<TextField
|
||||||
|
label="用户 ID"
|
||||||
|
size="small"
|
||||||
|
value={form.targetUserId}
|
||||||
|
onChange={updateField("targetUserId")}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{form.targetScope === "user_ids" ? (
|
||||||
|
<TextField
|
||||||
|
label="用户 ID 列表"
|
||||||
|
size="small"
|
||||||
|
value={form.userIds}
|
||||||
|
onChange={updateField("userIds")}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{form.targetScope === "region" ? (
|
||||||
|
<TextField
|
||||||
|
label="区域 ID"
|
||||||
|
size="small"
|
||||||
|
value={form.regionId}
|
||||||
|
onChange={updateField("regionId")}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{form.targetScope === "country" ? (
|
||||||
|
<TextField
|
||||||
|
label="国家"
|
||||||
|
size="small"
|
||||||
|
value={form.country}
|
||||||
|
onChange={updateField("country")}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<TextField
|
||||||
|
label="幂等命令 ID"
|
||||||
|
size="small"
|
||||||
|
value={form.commandId}
|
||||||
|
onChange={updateField("commandId")}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="批量大小"
|
||||||
|
size="small"
|
||||||
|
type="number"
|
||||||
|
value={form.batchSize}
|
||||||
|
onChange={updateField("batchSize")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.noticeContentGrid}>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
label="标题"
|
||||||
|
size="small"
|
||||||
|
value={form.title}
|
||||||
|
onChange={updateField("title")}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
label="摘要"
|
||||||
|
size="small"
|
||||||
|
value={form.summary}
|
||||||
|
onChange={updateField("summary")}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
minRows={4}
|
||||||
|
className={styles.noticeWideField}
|
||||||
|
label="正文"
|
||||||
|
value={form.body}
|
||||||
|
onChange={updateField("body")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.noticeGrid}>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="跳转类型"
|
||||||
|
size="small"
|
||||||
|
value={form.actionType}
|
||||||
|
onChange={updateField("actionType")}
|
||||||
|
>
|
||||||
|
{actionTypes.map((item) => (
|
||||||
|
<MenuItem key={item.value || "none"} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
label="跳转参数"
|
||||||
|
size="small"
|
||||||
|
value={form.actionParam}
|
||||||
|
onChange={updateField("actionParam")}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="关联类型"
|
||||||
|
size="small"
|
||||||
|
value={form.aggregateType}
|
||||||
|
onChange={updateField("aggregateType")}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="关联 ID"
|
||||||
|
size="small"
|
||||||
|
value={form.aggregateId}
|
||||||
|
onChange={updateField("aggregateId")}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="事件类型"
|
||||||
|
size="small"
|
||||||
|
value={form.producerEventType}
|
||||||
|
onChange={updateField("producerEventType")}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="优先级"
|
||||||
|
size="small"
|
||||||
|
type="number"
|
||||||
|
value={form.priority}
|
||||||
|
onChange={updateField("priority")}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="过期时间"
|
||||||
|
size="small"
|
||||||
|
type="datetime-local"
|
||||||
|
value={form.expireAt}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
onChange={updateField("expireAt")}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
minRows={3}
|
||||||
|
label="Metadata JSON"
|
||||||
|
value={form.metadataJson}
|
||||||
|
onChange={updateField("metadataJson")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className={styles.noticePreview}>
|
||||||
|
<h3>{form.title || "标题"}</h3>
|
||||||
|
<p>{form.summary || "摘要"}</p>
|
||||||
|
<div className={styles.noticePreviewMeta}>
|
||||||
|
<span>{messageTypes.find((item) => item.value === form.messageType)?.label}</span>
|
||||||
|
<span>{targetLabel}</span>
|
||||||
|
<span>{form.actionType || "无跳转"}</span>
|
||||||
|
</div>
|
||||||
|
{result ? (
|
||||||
|
<Alert severity={result.created ? "success" : "info"}>
|
||||||
|
{result.status} · {result.job_id} · {result.command_id}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</AdminListPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPayload(form) {
|
||||||
|
const title = form.title.trim();
|
||||||
|
const summary = form.summary.trim();
|
||||||
|
if (!title || !summary) {
|
||||||
|
return { error: "标题和摘要不能为空", ok: false };
|
||||||
|
}
|
||||||
|
const batchSize = parsePositiveNumber(form.batchSize, "批量大小");
|
||||||
|
if (!batchSize.ok) {
|
||||||
|
return batchSize;
|
||||||
|
}
|
||||||
|
const priority = parseOptionalNumber(form.priority, "优先级");
|
||||||
|
if (!priority.ok) {
|
||||||
|
return priority;
|
||||||
|
}
|
||||||
|
const expireAtMS = datetimeLocalToMS(form.expireAt);
|
||||||
|
if (!expireAtMS.ok) {
|
||||||
|
return expireAtMS;
|
||||||
|
}
|
||||||
|
const target = buildTargetPayload(form);
|
||||||
|
if (!target.ok) {
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
const metadataJson = form.metadataJson.trim();
|
||||||
|
if (metadataJson) {
|
||||||
|
try {
|
||||||
|
JSON.parse(metadataJson);
|
||||||
|
} catch {
|
||||||
|
return { error: "Metadata JSON 不正确", ok: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
value: {
|
||||||
|
...target.value,
|
||||||
|
action_param: form.actionParam.trim() || undefined,
|
||||||
|
action_type: form.actionType,
|
||||||
|
aggregate_id: form.aggregateId.trim() || undefined,
|
||||||
|
aggregate_type: form.aggregateType.trim() || undefined,
|
||||||
|
batch_size: batchSize.value,
|
||||||
|
body: form.body.trim() || undefined,
|
||||||
|
command_id: form.commandId.trim() || undefined,
|
||||||
|
expire_at_ms: expireAtMS.value || undefined,
|
||||||
|
message_type: form.messageType,
|
||||||
|
metadata_json: metadataJson || undefined,
|
||||||
|
priority: priority.value,
|
||||||
|
producer_event_type: form.producerEventType.trim() || undefined,
|
||||||
|
summary,
|
||||||
|
target_scope: form.targetScope,
|
||||||
|
title,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTargetPayload(form) {
|
||||||
|
if (form.targetScope === "single_user") {
|
||||||
|
const targetUserID = parsePositiveNumber(form.targetUserId, "用户 ID");
|
||||||
|
return targetUserID.ok ? { ok: true, value: { target_user_id: targetUserID.value } } : targetUserID;
|
||||||
|
}
|
||||||
|
if (form.targetScope === "user_ids") {
|
||||||
|
const ids = form.userIds
|
||||||
|
.split(/[\s,,]+/)
|
||||||
|
.map((item) => Number(item.trim()))
|
||||||
|
.filter((value) => Number.isInteger(value) && value > 0);
|
||||||
|
if (!ids.length) {
|
||||||
|
return { error: "用户 ID 列表不能为空", ok: false };
|
||||||
|
}
|
||||||
|
return { ok: true, value: { user_ids: Array.from(new Set(ids)) } };
|
||||||
|
}
|
||||||
|
if (form.targetScope === "region") {
|
||||||
|
const regionID = parsePositiveNumber(form.regionId, "区域 ID");
|
||||||
|
return regionID.ok ? { ok: true, value: { region_id: regionID.value } } : regionID;
|
||||||
|
}
|
||||||
|
if (form.targetScope === "country") {
|
||||||
|
const country = form.country.trim();
|
||||||
|
return country ? { ok: true, value: { country } } : { error: "国家不能为空", ok: false };
|
||||||
|
}
|
||||||
|
return { ok: true, value: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePositiveNumber(value, label) {
|
||||||
|
const number = Number(String(value || "").trim());
|
||||||
|
if (!Number.isInteger(number) || number <= 0) {
|
||||||
|
return { error: `${label} 必须是正整数`, ok: false };
|
||||||
|
}
|
||||||
|
return { ok: true, value: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionalNumber(value, label) {
|
||||||
|
const raw = String(value ?? "").trim();
|
||||||
|
if (!raw) {
|
||||||
|
return { ok: true, value: 0 };
|
||||||
|
}
|
||||||
|
const number = Number(raw);
|
||||||
|
if (!Number.isInteger(number)) {
|
||||||
|
return { error: `${label} 必须是整数`, ok: false };
|
||||||
|
}
|
||||||
|
return { ok: true, value: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
function datetimeLocalToMS(value) {
|
||||||
|
if (!value) {
|
||||||
|
return { ok: true, value: 0 };
|
||||||
|
}
|
||||||
|
const ms = new Date(value).getTime();
|
||||||
|
if (!Number.isFinite(ms)) {
|
||||||
|
return { error: "过期时间不正确", ok: false };
|
||||||
|
}
|
||||||
|
return { ok: true, value: ms };
|
||||||
|
}
|
||||||
@ -18,3 +18,12 @@ export function useGiftDiamondAbilities() {
|
|||||||
canView: can(PERMISSIONS.giftDiamondView),
|
canView: can(PERMISSIONS.giftDiamondView),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useFullServerNoticeAbilities() {
|
||||||
|
const { can } = useAuth();
|
||||||
|
|
||||||
|
return {
|
||||||
|
canSend: can(PERMISSIONS.fullServerNoticeSend),
|
||||||
|
canView: can(PERMISSIONS.fullServerNoticeView),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -41,4 +41,12 @@ export const operationsRoutes = [
|
|||||||
path: "/operations/gift-diamonds",
|
path: "/operations/gift-diamonds",
|
||||||
permission: PERMISSIONS.giftDiamondView,
|
permission: PERMISSIONS.giftDiamondView,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "全服通知",
|
||||||
|
loader: () => import("./pages/FullServerNoticePage.jsx").then((module) => module.FullServerNoticePage),
|
||||||
|
menuCode: MENU_CODES.operationFullServerNotice,
|
||||||
|
pageKey: "operation-full-server-notice",
|
||||||
|
path: "/operations/full-server-notices",
|
||||||
|
permission: PERMISSIONS.fullServerNoticeView,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import type {
|
|||||||
RechargeProductPayload,
|
RechargeProductPayload,
|
||||||
ThirdPartyPaymentChannelDto,
|
ThirdPartyPaymentChannelDto,
|
||||||
ThirdPartyPaymentMethodDto,
|
ThirdPartyPaymentMethodDto,
|
||||||
|
ThirdPartyPaymentRateSyncDto,
|
||||||
} from "@/shared/api/types";
|
} from "@/shared/api/types";
|
||||||
|
|
||||||
export function listRechargeBills(query: PageQuery = {}): Promise<ApiPage<RechargeBillDto>> {
|
export function listRechargeBills(query: PageQuery = {}): Promise<ApiPage<RechargeBillDto>> {
|
||||||
@ -97,3 +98,10 @@ export function updateThirdPartyPaymentRate(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function syncThirdPartyPaymentRates(): Promise<ThirdPartyPaymentRateSyncDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.syncThirdPartyPaymentRates;
|
||||||
|
return apiRequest<ThirdPartyPaymentRateSyncDto>(apiEndpointPath(API_OPERATIONS.syncThirdPartyPaymentRates), {
|
||||||
|
method: endpoint.method,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import {
|
import {
|
||||||
listThirdPartyPaymentChannels,
|
listThirdPartyPaymentChannels,
|
||||||
setThirdPartyPaymentMethodStatus,
|
setThirdPartyPaymentMethodStatus,
|
||||||
|
syncThirdPartyPaymentRates,
|
||||||
updateThirdPartyPaymentRate,
|
updateThirdPartyPaymentRate,
|
||||||
} from "@/features/payment/api";
|
} from "@/features/payment/api";
|
||||||
import { usePaymentAbilities } from "@/features/payment/permissions.js";
|
import { usePaymentAbilities } from "@/features/payment/permissions.js";
|
||||||
@ -106,6 +107,25 @@ export function useThirdPartyPaymentPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const syncRates = async () => {
|
||||||
|
// 汇率同步只触发服务端动作;汇率源、失败切换和写库都在 admin-server 完成,前端不接收可篡改的汇率输入。
|
||||||
|
if (!abilities.canUpdateThirdParty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoadingAction("sync-rates");
|
||||||
|
try {
|
||||||
|
const summary = await syncThirdPartyPaymentRates();
|
||||||
|
const fresh = await result.reload();
|
||||||
|
setRateDrafts(rateDraftsFromChannels(fresh?.items || []));
|
||||||
|
const suffix = summary?.sourceNames?.length ? `(${summary.sourceNames.join("、")})` : "";
|
||||||
|
showToast(`汇率已同步 ${summary?.updatedCount || 0} 个支付方式${suffix}`, "success");
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "汇率同步失败", "error");
|
||||||
|
} finally {
|
||||||
|
setLoadingAction("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
abilities,
|
abilities,
|
||||||
activeChannel,
|
activeChannel,
|
||||||
@ -118,7 +138,17 @@ export function useThirdPartyPaymentPage() {
|
|||||||
reload: result.reload,
|
reload: result.reload,
|
||||||
saveRate,
|
saveRate,
|
||||||
setRateDraft,
|
setRateDraft,
|
||||||
|
syncRates,
|
||||||
toggleChannel,
|
toggleChannel,
|
||||||
toggleMethod,
|
toggleMethod,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rateDraftsFromChannels(channels) {
|
||||||
|
return channels.reduce((drafts, channel) => {
|
||||||
|
(channel.methods || []).forEach((method) => {
|
||||||
|
drafts[String(method.methodId)] = method.usdToCurrencyRate || "";
|
||||||
|
});
|
||||||
|
return drafts;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|||||||
@ -2,9 +2,13 @@ import AddOutlined from "@mui/icons-material/AddOutlined";
|
|||||||
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import Tab from "@mui/material/Tab";
|
||||||
|
import Tabs from "@mui/material/Tabs";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import { useMemo } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useRechargeProductsPage } from "@/features/payment/hooks/useRechargeProductsPage.js";
|
import { useRechargeProductsPage } from "@/features/payment/hooks/useRechargeProductsPage.js";
|
||||||
|
import { ThirdPartyPaymentContent } from "@/features/payment/pages/ThirdPartyPaymentPage.jsx";
|
||||||
|
import styles from "@/features/payment/payment.module.css";
|
||||||
import { Button } from "@/shared/ui/Button.jsx";
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
import {
|
import {
|
||||||
AdminFormAmountField,
|
AdminFormAmountField,
|
||||||
@ -49,24 +53,45 @@ const audienceOptions = [
|
|||||||
|
|
||||||
export function RechargeProductConfigPage() {
|
export function RechargeProductConfigPage() {
|
||||||
const page = useRechargeProductsPage();
|
const page = useRechargeProductsPage();
|
||||||
|
const [activeProductTab, setActiveProductTab] = useState("google");
|
||||||
|
const googleProductPlatform = "android";
|
||||||
|
const { platform, setPlatform } = page;
|
||||||
const items = page.data?.items || [];
|
const items = page.data?.items || [];
|
||||||
const regionLabels = useMemo(() => regionLabelMap(page.regionOptions), [page.regionOptions]);
|
const regionLabels = useMemo(() => regionLabelMap(page.regionOptions), [page.regionOptions]);
|
||||||
const regionValues = useMemo(() => page.regionOptions.map((option) => option.value), [page.regionOptions]);
|
const regionValues = useMemo(() => page.regionOptions.map((option) => option.value), [page.regionOptions]);
|
||||||
const columns = useMemo(() => productColumns(page, regionLabels), [page, regionLabels]);
|
const columns = useMemo(() => productColumns(page, regionLabels, true), [page, regionLabels]);
|
||||||
const saving = page.loadingAction === "create" || page.loadingAction === "edit";
|
const saving = page.loadingAction === "create" || page.loadingAction === "edit";
|
||||||
const canSubmit = page.editingItem ? page.abilities.canUpdateProduct : page.abilities.canCreateProduct;
|
const canSubmit = page.editingItem ? page.abilities.canUpdateProduct : page.abilities.canCreateProduct;
|
||||||
|
const openCreate = () => {
|
||||||
|
page.openCreate();
|
||||||
|
page.setForm({ platform: googleProductPlatform });
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeProductTab === "google" && platform !== googleProductPlatform) {
|
||||||
|
setPlatform(googleProductPlatform);
|
||||||
|
}
|
||||||
|
}, [activeProductTab, googleProductPlatform, platform, setPlatform]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminListPage>
|
<AdminListPage>
|
||||||
|
<div className={styles.paymentTabsBar}>
|
||||||
|
<Tabs value={activeProductTab} onChange={(_, value) => setActiveProductTab(value)}>
|
||||||
|
<Tab label="Google" value="google" />
|
||||||
|
<Tab label="三方" value="third_party" />
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
{activeProductTab === "google" ? (
|
||||||
|
<>
|
||||||
<AdminListToolbar
|
<AdminListToolbar
|
||||||
actions={
|
actions={
|
||||||
page.abilities.canCreateProduct ? (
|
page.abilities.canCreateProduct ? (
|
||||||
<Button
|
<Button
|
||||||
startIcon={<AddOutlined fontSize="small" />}
|
startIcon={<AddOutlined fontSize="small" />}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={page.openCreate}
|
onClick={openCreate}
|
||||||
>
|
>
|
||||||
新增内购
|
新增 Google 商品
|
||||||
</Button>
|
</Button>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
@ -87,13 +112,18 @@ export function RechargeProductConfigPage() {
|
|||||||
/>
|
/>
|
||||||
</AdminListBody>
|
</AdminListBody>
|
||||||
</DataState>
|
</DataState>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<ThirdPartyPaymentContent embedded />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeProductTab === "google" ? (
|
||||||
<AdminFormDialog
|
<AdminFormDialog
|
||||||
loading={saving}
|
loading={saving}
|
||||||
open={Boolean(page.activeAction)}
|
open={Boolean(page.activeAction)}
|
||||||
size="compact"
|
size="compact"
|
||||||
submitDisabled={!canSubmit || saving}
|
submitDisabled={!canSubmit || saving}
|
||||||
title={page.editingItem ? "编辑内购配置" : "新增内购配置"}
|
title={page.editingItem ? "编辑 Google 商品" : "新增 Google 商品"}
|
||||||
onClose={page.closeDialog}
|
onClose={page.closeDialog}
|
||||||
onSubmit={page.submitProduct}
|
onSubmit={page.submitProduct}
|
||||||
>
|
>
|
||||||
@ -107,7 +137,7 @@ export function RechargeProductConfigPage() {
|
|||||||
onChange={(event) => page.setForm({ productName: event.target.value })}
|
onChange={(event) => page.setForm({ productName: event.target.value })}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
disabled={!canSubmit}
|
disabled
|
||||||
label="平台"
|
label="平台"
|
||||||
required
|
required
|
||||||
select
|
select
|
||||||
@ -184,11 +214,12 @@ export function RechargeProductConfigPage() {
|
|||||||
/>
|
/>
|
||||||
</AdminFormSection>
|
</AdminFormSection>
|
||||||
</AdminFormDialog>
|
</AdminFormDialog>
|
||||||
|
) : null}
|
||||||
</AdminListPage>
|
</AdminListPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function productColumns(page, regionLabels) {
|
function productColumns(page, regionLabels, lockedPlatform = false) {
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
key: "product",
|
key: "product",
|
||||||
@ -216,7 +247,9 @@ function productColumns(page, regionLabels) {
|
|||||||
key: "platform",
|
key: "platform",
|
||||||
label: "平台",
|
label: "平台",
|
||||||
render: (item) => platformLabel(item.platform),
|
render: (item) => platformLabel(item.platform),
|
||||||
filter: createOptionsColumnFilter({
|
filter: lockedPlatform
|
||||||
|
? undefined
|
||||||
|
: createOptionsColumnFilter({
|
||||||
options: platformOptions,
|
options: platformOptions,
|
||||||
placeholder: "搜索平台",
|
placeholder: "搜索平台",
|
||||||
value: page.platform,
|
value: page.platform,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined";
|
import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined";
|
||||||
import KeyboardArrowRightOutlined from "@mui/icons-material/KeyboardArrowRightOutlined";
|
import KeyboardArrowRightOutlined from "@mui/icons-material/KeyboardArrowRightOutlined";
|
||||||
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
|
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
|
||||||
|
import SyncOutlined from "@mui/icons-material/SyncOutlined";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
@ -12,22 +13,37 @@ import { DataState } from "@/shared/ui/DataState.jsx";
|
|||||||
import styles from "@/features/payment/payment.module.css";
|
import styles from "@/features/payment/payment.module.css";
|
||||||
|
|
||||||
export function ThirdPartyPaymentPage() {
|
export function ThirdPartyPaymentPage() {
|
||||||
|
return <ThirdPartyPaymentContent showProductButton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThirdPartyPaymentContent({ embedded = false, showProductButton = false }) {
|
||||||
const page = useThirdPartyPaymentPage();
|
const page = useThirdPartyPaymentPage();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
// 国家分组只影响展示密度,不改变后端返回的可开关粒度;每个 method 仍独立保存状态和汇率。
|
// 国家分组只影响展示密度,不改变后端返回的可开关粒度;每个 method 仍独立保存状态和汇率。
|
||||||
const groupedMethods = useMemo(() => groupMethods(page.activeChannel?.methods || []), [page.activeChannel]);
|
const groupedMethods = useMemo(() => groupMethods(page.activeChannel?.methods || []), [page.activeChannel]);
|
||||||
|
|
||||||
return (
|
const content = (
|
||||||
<AdminListPage>
|
<>
|
||||||
<AdminListToolbar
|
<AdminListToolbar
|
||||||
actions={
|
actions={
|
||||||
|
<div className={styles.toolbarActions}>
|
||||||
|
<Button
|
||||||
|
disabled={!page.abilities.canUpdateThirdParty || page.loadingAction === "sync-rates"}
|
||||||
|
startIcon={<SyncOutlined fontSize="small" />}
|
||||||
|
onClick={page.syncRates}
|
||||||
|
>
|
||||||
|
支付汇率全球同步
|
||||||
|
</Button>
|
||||||
|
{showProductButton ? (
|
||||||
<Button
|
<Button
|
||||||
startIcon={<SettingsOutlined fontSize="small" />}
|
startIcon={<SettingsOutlined fontSize="small" />}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={() => navigate("/payment/recharge-products")}
|
onClick={() => navigate("/payment/recharge-products")}
|
||||||
>
|
>
|
||||||
三方支付内购配置
|
支付内购商品
|
||||||
</Button>
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||||
@ -82,10 +98,13 @@ export function ThirdPartyPaymentPage() {
|
|||||||
</div>
|
</div>
|
||||||
{group.methods.map((method) => (
|
{group.methods.map((method) => (
|
||||||
<div key={method.methodId} className={styles.methodRow}>
|
<div key={method.methodId} className={styles.methodRow}>
|
||||||
|
<span className={styles.methodIdentity}>
|
||||||
|
<MethodLogo method={method} />
|
||||||
<span className={styles.methodName}>
|
<span className={styles.methodName}>
|
||||||
<strong>{method.methodName || method.payWay || "-"}</strong>
|
<strong>{method.methodName || method.payWay || "-"}</strong>
|
||||||
<small>{method.payWay || "-"}</small>
|
<small>{method.payWay || "-"}</small>
|
||||||
</span>
|
</span>
|
||||||
|
</span>
|
||||||
<span>{method.payType || "-"}</span>
|
<span>{method.payType || "-"}</span>
|
||||||
<span>{method.currencyCode || "-"}</span>
|
<span>{method.currencyCode || "-"}</span>
|
||||||
<span className={styles.rateEditor}>
|
<span className={styles.rateEditor}>
|
||||||
@ -117,7 +136,8 @@ export function ThirdPartyPaymentPage() {
|
|||||||
page.loadingAction === `status:${method.methodId}`
|
page.loadingAction === `status:${method.methodId}`
|
||||||
}
|
}
|
||||||
inputProps={{
|
inputProps={{
|
||||||
"aria-label": method.status === "active"
|
"aria-label":
|
||||||
|
method.status === "active"
|
||||||
? "关闭支付方式"
|
? "关闭支付方式"
|
||||||
: "打开支付方式",
|
: "打开支付方式",
|
||||||
}}
|
}}
|
||||||
@ -139,14 +159,28 @@ export function ThirdPartyPaymentPage() {
|
|||||||
</div>
|
</div>
|
||||||
</AdminListBody>
|
</AdminListBody>
|
||||||
</DataState>
|
</DataState>
|
||||||
</AdminListPage>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (embedded) {
|
||||||
|
return <div className={styles.paymentEmbedded}>{content}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AdminListPage>{content}</AdminListPage>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusPill({ active }) {
|
function StatusPill({ active }) {
|
||||||
return <span className={active ? styles.statusActive : styles.statusDisabled}>{active ? "已启用" : "已停用"}</span>;
|
return <span className={active ? styles.statusActive : styles.statusDisabled}>{active ? "已启用" : "已停用"}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MethodLogo({ method }) {
|
||||||
|
const label = String(method.methodName || method.payType || method.payWay || "Pay").slice(0, 2).toUpperCase();
|
||||||
|
if (method.logoUrl) {
|
||||||
|
return <img alt="" className={styles.methodLogo} src={method.logoUrl} />;
|
||||||
|
}
|
||||||
|
return <span className={styles.methodLogoFallback}>{label}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
function groupMethods(methods) {
|
function groupMethods(methods) {
|
||||||
// GLOBAL 是三方支付的跨国家方式,放在最前面,国家方式再按标题排序,方便运营先处理全局支付。
|
// GLOBAL 是三方支付的跨国家方式,放在最前面,国家方式再按标题排序,方便运营先处理全局支付。
|
||||||
const groups = new Map();
|
const groups = new Map();
|
||||||
|
|||||||
@ -55,6 +55,29 @@
|
|||||||
font-size: var(--admin-font-size);
|
font-size: var(--admin-font-size);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbarActions {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paymentTabsBar {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 0 var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.paymentEmbedded {
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.thirdPartyLayout {
|
.thirdPartyLayout {
|
||||||
display: grid;
|
display: grid;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@ -207,6 +230,37 @@
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.methodIdentity {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
grid-template-columns: 56px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.methodLogo,
|
||||||
|
.methodLogoFallback {
|
||||||
|
width: 56px;
|
||||||
|
height: 32px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-card-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.methodLogo {
|
||||||
|
display: block;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.methodLogoFallback {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
.methodName strong,
|
.methodName strong,
|
||||||
.methodName small {
|
.methodName small {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@ -18,7 +18,7 @@ export const paymentRoutes = [
|
|||||||
permission: PERMISSIONS.paymentThirdPartyView,
|
permission: PERMISSIONS.paymentThirdPartyView,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "三方支付内购配置",
|
label: "支付内购商品",
|
||||||
loader: () =>
|
loader: () =>
|
||||||
import("./pages/RechargeProductConfigPage.jsx").then((module) => module.RechargeProductConfigPage),
|
import("./pages/RechargeProductConfigPage.jsx").then((module) => module.RechargeProductConfigPage),
|
||||||
menuCode: MENU_CODES.paymentRechargeProducts,
|
menuCode: MENU_CODES.paymentRechargeProducts,
|
||||||
|
|||||||
@ -39,6 +39,7 @@ const permissionGroupDefinitions = [
|
|||||||
key: "activities",
|
key: "activities",
|
||||||
prefixes: [
|
prefixes: [
|
||||||
"achievement",
|
"achievement",
|
||||||
|
"agency-opening",
|
||||||
"cp-config",
|
"cp-config",
|
||||||
"cumulative-recharge-reward",
|
"cumulative-recharge-reward",
|
||||||
"daily-task",
|
"daily-task",
|
||||||
|
|||||||
@ -9,6 +9,9 @@ import type {
|
|||||||
RoomDto,
|
RoomDto,
|
||||||
RoomPinDto,
|
RoomPinDto,
|
||||||
RoomPinPayload,
|
RoomPinPayload,
|
||||||
|
AvailableRoomRobotDto,
|
||||||
|
RobotRoomDto,
|
||||||
|
RobotRoomPayload,
|
||||||
RoomUpdatePayload,
|
RoomUpdatePayload,
|
||||||
} from "@/shared/api/types";
|
} from "@/shared/api/types";
|
||||||
|
|
||||||
@ -72,3 +75,40 @@ export function updateRoomConfig(payload: RoomConfigPayload): Promise<RoomConfig
|
|||||||
method: endpoint.method,
|
method: endpoint.method,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listRobotRooms(query: PageQuery = {}): Promise<ApiPage<RobotRoomDto>> {
|
||||||
|
const endpoint = API_ENDPOINTS.listRobotRooms;
|
||||||
|
return apiRequest<ApiPage<RobotRoomDto>>(apiEndpointPath(API_OPERATIONS.listRobotRooms), {
|
||||||
|
method: endpoint.method,
|
||||||
|
query,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listAvailableRoomRobots(): Promise<AvailableRoomRobotDto[]> {
|
||||||
|
const endpoint = API_ENDPOINTS.listAvailableRoomRobots;
|
||||||
|
return apiRequest<AvailableRoomRobotDto[]>(apiEndpointPath(API_OPERATIONS.listAvailableRoomRobots), {
|
||||||
|
method: endpoint.method,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRobotRoom(payload: RobotRoomPayload): Promise<RobotRoomDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.createRobotRoom;
|
||||||
|
return apiRequest<RobotRoomDto, RobotRoomPayload>(apiEndpointPath(API_OPERATIONS.createRobotRoom), {
|
||||||
|
body: payload,
|
||||||
|
method: endpoint.method,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startRobotRoom(roomId: EntityId): Promise<RobotRoomDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.startRobotRoom;
|
||||||
|
return apiRequest<RobotRoomDto>(apiEndpointPath(API_OPERATIONS.startRobotRoom, { room_id: roomId }), {
|
||||||
|
method: endpoint.method,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopRobotRoom(roomId: EntityId): Promise<RobotRoomDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.stopRobotRoom;
|
||||||
|
return apiRequest<RobotRoomDto>(apiEndpointPath(API_OPERATIONS.stopRobotRoom, { room_id: roomId }), {
|
||||||
|
method: endpoint.method,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
242
src/features/rooms/hooks/useRobotRoomsPage.js
Normal file
242
src/features/rooms/hooks/useRobotRoomsPage.js
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { listGifts } from "@/features/resources/api";
|
||||||
|
import {
|
||||||
|
createRobotRoom,
|
||||||
|
listAvailableRoomRobots,
|
||||||
|
listRobotRooms,
|
||||||
|
startRobotRoom,
|
||||||
|
stopRobotRoom,
|
||||||
|
} from "@/features/rooms/api";
|
||||||
|
import { useRoomAbilities } from "@/features/rooms/permissions.js";
|
||||||
|
import { robotRoomCreateSchema } from "@/features/rooms/schema";
|
||||||
|
import { parseForm } from "@/shared/forms/validation";
|
||||||
|
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||||
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
|
|
||||||
|
const pageSize = 50;
|
||||||
|
const emptyData = { items: [], page: 1, pageSize, total: 0 };
|
||||||
|
|
||||||
|
function emptyForm() {
|
||||||
|
return {
|
||||||
|
candidateRobotUserIds: [],
|
||||||
|
giftIds: [],
|
||||||
|
luckyComboMax: "10000",
|
||||||
|
luckyComboMin: "100",
|
||||||
|
luckyGiftIds: [],
|
||||||
|
luckyPauseMaxMs: "20000",
|
||||||
|
luckyPauseMinMs: "5000",
|
||||||
|
maxRobotCount: "8",
|
||||||
|
minRobotCount: "6",
|
||||||
|
normalGiftIntervalMs: "10000",
|
||||||
|
ownerRobotUserId: "",
|
||||||
|
roomName: "",
|
||||||
|
visibleRegionId: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRobotRoomsPage() {
|
||||||
|
const abilities = useRoomAbilities();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const [activeAction, setActiveAction] = useState("");
|
||||||
|
const [availableRobotsState, setAvailableRobotsState] = useState({ error: "", loading: false, options: [] });
|
||||||
|
const [candidatesTouched, setCandidatesTouched] = useState(false);
|
||||||
|
const [form, setForm] = useState(emptyForm);
|
||||||
|
const [giftOptionsState, setGiftOptionsState] = useState({ error: "", loading: false, options: [] });
|
||||||
|
const [loadingAction, setLoadingAction] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [status, setStatus] = useState("active");
|
||||||
|
const filters = useMemo(() => ({ status }), [status]);
|
||||||
|
const {
|
||||||
|
data = emptyData,
|
||||||
|
error,
|
||||||
|
loading,
|
||||||
|
reload,
|
||||||
|
} = usePaginatedQuery({
|
||||||
|
errorMessage: "加载机器人房间列表失败",
|
||||||
|
fetcher: listRobotRooms,
|
||||||
|
filters,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
queryKey: ["robot-rooms", filters, page],
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadAvailableRobots = async () => {
|
||||||
|
setAvailableRobotsState((current) => ({ ...current, error: "", loading: true }));
|
||||||
|
try {
|
||||||
|
const options = await listAvailableRoomRobots();
|
||||||
|
setAvailableRobotsState({ error: "", loading: false, options: options || [] });
|
||||||
|
} catch (err) {
|
||||||
|
setAvailableRobotsState({ error: err.message || "加载可用机器人失败", loading: false, options: [] });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadGiftOptions = async () => {
|
||||||
|
setGiftOptionsState((current) => ({ ...current, error: "", loading: true }));
|
||||||
|
try {
|
||||||
|
const result = await listGifts({ page: 1, page_size: 200, status: "active" });
|
||||||
|
setGiftOptionsState({ error: "", loading: false, options: result?.items || [] });
|
||||||
|
} catch (err) {
|
||||||
|
setGiftOptionsState({ error: err.message || "加载礼物列表失败", loading: false, options: [] });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeAction !== "create") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadAvailableRobots();
|
||||||
|
loadGiftOptions();
|
||||||
|
}, [activeAction]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeAction !== "create" || candidatesTouched) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ids = availableRobotsState.options.map((item) => item.userId).filter(Boolean);
|
||||||
|
if (ids.length > 0) {
|
||||||
|
setForm((current) => ({ ...current, candidateRobotUserIds: ids }));
|
||||||
|
}
|
||||||
|
}, [activeAction, availableRobotsState.options, candidatesTouched]);
|
||||||
|
|
||||||
|
const changeStatus = (value) => {
|
||||||
|
setStatus(value || "active");
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
setStatus("active");
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setActiveAction("create");
|
||||||
|
setCandidatesTouched(false);
|
||||||
|
setForm(emptyForm());
|
||||||
|
setAvailableRobotsState({ error: "", loading: false, options: [] });
|
||||||
|
setGiftOptionsState({ error: "", loading: false, options: [] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeAction = () => {
|
||||||
|
setActiveAction("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitCreate = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
await runAction("create", "机器人房间已创建", async () => {
|
||||||
|
const payload = parseForm(robotRoomCreateSchema, form);
|
||||||
|
await createRobotRoom(payload);
|
||||||
|
closeAction();
|
||||||
|
await reload();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const setRoomStatus = async (room) => {
|
||||||
|
if (!room?.roomId || !abilities.canRobotUpdate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextActive = room.status !== "active";
|
||||||
|
await runAction(`status-${room.roomId}`, nextActive ? "机器人房间已启动" : "机器人房间已停止", async () => {
|
||||||
|
if (nextActive) {
|
||||||
|
await startRobotRoom(room.roomId);
|
||||||
|
} else {
|
||||||
|
await stopRobotRoom(room.roomId);
|
||||||
|
}
|
||||||
|
await reload();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectOwnerRobot = (robot) => {
|
||||||
|
setForm((current) => ({ ...current, ownerRobotUserId: robot?.userId || "" }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectCandidateRobots = (robots) => {
|
||||||
|
setCandidatesTouched(true);
|
||||||
|
setForm((current) => ({ ...current, candidateRobotUserIds: robots.map((robot) => robot.userId).filter(Boolean) }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectGiftIds = (key, gifts) => {
|
||||||
|
setForm((current) => ({ ...current, [key]: gifts.map(giftId).filter(Boolean) }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const runAction = async (action, successMessage, submitter) => {
|
||||||
|
setLoadingAction(action);
|
||||||
|
try {
|
||||||
|
await submitter();
|
||||||
|
showToast(successMessage, "success");
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "操作失败", "error");
|
||||||
|
} finally {
|
||||||
|
setLoadingAction("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedOwnerRobot = useMemo(
|
||||||
|
() => availableRobotsState.options.find((item) => item.userId === String(form.ownerRobotUserId)) || null,
|
||||||
|
[availableRobotsState.options, form.ownerRobotUserId],
|
||||||
|
);
|
||||||
|
const selectedCandidateRobots = useMemo(() => {
|
||||||
|
const selected = new Set((form.candidateRobotUserIds || []).map(String));
|
||||||
|
return availableRobotsState.options.filter((item) => selected.has(String(item.userId)));
|
||||||
|
}, [availableRobotsState.options, form.candidateRobotUserIds]);
|
||||||
|
const selectedGiftOptions = useMemo(() => {
|
||||||
|
const selected = new Set((form.giftIds || []).map(String));
|
||||||
|
return giftOptionsState.options.filter((item) => selected.has(giftId(item)));
|
||||||
|
}, [form.giftIds, giftOptionsState.options]);
|
||||||
|
const selectedLuckyGiftOptions = useMemo(() => {
|
||||||
|
const selected = new Set((form.luckyGiftIds || []).map(String));
|
||||||
|
return giftOptionsState.options.filter((item) => selected.has(giftId(item)));
|
||||||
|
}, [form.luckyGiftIds, giftOptionsState.options]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
abilities,
|
||||||
|
activeAction,
|
||||||
|
availableRobotsError: availableRobotsState.error,
|
||||||
|
availableRobotsLoading: availableRobotsState.loading,
|
||||||
|
availableRobots: availableRobotsState.options,
|
||||||
|
changeStatus,
|
||||||
|
closeAction,
|
||||||
|
data,
|
||||||
|
error,
|
||||||
|
form,
|
||||||
|
giftOptions: giftOptionsState.options,
|
||||||
|
giftOptionsError: giftOptionsState.error,
|
||||||
|
giftOptionsLoading: giftOptionsState.loading,
|
||||||
|
loading,
|
||||||
|
loadingAction,
|
||||||
|
openCreate,
|
||||||
|
page,
|
||||||
|
reload,
|
||||||
|
resetFilters,
|
||||||
|
selectCandidateRobots,
|
||||||
|
selectGiftIds,
|
||||||
|
selectOwnerRobot,
|
||||||
|
selectedCandidateRobots,
|
||||||
|
selectedGiftOptions,
|
||||||
|
selectedLuckyGiftOptions,
|
||||||
|
selectedOwnerRobot,
|
||||||
|
setForm,
|
||||||
|
setPage,
|
||||||
|
setRoomStatus,
|
||||||
|
status,
|
||||||
|
submitCreate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function robotOptionLabel(robot) {
|
||||||
|
if (!robot) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const user = robot.user || {};
|
||||||
|
return [user.username, user.displayUserId, robot.userId].filter(Boolean).join(" / ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function giftId(gift) {
|
||||||
|
return String(gift?.giftId || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function giftOptionLabel(gift) {
|
||||||
|
if (!gift) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return [gift.name, gift.giftId].filter(Boolean).join(" / ");
|
||||||
|
}
|
||||||
365
src/features/rooms/pages/RoomRobotPage.jsx
Normal file
365
src/features/rooms/pages/RoomRobotPage.jsx
Normal file
@ -0,0 +1,365 @@
|
|||||||
|
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||||
|
import BedroomParentOutlined from "@mui/icons-material/BedroomParentOutlined";
|
||||||
|
import Autocomplete from "@mui/material/Autocomplete";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
|
import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx";
|
||||||
|
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
||||||
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
|
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||||
|
import { AdminFilterResetButton } from "@/shared/ui/AdminListLayout.jsx";
|
||||||
|
import { createOptionsColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
|
import {
|
||||||
|
giftId,
|
||||||
|
giftOptionLabel,
|
||||||
|
robotOptionLabel,
|
||||||
|
useRobotRoomsPage,
|
||||||
|
} from "@/features/rooms/hooks/useRobotRoomsPage.js";
|
||||||
|
import styles from "@/features/rooms/rooms.module.css";
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ label: "运行中", value: "active" },
|
||||||
|
{ label: "已停止", value: "stopped" },
|
||||||
|
{ label: "全部状态", value: "all" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
key: "room",
|
||||||
|
label: "房间信息",
|
||||||
|
width: "minmax(280px, 1.4fr)",
|
||||||
|
render: (room) => <RobotRoomIdentity room={room} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "owner",
|
||||||
|
label: "房主机器人",
|
||||||
|
width: "minmax(220px, 1fr)",
|
||||||
|
render: (room) => <RobotIdentity user={room.owner} fallbackId={room.ownerRobotUserId} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "robots",
|
||||||
|
label: "进房机器人",
|
||||||
|
width: "minmax(120px, 0.5fr)",
|
||||||
|
render: (room) => Number(room.robotUserIds?.length || room.robots?.length || 0).toLocaleString("zh-CN"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "normalRule",
|
||||||
|
label: "普通礼物",
|
||||||
|
width: "minmax(190px, 0.8fr)",
|
||||||
|
render: (room) => (
|
||||||
|
<RuleStack
|
||||||
|
rows={[
|
||||||
|
`${room.giftRule?.giftIds?.length || 0} 个礼物`,
|
||||||
|
`${formatSeconds(room.giftRule?.normalGiftIntervalMs)} / 次`,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "luckyRule",
|
||||||
|
label: "幸运礼物",
|
||||||
|
width: "minmax(220px, 0.9fr)",
|
||||||
|
render: (room) => (
|
||||||
|
<RuleStack
|
||||||
|
rows={[
|
||||||
|
`${room.giftRule?.luckyGiftIds?.length || 0} 个礼物`,
|
||||||
|
`${room.giftRule?.luckyComboMin || 0}-${room.giftRule?.luckyComboMax || 0} 连击`,
|
||||||
|
`${formatSeconds(room.giftRule?.luckyPauseMinMs)}-${formatSeconds(room.giftRule?.luckyPauseMaxMs)} 间隔`,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "createdAt",
|
||||||
|
label: "创建时间",
|
||||||
|
width: "minmax(170px, 0.8fr)",
|
||||||
|
render: (room) => formatMillis(room.createdAtMs),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function RoomRobotPage() {
|
||||||
|
const page = useRobotRoomsPage();
|
||||||
|
const items = page.data.items || [];
|
||||||
|
const total = page.data.total || 0;
|
||||||
|
const tableColumns = [
|
||||||
|
...columns.map((column) =>
|
||||||
|
column.key === "createdAt"
|
||||||
|
? {
|
||||||
|
...column,
|
||||||
|
filter: createOptionsColumnFilter({
|
||||||
|
options: statusOptions,
|
||||||
|
placeholder: "房间状态",
|
||||||
|
value: page.status,
|
||||||
|
onChange: page.changeStatus,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: column,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
label: "状态",
|
||||||
|
width: "minmax(112px, 0.5fr)",
|
||||||
|
render: (room) => <RobotRoomStatus page={page} room={room} />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={styles.root}>
|
||||||
|
<div className={styles.contentPanel}>
|
||||||
|
<div className={styles.toolbar}>
|
||||||
|
<section className={styles.filters}>
|
||||||
|
<AdminFilterResetButton disabled={page.status === "active"} onClick={page.resetFilters} />
|
||||||
|
</section>
|
||||||
|
{page.abilities.canRobotCreate ? (
|
||||||
|
<div className={styles.toolbarActions}>
|
||||||
|
<Button startIcon={<AddOutlined fontSize="small" />} variant="primary" onClick={page.openCreate}>
|
||||||
|
增加机器人房间
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||||
|
<div className={styles.listBlock}>
|
||||||
|
<DataTable
|
||||||
|
columns={tableColumns}
|
||||||
|
items={items}
|
||||||
|
minWidth="1320px"
|
||||||
|
rowKey={(room) => room.roomId}
|
||||||
|
pagination={
|
||||||
|
total > 0
|
||||||
|
? {
|
||||||
|
page: page.page,
|
||||||
|
pageSize: page.data.pageSize || 50,
|
||||||
|
total,
|
||||||
|
onPageChange: page.setPage,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DataState>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdminFormDialog
|
||||||
|
loading={page.loadingAction === "create"}
|
||||||
|
open={page.activeAction === "create"}
|
||||||
|
size="wide"
|
||||||
|
submitDisabled={!page.abilities.canRobotCreate || page.loadingAction === "create"}
|
||||||
|
title="增加机器人房间"
|
||||||
|
onClose={page.closeAction}
|
||||||
|
onSubmit={page.submitCreate}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
label="房间名称"
|
||||||
|
value={page.form.roomName}
|
||||||
|
onChange={(event) => page.setForm({ ...page.form, roomName: event.target.value })}
|
||||||
|
/>
|
||||||
|
<Autocomplete
|
||||||
|
getOptionLabel={robotOptionLabel}
|
||||||
|
isOptionEqualToValue={(option, value) => option.userId === value.userId}
|
||||||
|
loading={page.availableRobotsLoading}
|
||||||
|
noOptionsText="当前无数据"
|
||||||
|
options={page.availableRobots}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
error={Boolean(page.availableRobotsError)}
|
||||||
|
helperText={page.availableRobotsError}
|
||||||
|
label="房主机器人"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
renderOption={(props, option) => (
|
||||||
|
<li {...props} key={option.userId}>
|
||||||
|
<RobotIdentity user={option.user} fallbackId={option.userId} />
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
size="small"
|
||||||
|
value={page.selectedOwnerRobot}
|
||||||
|
onChange={(_, option) => page.selectOwnerRobot(option)}
|
||||||
|
/>
|
||||||
|
<Autocomplete
|
||||||
|
multiple
|
||||||
|
disableCloseOnSelect
|
||||||
|
getOptionLabel={robotOptionLabel}
|
||||||
|
isOptionEqualToValue={(option, value) => option.userId === value.userId}
|
||||||
|
loading={page.availableRobotsLoading}
|
||||||
|
noOptionsText="当前无数据"
|
||||||
|
options={page.availableRobots}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
error={Boolean(page.availableRobotsError)}
|
||||||
|
helperText={page.availableRobotsError}
|
||||||
|
label="候选机器人"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
renderOption={(props, option) => (
|
||||||
|
<li {...props} key={option.userId}>
|
||||||
|
<RobotIdentity user={option.user} fallbackId={option.userId} />
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
size="small"
|
||||||
|
value={page.selectedCandidateRobots}
|
||||||
|
onChange={(_, options) => page.selectCandidateRobots(options)}
|
||||||
|
/>
|
||||||
|
<div className={styles.formGrid}>
|
||||||
|
<TextField
|
||||||
|
label="最少进房人数"
|
||||||
|
required
|
||||||
|
type="number"
|
||||||
|
value={page.form.minRobotCount}
|
||||||
|
onChange={(event) => page.setForm({ ...page.form, minRobotCount: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="最多进房人数"
|
||||||
|
required
|
||||||
|
type="number"
|
||||||
|
value={page.form.maxRobotCount}
|
||||||
|
onChange={(event) => page.setForm({ ...page.form, maxRobotCount: event.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Autocomplete
|
||||||
|
multiple
|
||||||
|
disableCloseOnSelect
|
||||||
|
getOptionLabel={giftOptionLabel}
|
||||||
|
isOptionEqualToValue={(option, value) => giftId(option) === giftId(value)}
|
||||||
|
loading={page.giftOptionsLoading}
|
||||||
|
noOptionsText="当前无数据"
|
||||||
|
options={page.giftOptions}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
error={Boolean(page.giftOptionsError)}
|
||||||
|
helperText={page.giftOptionsError}
|
||||||
|
label="礼物合集"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
size="small"
|
||||||
|
value={page.selectedGiftOptions}
|
||||||
|
onChange={(_, options) => page.selectGiftIds("giftIds", options)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="普通礼物频次(毫秒)"
|
||||||
|
required
|
||||||
|
type="number"
|
||||||
|
value={page.form.normalGiftIntervalMs}
|
||||||
|
onChange={(event) => page.setForm({ ...page.form, normalGiftIntervalMs: event.target.value })}
|
||||||
|
/>
|
||||||
|
<Autocomplete
|
||||||
|
multiple
|
||||||
|
disableCloseOnSelect
|
||||||
|
getOptionLabel={giftOptionLabel}
|
||||||
|
isOptionEqualToValue={(option, value) => giftId(option) === giftId(value)}
|
||||||
|
loading={page.giftOptionsLoading}
|
||||||
|
noOptionsText="当前无数据"
|
||||||
|
options={page.giftOptions}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
error={Boolean(page.giftOptionsError)}
|
||||||
|
helperText={page.giftOptionsError}
|
||||||
|
label="幸运礼物合集"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
size="small"
|
||||||
|
value={page.selectedLuckyGiftOptions}
|
||||||
|
onChange={(_, options) => page.selectGiftIds("luckyGiftIds", options)}
|
||||||
|
/>
|
||||||
|
<div className={styles.formGrid}>
|
||||||
|
<TextField
|
||||||
|
label="最少幸运连击"
|
||||||
|
required
|
||||||
|
type="number"
|
||||||
|
value={page.form.luckyComboMin}
|
||||||
|
onChange={(event) => page.setForm({ ...page.form, luckyComboMin: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="最多幸运连击"
|
||||||
|
required
|
||||||
|
type="number"
|
||||||
|
value={page.form.luckyComboMax}
|
||||||
|
onChange={(event) => page.setForm({ ...page.form, luckyComboMax: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="最短幸运间隔(毫秒)"
|
||||||
|
required
|
||||||
|
type="number"
|
||||||
|
value={page.form.luckyPauseMinMs}
|
||||||
|
onChange={(event) => page.setForm({ ...page.form, luckyPauseMinMs: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="最长幸运间隔(毫秒)"
|
||||||
|
required
|
||||||
|
type="number"
|
||||||
|
value={page.form.luckyPauseMaxMs}
|
||||||
|
onChange={(event) => page.setForm({ ...page.form, luckyPauseMaxMs: event.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AdminFormDialog>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RobotRoomIdentity({ room }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.identity}>
|
||||||
|
<span className={styles.cover}>
|
||||||
|
{room.coverUrl ? <img src={room.coverUrl} alt="" /> : <BedroomParentOutlined fontSize="small" />}
|
||||||
|
</span>
|
||||||
|
<div className={styles.identityText}>
|
||||||
|
<div className={styles.name}>{room.title || "-"}</div>
|
||||||
|
<div className={styles.meta}>长ID {room.roomId || "-"}</div>
|
||||||
|
<div className={styles.meta}>短ID {room.roomShortId || "-"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RobotIdentity({ user = {}, fallbackId = "" }) {
|
||||||
|
return <AdminUserIdentity openInAppUserDetail rows={[user.displayUserId, user.userId || fallbackId]} user={user} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RobotRoomStatus({ page, room }) {
|
||||||
|
const checked = room.status === "active";
|
||||||
|
return (
|
||||||
|
<div className={styles.statusCell}>
|
||||||
|
<Tooltip arrow title={checked ? "停止" : "启动"}>
|
||||||
|
<span>
|
||||||
|
<AdminSwitch
|
||||||
|
checked={checked}
|
||||||
|
checkedLabel="运行"
|
||||||
|
disabled={!page.abilities.canRobotUpdate || page.loadingAction === `status-${room.roomId}`}
|
||||||
|
label={checked ? "停止机器人房间" : "启动机器人房间"}
|
||||||
|
uncheckedLabel="停止"
|
||||||
|
onChange={() => page.setRoomStatus(room)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RuleStack({ rows }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<span className={styles.meta} key={row}>
|
||||||
|
{row}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSeconds(ms) {
|
||||||
|
const seconds = Math.round(Number(ms || 0) / 1000);
|
||||||
|
return `${seconds.toLocaleString("zh-CN")}秒`;
|
||||||
|
}
|
||||||
@ -11,6 +11,9 @@ export function useRoomAbilities() {
|
|||||||
canPinCancel: can(PERMISSIONS.roomPinCancel),
|
canPinCancel: can(PERMISSIONS.roomPinCancel),
|
||||||
canPinCreate: can(PERMISSIONS.roomPinCreate),
|
canPinCreate: can(PERMISSIONS.roomPinCreate),
|
||||||
canPinView: can(PERMISSIONS.roomPinView),
|
canPinView: can(PERMISSIONS.roomPinView),
|
||||||
|
canRobotCreate: can(PERMISSIONS.roomRobotCreate),
|
||||||
|
canRobotUpdate: can(PERMISSIONS.roomRobotUpdate),
|
||||||
|
canRobotView: can(PERMISSIONS.roomRobotView),
|
||||||
canUpdate: can(PERMISSIONS.roomUpdate),
|
canUpdate: can(PERMISSIONS.roomUpdate),
|
||||||
canUpload: can(PERMISSIONS.uploadCreate),
|
canUpload: can(PERMISSIONS.uploadCreate),
|
||||||
canView: can(PERMISSIONS.roomView),
|
canView: can(PERMISSIONS.roomView),
|
||||||
|
|||||||
@ -54,6 +54,18 @@
|
|||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.formGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.formGrid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.search {
|
.search {
|
||||||
flex: 0 1 320px;
|
flex: 0 1 320px;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,4 +25,12 @@ export const roomRoutes = [
|
|||||||
path: "/rooms/config",
|
path: "/rooms/config",
|
||||||
permission: PERMISSIONS.roomConfigView,
|
permission: PERMISSIONS.roomConfigView,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "机器人房间",
|
||||||
|
loader: () => import("./pages/RoomRobotPage.jsx").then((module) => module.RoomRobotPage),
|
||||||
|
menuCode: MENU_CODES.roomRobots,
|
||||||
|
pageKey: "room-robots",
|
||||||
|
path: "/rooms/robots",
|
||||||
|
permission: PERMISSIONS.roomRobotView,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -70,7 +70,37 @@ export const roomPinCreateSchema = z
|
|||||||
path: ["expiresAtMs"],
|
path: ["expiresAtMs"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const robotRoomCreateSchema = z
|
||||||
|
.object({
|
||||||
|
candidateRobotUserIds: z.array(z.coerce.number().int().positive()).min(1, "请选择候选机器人"),
|
||||||
|
giftIds: z.array(z.string().trim().min(1)).min(1, "请选择普通礼物合集"),
|
||||||
|
luckyComboMax: z.coerce.number().int().min(1, "幸运礼物连击范围不正确"),
|
||||||
|
luckyComboMin: z.coerce.number().int().min(1, "幸运礼物连击范围不正确"),
|
||||||
|
luckyGiftIds: z.array(z.string().trim().min(1)).min(1, "请选择幸运礼物合集"),
|
||||||
|
luckyPauseMaxMs: z.coerce.number().int().min(1000, "幸运礼物间隔范围不正确"),
|
||||||
|
luckyPauseMinMs: z.coerce.number().int().min(1000, "幸运礼物间隔范围不正确"),
|
||||||
|
maxRobotCount: z.coerce.number().int().min(1, "机器人数量范围不正确"),
|
||||||
|
minRobotCount: z.coerce.number().int().min(1, "机器人数量范围不正确"),
|
||||||
|
normalGiftIntervalMs: z.coerce.number().int().min(1000, "普通礼物频次不正确"),
|
||||||
|
ownerRobotUserId: z.coerce.number().int().positive("请选择房主机器人"),
|
||||||
|
roomName: z.string().trim().max(128, "房间名称不能超过 128 个字符").optional().default(""),
|
||||||
|
visibleRegionId: z.coerce.number().int().min(0, "区域 ID 不正确").default(0),
|
||||||
|
})
|
||||||
|
.refine((value) => value.maxRobotCount >= value.minRobotCount, {
|
||||||
|
message: "机器人数量范围不正确",
|
||||||
|
path: ["maxRobotCount"],
|
||||||
|
})
|
||||||
|
.refine((value) => value.luckyComboMax >= value.luckyComboMin, {
|
||||||
|
message: "幸运礼物连击范围不正确",
|
||||||
|
path: ["luckyComboMax"],
|
||||||
|
})
|
||||||
|
.refine((value) => value.luckyPauseMaxMs >= value.luckyPauseMinMs, {
|
||||||
|
message: "幸运礼物间隔范围不正确",
|
||||||
|
path: ["luckyPauseMaxMs"],
|
||||||
|
});
|
||||||
|
|
||||||
export type RoomUpdateForm = z.infer<typeof roomUpdateSchema>;
|
export type RoomUpdateForm = z.infer<typeof roomUpdateSchema>;
|
||||||
export type RoomStatusForm = z.infer<typeof roomStatusSchema>;
|
export type RoomStatusForm = z.infer<typeof roomStatusSchema>;
|
||||||
export type RoomConfigForm = z.infer<typeof roomConfigSchema>;
|
export type RoomConfigForm = z.infer<typeof roomConfigSchema>;
|
||||||
export type RoomPinCreateForm = z.infer<typeof roomPinCreateSchema>;
|
export type RoomPinCreateForm = z.infer<typeof roomPinCreateSchema>;
|
||||||
|
export type RobotRoomCreateForm = z.infer<typeof robotRoomCreateSchema>;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
386
src/shared/api/generated/schema.d.ts
vendored
386
src/shared/api/generated/schema.d.ts
vendored
@ -100,6 +100,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/admin/activity/cp/config": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getCPConfig"];
|
||||||
|
put: operations["updateCPConfig"];
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/admin/activity/red-packets": {
|
"/admin/activity/red-packets": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -532,6 +548,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/admin/managers": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["listManagers"];
|
||||||
|
put?: never;
|
||||||
|
post: operations["createManager"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/admin/agencies": {
|
"/admin/agencies": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -1188,6 +1220,22 @@ export interface paths {
|
|||||||
patch: operations["updateDiceConfig"];
|
patch: operations["updateDiceConfig"];
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/admin/game/self-games/{game_id}/pool/adjust": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["adjustDicePool"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/admin/game/robots": {
|
"/admin/game/robots": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -1604,6 +1652,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/admin/operations/full-server-notices/fanout": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["createFullServerNoticeFanout"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/admin/operations/reports": {
|
"/admin/operations/reports": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -1716,6 +1780,22 @@ export interface paths {
|
|||||||
patch: operations["updateThirdPartyPaymentRate"];
|
patch: operations["updateThirdPartyPaymentRate"];
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/admin/payment/third-party-rates/sync": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["syncThirdPartyPaymentRates"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/admin/regions": {
|
"/admin/regions": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -2116,6 +2196,70 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/admin/rooms/robot-rooms": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["listRobotRooms"];
|
||||||
|
put?: never;
|
||||||
|
post: operations["createRobotRoom"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/admin/rooms/robot-rooms/available-robots": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["listAvailableRoomRobots"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/admin/rooms/robot-rooms/{room_id}/start": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["startRobotRoom"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/admin/rooms/robot-rooms/{room_id}/stop": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["stopRobotRoom"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/admin/users/level-config": {
|
"/admin/users/level-config": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -2910,6 +3054,12 @@ export interface components {
|
|||||||
pageSize: number;
|
pageSize: number;
|
||||||
total: number;
|
total: number;
|
||||||
};
|
};
|
||||||
|
ApiPageManager: {
|
||||||
|
items: components["schemas"]["Manager"][];
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
ApiPageBDProfile: {
|
ApiPageBDProfile: {
|
||||||
items: components["schemas"]["BDProfile"][];
|
items: components["schemas"]["BDProfile"][];
|
||||||
page: number;
|
page: number;
|
||||||
@ -2940,6 +3090,12 @@ export interface components {
|
|||||||
ApiResponseAgencyPage: components["schemas"]["Envelope"] & {
|
ApiResponseAgencyPage: components["schemas"]["Envelope"] & {
|
||||||
data?: components["schemas"]["ApiPageAgency"];
|
data?: components["schemas"]["ApiPageAgency"];
|
||||||
};
|
};
|
||||||
|
ApiResponseManagerPage: components["schemas"]["Envelope"] & {
|
||||||
|
data?: components["schemas"]["ApiPageManager"];
|
||||||
|
};
|
||||||
|
ApiResponseManager: components["schemas"]["Envelope"] & {
|
||||||
|
data?: components["schemas"]["Manager"];
|
||||||
|
};
|
||||||
ApiResponseBDProfilePage: components["schemas"]["Envelope"] & {
|
ApiResponseBDProfilePage: components["schemas"]["Envelope"] & {
|
||||||
data?: components["schemas"]["ApiPageBDProfile"];
|
data?: components["schemas"]["ApiPageBDProfile"];
|
||||||
};
|
};
|
||||||
@ -3011,6 +3167,25 @@ export interface components {
|
|||||||
status?: string;
|
status?: string;
|
||||||
updatedAtMs?: number;
|
updatedAtMs?: number;
|
||||||
};
|
};
|
||||||
|
Manager: {
|
||||||
|
avatar?: string;
|
||||||
|
bdLeaderCount?: number;
|
||||||
|
createdAtMs?: number;
|
||||||
|
contact?: string;
|
||||||
|
displayUserId?: string;
|
||||||
|
lastInvitedAtMs?: number;
|
||||||
|
regionId?: number;
|
||||||
|
regionName?: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
status?: "active" | "disabled";
|
||||||
|
updatedAtMs?: number;
|
||||||
|
userId: string;
|
||||||
|
username?: string;
|
||||||
|
};
|
||||||
|
CreateManagerPayload: {
|
||||||
|
contact?: string;
|
||||||
|
targetUserId: string;
|
||||||
|
};
|
||||||
BDProfile: {
|
BDProfile: {
|
||||||
createdAtMs?: number;
|
createdAtMs?: number;
|
||||||
createdByUserId?: number;
|
createdByUserId?: number;
|
||||||
@ -3088,6 +3263,24 @@ export interface components {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description OK */
|
/** @description OK */
|
||||||
|
ManagerPageResponse: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiResponseManagerPage"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description OK */
|
||||||
|
ManagerResponse: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiResponseManager"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description OK */
|
||||||
BDProfilePageResponse: {
|
BDProfilePageResponse: {
|
||||||
headers: {
|
headers: {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
@ -3436,6 +3629,30 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
getCPConfig: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
updateCPConfig: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
listRedPackets: {
|
listRedPackets: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -3901,6 +4118,40 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
listManagers: {
|
||||||
|
parameters: {
|
||||||
|
query?: {
|
||||||
|
page?: components["parameters"]["Page"];
|
||||||
|
page_size?: components["parameters"]["PageSize"];
|
||||||
|
keyword?: components["parameters"]["Keyword"];
|
||||||
|
status?: components["parameters"]["Status"];
|
||||||
|
region_id?: components["parameters"]["RegionId"];
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["ManagerPageResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
createManager: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["CreateManagerPayload"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
201: components["responses"]["ManagerResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
listAgencies: {
|
listAgencies: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
@ -4777,6 +5028,20 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
adjustDicePool: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
game_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
listDiceRobots: {
|
listDiceRobots: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -5234,6 +5499,51 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
createFullServerNoticeFanout: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
action_param?: string;
|
||||||
|
action_type?: string;
|
||||||
|
aggregate_id?: string;
|
||||||
|
aggregate_type?: string;
|
||||||
|
batch_size?: number;
|
||||||
|
body?: string;
|
||||||
|
command_id?: string;
|
||||||
|
country?: string;
|
||||||
|
/** Format: int64 */
|
||||||
|
expire_at_ms?: number;
|
||||||
|
icon_url?: string;
|
||||||
|
image_url?: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
message_type: "system" | "activity";
|
||||||
|
metadata_json?: string;
|
||||||
|
priority?: number;
|
||||||
|
producer_event_type?: string;
|
||||||
|
/** Format: int64 */
|
||||||
|
region_id?: number;
|
||||||
|
/** Format: int64 */
|
||||||
|
sent_at_ms?: number;
|
||||||
|
summary: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
target_scope: "all_active_users" | "single_user" | "user_ids" | "region" | "country";
|
||||||
|
/** Format: int64 */
|
||||||
|
target_user_id?: number;
|
||||||
|
title: string;
|
||||||
|
user_ids?: number[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
201: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
listReports: {
|
listReports: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -5350,6 +5660,18 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
syncThirdPartyPaymentRates: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
listRegions: {
|
listRegions: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -5820,6 +6142,70 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
listRobotRooms: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
createRobotRoom: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
listAvailableRoomRobots: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
startRobotRoom: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
room_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
stopRobotRoom: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
room_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
listLevelConfig: {
|
listLevelConfig: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@ -452,6 +452,7 @@ export interface ThirdPartyPaymentMethodDto {
|
|||||||
countryCode?: string;
|
countryCode?: string;
|
||||||
countryName?: string;
|
countryName?: string;
|
||||||
currencyCode?: string;
|
currencyCode?: string;
|
||||||
|
logoUrl?: string;
|
||||||
methodId: number;
|
methodId: number;
|
||||||
methodName?: string;
|
methodName?: string;
|
||||||
payType?: string;
|
payType?: string;
|
||||||
@ -472,6 +473,21 @@ export interface ThirdPartyPaymentChannelDto {
|
|||||||
status?: string;
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ThirdPartyPaymentRateSyncDto {
|
||||||
|
missingCurrencies?: string[];
|
||||||
|
rates?: Record<string, string>;
|
||||||
|
skippedCount?: number;
|
||||||
|
sourceNames?: string[];
|
||||||
|
sources?: Array<{
|
||||||
|
currencies?: string[];
|
||||||
|
error?: string;
|
||||||
|
name: string;
|
||||||
|
success: boolean;
|
||||||
|
url?: string;
|
||||||
|
}>;
|
||||||
|
updatedCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AppVersionDto {
|
export interface AppVersionDto {
|
||||||
appCode?: string;
|
appCode?: string;
|
||||||
buildNumber: number;
|
buildNumber: number;
|
||||||
@ -542,6 +558,26 @@ export interface AgencyDto {
|
|||||||
updatedAtMs?: number;
|
updatedAtMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ManagerDto {
|
||||||
|
avatar?: string;
|
||||||
|
bdLeaderCount?: number;
|
||||||
|
contact?: string;
|
||||||
|
createdAtMs?: number;
|
||||||
|
displayUserId?: string;
|
||||||
|
lastInvitedAtMs?: number;
|
||||||
|
regionId?: number;
|
||||||
|
regionName?: string;
|
||||||
|
status?: "active" | "disabled";
|
||||||
|
updatedAtMs?: number;
|
||||||
|
userId: string;
|
||||||
|
username?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateManagerPayload {
|
||||||
|
contact?: string;
|
||||||
|
targetUserId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface HostProfileDto {
|
export interface HostProfileDto {
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
createdAtMs?: number;
|
createdAtMs?: number;
|
||||||
@ -902,6 +938,61 @@ export interface RoomPinPayload {
|
|||||||
weight: number;
|
weight: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RobotGiftRuleDto {
|
||||||
|
giftIds?: string[];
|
||||||
|
luckyGiftIds?: string[];
|
||||||
|
luckyComboMax?: number;
|
||||||
|
luckyComboMin?: number;
|
||||||
|
luckyPauseMaxMs?: number;
|
||||||
|
luckyPauseMinMs?: number;
|
||||||
|
normalGiftIntervalMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RobotRoomDto {
|
||||||
|
appCode?: string;
|
||||||
|
coverUrl?: string;
|
||||||
|
createdAtMs?: number;
|
||||||
|
createdByAdminId?: number;
|
||||||
|
giftRule?: RobotGiftRuleDto;
|
||||||
|
owner?: RoomOwnerDto;
|
||||||
|
ownerRobotUserId?: string;
|
||||||
|
regionName?: string;
|
||||||
|
robots?: RoomOwnerDto[];
|
||||||
|
robotUserIds?: string[];
|
||||||
|
roomId: string;
|
||||||
|
roomShortId?: string;
|
||||||
|
status?: string;
|
||||||
|
title?: string;
|
||||||
|
updatedAtMs?: number;
|
||||||
|
visibleRegionId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AvailableRoomRobotDto {
|
||||||
|
lastUsedAtMs?: number;
|
||||||
|
roomScene?: string;
|
||||||
|
status?: string;
|
||||||
|
usedCount?: number;
|
||||||
|
user?: RoomOwnerDto;
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RobotRoomPayload {
|
||||||
|
candidateRobotUserIds: EntityId[];
|
||||||
|
giftIds: string[];
|
||||||
|
luckyComboMax: number;
|
||||||
|
luckyComboMin: number;
|
||||||
|
luckyGiftIds: string[];
|
||||||
|
luckyPauseMaxMs: number;
|
||||||
|
luckyPauseMinMs: number;
|
||||||
|
maxRobotCount: number;
|
||||||
|
minRobotCount: number;
|
||||||
|
normalGiftIntervalMs: number;
|
||||||
|
ownerRobotUserId: EntityId;
|
||||||
|
roomAvatar?: string;
|
||||||
|
roomName?: string;
|
||||||
|
visibleRegionId?: EntityId;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RoomOwnerDto {
|
export interface RoomOwnerDto {
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
displayUserId?: string;
|
displayUserId?: string;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user