Compare commits
4 Commits
743e4dc1c8
...
57231bc95a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57231bc95a | ||
|
|
0789e02c6a | ||
|
|
3b21bd1133 | ||
|
|
d876ff6985 |
@ -154,6 +154,30 @@
|
||||
"x-permissions": ["cp-config:update"]
|
||||
}
|
||||
},
|
||||
"/admin/activity/cp/relationships": {
|
||||
"get": {
|
||||
"operationId": "listCPRelationships",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "cp-config:view",
|
||||
"x-permissions": ["cp-config:view"]
|
||||
}
|
||||
},
|
||||
"/admin/activity/cp/applications": {
|
||||
"get": {
|
||||
"operationId": "listCPApplications",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "cp-config:view",
|
||||
"x-permissions": ["cp-config:view"]
|
||||
}
|
||||
},
|
||||
"/admin/activity/red-packets": {
|
||||
"get": {
|
||||
"operationId": "listRedPackets",
|
||||
@ -2857,6 +2881,18 @@
|
||||
"x-permissions": ["resource-shop:update"]
|
||||
}
|
||||
},
|
||||
"/admin/resource-shop/purchase-orders": {
|
||||
"get": {
|
||||
"operationId": "listResourceShopPurchaseOrders",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "resource-shop:view",
|
||||
"x-permissions": ["resource-shop:view"]
|
||||
}
|
||||
},
|
||||
"/admin/resource-shop/items/{shop_item_id}/disable": {
|
||||
"post": {
|
||||
"operationId": "disableResourceShopItem",
|
||||
@ -3320,7 +3356,7 @@
|
||||
"operationId": "getHumanRoomRobotConfig",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
"$ref": "#/components/responses/HumanRoomRobotConfigResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "room-robot:view",
|
||||
@ -3328,9 +3364,18 @@
|
||||
},
|
||||
"put": {
|
||||
"operationId": "updateHumanRoomRobotConfig",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HumanRoomRobotConfigInput"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
"$ref": "#/components/responses/HumanRoomRobotConfigResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "room-robot:update",
|
||||
@ -4657,6 +4702,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"HumanRoomRobotConfigResponse": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResponseHumanRoomRobotConfig"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"LogPageResponse": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
@ -5013,6 +5068,21 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"ApiResponseHumanRoomRobotConfig": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Envelope"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/HumanRoomRobotConfig"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ApiResponseLogPage": {
|
||||
"allOf": [
|
||||
{
|
||||
@ -5426,6 +5496,111 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"HumanRoomRobotCountryRule": {
|
||||
"type": "object",
|
||||
"required": ["countryCode", "maxRoomCount"],
|
||||
"properties": {
|
||||
"countryCode": {
|
||||
"type": "string"
|
||||
},
|
||||
"maxRoomCount": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"HumanRoomRobotConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"candidateRoomMaxOnline": {
|
||||
"type": "integer"
|
||||
},
|
||||
"countryLimitEnabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"countryRules": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/HumanRoomRobotCountryRule"
|
||||
}
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"giftIds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"luckyComboMax": {
|
||||
"type": "integer"
|
||||
},
|
||||
"luckyComboMin": {
|
||||
"type": "integer"
|
||||
},
|
||||
"luckyGiftIds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"luckyPauseMaxMs": {
|
||||
"type": "integer"
|
||||
},
|
||||
"luckyPauseMinMs": {
|
||||
"type": "integer"
|
||||
},
|
||||
"maxGiftSenders": {
|
||||
"type": "integer"
|
||||
},
|
||||
"normalGiftIntervalMaxMs": {
|
||||
"type": "integer"
|
||||
},
|
||||
"normalGiftIntervalMinMs": {
|
||||
"type": "integer"
|
||||
},
|
||||
"robotReplaceMaxMs": {
|
||||
"type": "integer"
|
||||
},
|
||||
"robotReplaceMinMs": {
|
||||
"type": "integer"
|
||||
},
|
||||
"robotStayMaxMs": {
|
||||
"type": "integer"
|
||||
},
|
||||
"robotStayMinMs": {
|
||||
"type": "integer"
|
||||
},
|
||||
"roomFullStopOnline": {
|
||||
"type": "integer"
|
||||
},
|
||||
"roomTargetMaxOnline": {
|
||||
"type": "integer"
|
||||
},
|
||||
"roomTargetMinOnline": {
|
||||
"type": "integer"
|
||||
},
|
||||
"superLuckyGiftIds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"updatedAtMs": {
|
||||
"type": "integer"
|
||||
},
|
||||
"updatedByAdminId": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"HumanRoomRobotConfigInput": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/HumanRoomRobotConfig"
|
||||
}
|
||||
]
|
||||
},
|
||||
"Log": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
|
||||
@ -88,6 +88,30 @@ test("renders cp config route with an authenticated session", async () => {
|
||||
],
|
||||
});
|
||||
}
|
||||
if (url.includes("/v1/admin/activity/cp/relationships")) {
|
||||
return jsonResponse({
|
||||
items: [
|
||||
{
|
||||
relationshipId: "cp_1001_1002",
|
||||
relationType: "cp",
|
||||
status: "active",
|
||||
userA: { displayUserId: "1001", userId: "1001", username: "A" },
|
||||
userB: { displayUserId: "1002", userId: "1002", username: "B" },
|
||||
intimacyValue: 12000,
|
||||
level: 2,
|
||||
durationDays: 3,
|
||||
gift: { giftId: "rose", giftName: "Rose", giftCount: 1, giftValue: 100 },
|
||||
formedAtMs: 1760000000000,
|
||||
},
|
||||
],
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
total: 1,
|
||||
});
|
||||
}
|
||||
if (url.includes("/v1/admin/activity/cp/applications")) {
|
||||
return jsonResponse({ items: [], page: 1, pageSize: 50, total: 0 });
|
||||
}
|
||||
return jsonResponse(null);
|
||||
});
|
||||
|
||||
|
||||
@ -1,7 +1,66 @@
|
||||
import { apiRequest } from "@/shared/api/request";
|
||||
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||
import type { ApiPage, PageQuery } from "@/shared/api/types";
|
||||
|
||||
export type CPRelationType = "cp" | "brother" | "sister";
|
||||
|
||||
export interface CPUserProfileDto {
|
||||
userId: string;
|
||||
displayUserId?: string;
|
||||
prettyDisplayUserId?: string;
|
||||
prettyId?: string;
|
||||
username?: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
export interface CPGiftSnapshotDto {
|
||||
giftId: string;
|
||||
giftName: string;
|
||||
giftIconUrl: string;
|
||||
giftAnimationUrl: string;
|
||||
giftCount: number;
|
||||
giftValue: number;
|
||||
billingReceiptId: string;
|
||||
}
|
||||
|
||||
export interface CPRelationshipDto {
|
||||
relationshipId: string;
|
||||
relationType: CPRelationType;
|
||||
status: string;
|
||||
userA: CPUserProfileDto;
|
||||
userB: CPUserProfileDto;
|
||||
intimacyValue: number;
|
||||
level: number;
|
||||
durationDays: number;
|
||||
gift?: CPGiftSnapshotDto;
|
||||
formedAtMs: number;
|
||||
updatedAtMs: number;
|
||||
endedAtMs: number;
|
||||
}
|
||||
|
||||
export interface CPApplicationDto {
|
||||
applicationId: string;
|
||||
relationType: CPRelationType;
|
||||
status: string;
|
||||
requester: CPUserProfileDto;
|
||||
target: CPUserProfileDto;
|
||||
gift: CPGiftSnapshotDto;
|
||||
roomId: string;
|
||||
roomRegionId: number;
|
||||
createdAtMs: number;
|
||||
updatedAtMs: number;
|
||||
expiresAtMs: number;
|
||||
decidedAtMs: number;
|
||||
}
|
||||
|
||||
export interface CPRelationshipPageDto extends ApiPage<CPRelationshipDto> {
|
||||
serverTimeMs?: number;
|
||||
}
|
||||
|
||||
export interface CPApplicationPageDto extends ApiPage<CPApplicationDto> {
|
||||
serverTimeMs?: number;
|
||||
}
|
||||
|
||||
export interface CPLevelDto {
|
||||
level: number;
|
||||
intimacyThreshold: number;
|
||||
@ -66,20 +125,55 @@ export interface CPConfigPayload {
|
||||
};
|
||||
}
|
||||
|
||||
const path = "/v1/admin/activity/cp/config";
|
||||
|
||||
export function getCPConfig(): Promise<CPConfigDto> {
|
||||
return apiRequest<RawConfig>(path).then(normalizeConfig);
|
||||
const endpoint = API_ENDPOINTS.getCPConfig;
|
||||
return apiRequest<RawConfig>(apiEndpointPath(API_OPERATIONS.getCPConfig), { method: endpoint.method }).then(
|
||||
normalizeConfig,
|
||||
);
|
||||
}
|
||||
|
||||
export function updateCPConfig(payload: CPConfigPayload): Promise<CPConfigDto> {
|
||||
return apiRequest<RawConfig, CPConfigPayload>(path, { body: payload, method: "PUT" }).then(normalizeConfig);
|
||||
const endpoint = API_ENDPOINTS.updateCPConfig;
|
||||
return apiRequest<RawConfig, CPConfigPayload>(apiEndpointPath(API_OPERATIONS.updateCPConfig), {
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
}).then(normalizeConfig);
|
||||
}
|
||||
|
||||
export function listCPRelationships(query: PageQuery = {}): Promise<CPRelationshipPageDto> {
|
||||
const endpoint = API_ENDPOINTS.listCPRelationships;
|
||||
return apiRequest<RawRelationshipPage>(apiEndpointPath(API_OPERATIONS.listCPRelationships), {
|
||||
method: endpoint.method,
|
||||
query,
|
||||
}).then(normalizeRelationshipPage);
|
||||
}
|
||||
|
||||
export function listCPApplications(query: PageQuery = {}): Promise<CPApplicationPageDto> {
|
||||
const endpoint = API_ENDPOINTS.listCPApplications;
|
||||
return apiRequest<RawApplicationPage>(apiEndpointPath(API_OPERATIONS.listCPApplications), {
|
||||
method: endpoint.method,
|
||||
query,
|
||||
}).then(normalizeApplicationPage);
|
||||
}
|
||||
|
||||
type RawConfig = CPConfigDto & Record<string, unknown>;
|
||||
type RawRelation = CPRelationConfigDto & Record<string, unknown>;
|
||||
type RawLevel = CPLevelDto & Record<string, unknown>;
|
||||
type RawWeeklyRank = CPWeeklyRankDto & Record<string, unknown>;
|
||||
type RawRelationship = CPRelationshipDto & Record<string, unknown>;
|
||||
type RawApplication = CPApplicationDto & Record<string, unknown>;
|
||||
type RawUser = CPUserProfileDto & Record<string, unknown>;
|
||||
type RawGift = CPGiftSnapshotDto & Record<string, unknown>;
|
||||
type RawRelationshipPage = ApiPage<RawRelationship> & {
|
||||
relationships?: RawRelationship[];
|
||||
page_size?: number | string;
|
||||
server_time_ms?: number | string;
|
||||
} & Record<string, unknown>;
|
||||
type RawApplicationPage = ApiPage<RawApplication> & {
|
||||
applications?: RawApplication[];
|
||||
page_size?: number | string;
|
||||
server_time_ms?: number | string;
|
||||
} & Record<string, unknown>;
|
||||
|
||||
function normalizeConfig(raw: RawConfig): CPConfigDto {
|
||||
const item = asRecord(raw) as RawConfig;
|
||||
@ -137,6 +231,96 @@ function normalizeLevel(raw: unknown): CPLevelDto {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRelationshipPage(page: RawRelationshipPage): CPRelationshipPageDto {
|
||||
const response = asRecord(page) as RawRelationshipPage;
|
||||
const items = arrayValue(response.items ?? response.relationships).map(normalizeRelationship);
|
||||
return {
|
||||
items,
|
||||
page: numberValue(response.page) || 1,
|
||||
pageSize: numberValue(response.pageSize ?? response.page_size) || items.length || 50,
|
||||
total: numberValue(response.total),
|
||||
serverTimeMs: numberValue(response.serverTimeMs ?? response.server_time_ms),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeApplicationPage(page: RawApplicationPage): CPApplicationPageDto {
|
||||
const response = asRecord(page) as RawApplicationPage;
|
||||
const items = arrayValue(response.items ?? response.applications).map(normalizeApplication);
|
||||
return {
|
||||
items,
|
||||
page: numberValue(response.page) || 1,
|
||||
pageSize: numberValue(response.pageSize ?? response.page_size) || items.length || 50,
|
||||
total: numberValue(response.total),
|
||||
serverTimeMs: numberValue(response.serverTimeMs ?? response.server_time_ms),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRelationship(raw: unknown): CPRelationshipDto {
|
||||
const item = asRecord(raw) as RawRelationship;
|
||||
const userA = normalizeUser(item.userA ?? item.user_a ?? item.me ?? item.requester);
|
||||
const userB = normalizeUser(item.userB ?? item.user_b ?? item.partner ?? item.target);
|
||||
const gift = normalizeGift(item.gift ?? item.sourceGift ?? item.source_gift ?? item.applicationGift ?? item.application_gift);
|
||||
return {
|
||||
relationshipId: stringValue(item.relationshipId ?? item.relationship_id),
|
||||
relationType: normalizeRelationType(item.relationType ?? item.relation_type),
|
||||
status: stringValue(item.status) || "active",
|
||||
userA,
|
||||
userB,
|
||||
intimacyValue: numberValue(item.intimacyValue ?? item.intimacy_value ?? item.experienceValue ?? item.experience_value),
|
||||
level: numberValue(item.level ?? item.levelNo ?? item.level_no),
|
||||
durationDays: numberValue(item.durationDays ?? item.duration_days ?? item.days),
|
||||
gift: gift.giftId || gift.giftName || gift.giftValue ? gift : undefined,
|
||||
formedAtMs: numberValue(item.formedAtMs ?? item.formed_at_ms ?? item.createdAtMs ?? item.created_at_ms),
|
||||
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||
endedAtMs: numberValue(item.endedAtMs ?? item.ended_at_ms),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeApplication(raw: unknown): CPApplicationDto {
|
||||
const item = asRecord(raw) as RawApplication;
|
||||
return {
|
||||
applicationId: stringValue(item.applicationId ?? item.application_id),
|
||||
relationType: normalizeRelationType(item.relationType ?? item.relation_type),
|
||||
status: stringValue(item.status) || "pending",
|
||||
requester: normalizeUser(item.requester ?? item.requesterUser ?? item.requester_user ?? item.userA ?? item.user_a),
|
||||
target: normalizeUser(item.target ?? item.targetUser ?? item.target_user ?? item.userB ?? item.user_b),
|
||||
gift: normalizeGift(item.gift ?? item.sourceGift ?? item.source_gift),
|
||||
roomId: stringValue(item.roomId ?? item.room_id),
|
||||
roomRegionId: numberValue(item.roomRegionId ?? item.room_region_id),
|
||||
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
||||
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||
expiresAtMs: numberValue(item.expiresAtMs ?? item.expires_at_ms),
|
||||
decidedAtMs: numberValue(item.decidedAtMs ?? item.decided_at_ms),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeUser(raw: unknown): CPUserProfileDto {
|
||||
const item = asRecord(raw) as RawUser;
|
||||
return {
|
||||
userId: stringValue(item.userId ?? item.user_id),
|
||||
displayUserId: optionalString(item.displayUserId ?? item.display_user_id),
|
||||
prettyDisplayUserId: optionalString(item.prettyDisplayUserId ?? item.pretty_display_user_id),
|
||||
prettyId: optionalString(item.prettyId ?? item.pretty_id),
|
||||
username: optionalString(item.username ?? item.name),
|
||||
avatar: optionalString(item.avatar),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeGift(raw: unknown): CPGiftSnapshotDto {
|
||||
const item = asRecord(raw) as RawGift;
|
||||
return {
|
||||
giftId: stringValue(item.giftId ?? item.gift_id),
|
||||
giftName: stringValue(item.giftName ?? item.gift_name ?? item.name),
|
||||
giftIconUrl: stringValue(item.giftIconUrl ?? item.gift_icon_url ?? item.iconUrl ?? item.icon_url),
|
||||
giftAnimationUrl: stringValue(
|
||||
item.giftAnimationUrl ?? item.gift_animation_url ?? item.animationUrl ?? item.animation_url,
|
||||
),
|
||||
giftCount: numberValue(item.giftCount ?? item.gift_count ?? item.count),
|
||||
giftValue: numberValue(item.giftValue ?? item.gift_value ?? item.value),
|
||||
billingReceiptId: stringValue(item.billingReceiptId ?? item.billing_receipt_id),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRelationType(value: unknown): CPRelationType {
|
||||
const relationType = stringValue(value).toLowerCase();
|
||||
if (relationType === "brother" || relationType === "sister") {
|
||||
@ -161,6 +345,11 @@ function stringValue(value: unknown) {
|
||||
return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
|
||||
}
|
||||
|
||||
function optionalString(value: unknown) {
|
||||
const text = stringValue(value).trim();
|
||||
return text || undefined;
|
||||
}
|
||||
|
||||
function numberValue(value: unknown) {
|
||||
const number = Number(value || 0);
|
||||
return Number.isFinite(number) ? number : 0;
|
||||
|
||||
@ -38,6 +38,77 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tabsBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
padding: 0 2px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.tab {
|
||||
min-height: 40px !important;
|
||||
padding-right: 18px !important;
|
||||
padding-left: 18px !important;
|
||||
color: var(--text-secondary) !important;
|
||||
font-weight: 700 !important;
|
||||
text-transform: none !important;
|
||||
}
|
||||
|
||||
.giftCell {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.giftIcon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex: 0 0 auto;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--bg-card-strong);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.amount {
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 760;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.relationBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-card-strong);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.relationBadge_cp {
|
||||
color: #be123c;
|
||||
background: #ffe4e6;
|
||||
}
|
||||
|
||||
.relationBadge_brother {
|
||||
color: #1d4ed8;
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
.relationBadge_sister {
|
||||
color: #a21caf;
|
||||
background: #fae8ff;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -70,6 +141,16 @@
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.statusPending {
|
||||
color: #92400e;
|
||||
background: #fef3c7;
|
||||
}
|
||||
|
||||
.statusDanger {
|
||||
color: #b91c1c;
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
.levelPreview {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@ -95,31 +176,44 @@
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.configDrawer {
|
||||
box-sizing: border-box;
|
||||
width: min(980px, calc(100vw - 48px));
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
overflow: hidden;
|
||||
padding: var(--space-5);
|
||||
.configDialogPaper {
|
||||
width: min(1040px, calc(100vw - 32px)) !important;
|
||||
max-width: min(1040px, calc(100vw - 32px)) !important;
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--bg-card);
|
||||
box-shadow: var(--shadow-dialog);
|
||||
}
|
||||
|
||||
.configDrawerHeader {
|
||||
.configDialogForm {
|
||||
display: grid;
|
||||
max-height: calc(100vh - 56px);
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.configDialogTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
padding-bottom: var(--space-3);
|
||||
padding: var(--space-5) var(--space-5) var(--space-3) !important;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.configDrawerHeader h2 {
|
||||
.configDialogTitle h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.configDialogTitle span {
|
||||
display: inline-flex;
|
||||
margin-top: 4px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.configDrawerStats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@ -140,14 +234,18 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.configDrawerBody {
|
||||
.configDialogContent {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding-right: 2px;
|
||||
padding: var(--space-5) !important;
|
||||
}
|
||||
|
||||
.configDrawerActions {
|
||||
padding-top: var(--space-3);
|
||||
.configDialogActions {
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-5) var(--space-5) !important;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
@ -238,14 +336,28 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.configDrawer {
|
||||
width: 100vw;
|
||||
padding: var(--space-4);
|
||||
.tabsBar {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.configDrawerHeader {
|
||||
.configDialogPaper {
|
||||
width: calc(100vw - 24px) !important;
|
||||
max-width: calc(100vw - 24px) !important;
|
||||
margin: 12px !important;
|
||||
}
|
||||
|
||||
.configDialogTitle {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
padding: var(--space-4) var(--space-4) var(--space-3) !important;
|
||||
}
|
||||
|
||||
.configDialogContent {
|
||||
padding: var(--space-4) !important;
|
||||
}
|
||||
|
||||
.configDialogActions {
|
||||
padding: var(--space-3) var(--space-4) var(--space-4) !important;
|
||||
}
|
||||
|
||||
.levelEditorList,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { getCPConfig, updateCPConfig } from "@/features/cp-config/api";
|
||||
import { getCPConfig, listCPApplications, listCPRelationships, updateCPConfig } from "@/features/cp-config/api";
|
||||
import { useCPConfigAbilities } from "@/features/cp-config/permissions.js";
|
||||
import { listResourceGroups } from "@/features/resources/api";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
@ -14,10 +14,20 @@ export const cpWeeklyRankRanks = [1, 2, 3];
|
||||
|
||||
const defaultExpireHours = 24;
|
||||
const defaultThresholds = [0, 10000, 30000, 60000, 100000];
|
||||
const listPageSize = 50;
|
||||
const emptyListPage = () => ({ items: [], page: 1, pageSize: listPageSize, serverTimeMs: 0, total: 0 });
|
||||
|
||||
export function useCPConfigPage() {
|
||||
const abilities = useCPConfigAbilities();
|
||||
const { showToast } = useToast();
|
||||
const [activeTab, setActiveTabValue] = useState("relationships");
|
||||
const [relationships, setRelationships] = useState(emptyListPage);
|
||||
const [applications, setApplications] = useState(emptyListPage);
|
||||
const [relationshipsLoading, setRelationshipsLoading] = useState(false);
|
||||
const [applicationsLoading, setApplicationsLoading] = useState(false);
|
||||
const [relationshipsError, setRelationshipsError] = useState("");
|
||||
const [applicationsError, setApplicationsError] = useState("");
|
||||
const [applicationsLoaded, setApplicationsLoaded] = useState(false);
|
||||
const [config, setConfig] = useState({ relations: defaultRelations(), weeklyRank: defaultWeeklyRank() });
|
||||
const [form, setForm] = useState({
|
||||
relations: defaultRelations().map(relationToForm),
|
||||
@ -33,7 +43,7 @@ export function useCPConfigPage() {
|
||||
[config.relations],
|
||||
);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const reloadConfig = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [configResult, groupResult] = await Promise.allSettled([
|
||||
@ -62,8 +72,98 @@ export function useCPConfigPage() {
|
||||
}, [showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
void reloadConfig();
|
||||
}, [reloadConfig]);
|
||||
|
||||
const loadRelationships = useCallback(
|
||||
async (page = 1) => {
|
||||
const normalizedPage = Number(page || 1);
|
||||
if (normalizedPage <= 1) {
|
||||
setRelationshipsLoading(true);
|
||||
setRelationshipsError("");
|
||||
}
|
||||
try {
|
||||
const result = await listCPRelationships({ page: normalizedPage, page_size: listPageSize });
|
||||
setRelationships((current) =>
|
||||
normalizedPage > 1
|
||||
? { ...result, items: [...(current.items || []), ...(result.items || [])] }
|
||||
: result,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err.message || "加载用户关系列表失败";
|
||||
if (normalizedPage <= 1) {
|
||||
setRelationshipsError(message);
|
||||
} else {
|
||||
showToast(message, "error");
|
||||
}
|
||||
} finally {
|
||||
if (normalizedPage <= 1) {
|
||||
setRelationshipsLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[showToast],
|
||||
);
|
||||
|
||||
const loadApplications = useCallback(
|
||||
async (page = 1) => {
|
||||
const normalizedPage = Number(page || 1);
|
||||
if (normalizedPage <= 1) {
|
||||
setApplicationsLoading(true);
|
||||
setApplicationsError("");
|
||||
}
|
||||
try {
|
||||
const result = await listCPApplications({ page: normalizedPage, page_size: listPageSize });
|
||||
setApplications((current) =>
|
||||
normalizedPage > 1
|
||||
? { ...result, items: [...(current.items || []), ...(result.items || [])] }
|
||||
: result,
|
||||
);
|
||||
setApplicationsLoaded(true);
|
||||
} catch (err) {
|
||||
const message = err.message || "加载 CP 申请列表失败";
|
||||
if (normalizedPage <= 1) {
|
||||
setApplicationsError(message);
|
||||
} else {
|
||||
showToast(message, "error");
|
||||
}
|
||||
} finally {
|
||||
if (normalizedPage <= 1) {
|
||||
setApplicationsLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[showToast],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRelationships(1);
|
||||
}, [loadRelationships]);
|
||||
|
||||
const setActiveTab = useCallback(
|
||||
(tab) => {
|
||||
setActiveTabValue(tab);
|
||||
if (tab === "applications" && !applicationsLoaded) {
|
||||
void loadApplications(1);
|
||||
}
|
||||
},
|
||||
[applicationsLoaded, loadApplications],
|
||||
);
|
||||
|
||||
const reloadActiveTab = useCallback(() => {
|
||||
if (activeTab === "applications") {
|
||||
void loadApplications(1);
|
||||
return;
|
||||
}
|
||||
void loadRelationships(1);
|
||||
}, [activeTab, loadApplications, loadRelationships]);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
await Promise.allSettled([
|
||||
reloadConfig(),
|
||||
activeTab === "applications" ? loadApplications(1) : loadRelationships(1),
|
||||
]);
|
||||
}, [activeTab, loadApplications, loadRelationships, reloadConfig]);
|
||||
|
||||
const openDrawer = () => {
|
||||
setForm({
|
||||
@ -158,16 +258,27 @@ export function useCPConfigPage() {
|
||||
|
||||
return {
|
||||
abilities,
|
||||
activeTab,
|
||||
activeRelationCount,
|
||||
applications,
|
||||
applicationsError,
|
||||
applicationsLoading,
|
||||
closeDrawer,
|
||||
config,
|
||||
drawerOpen,
|
||||
form,
|
||||
loading,
|
||||
loadApplications,
|
||||
loadRelationships,
|
||||
openDrawer,
|
||||
reload,
|
||||
reloadActiveTab,
|
||||
relationships,
|
||||
relationshipsError,
|
||||
relationshipsLoading,
|
||||
resourceGroups,
|
||||
saving,
|
||||
setActiveTab,
|
||||
submit,
|
||||
updateLevel,
|
||||
updateRelation,
|
||||
|
||||
@ -1,23 +1,44 @@
|
||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||
import SaveOutlined from "@mui/icons-material/SaveOutlined";
|
||||
import Drawer from "@mui/material/Drawer";
|
||||
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import Tab from "@mui/material/Tab";
|
||||
import Tabs from "@mui/material/Tabs";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { useMemo } from "react";
|
||||
import { cpRelationLabels, cpWeeklyRankRanks, useCPConfigPage } from "@/features/cp-config/hooks/useCPConfigPage.js";
|
||||
import styles from "@/features/cp-config/cp-config.module.css";
|
||||
import { AdminListBody, AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { ResourceGroupSelectField } from "@/shared/ui/ResourceGroupSelectDrawer.jsx";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import { TimeText } from "@/shared/ui/TimeText.jsx";
|
||||
|
||||
const tabLabels = {
|
||||
relationships: "用户关系列表",
|
||||
applications: "CP申请列表",
|
||||
};
|
||||
|
||||
export function CPConfigPage() {
|
||||
const page = useCPConfigPage();
|
||||
const relations = page.config.relations || [];
|
||||
const columns = useMemo(() => cpConfigColumns(), []);
|
||||
const relationshipColumns = useMemo(
|
||||
() => cpRelationshipColumns(page.relationships.serverTimeMs),
|
||||
[page.relationships.serverTimeMs],
|
||||
);
|
||||
const applicationColumns = useMemo(() => cpApplicationColumns(), []);
|
||||
const isApplicationsTab = page.activeTab === "applications";
|
||||
const activePage = isApplicationsTab ? page.applications : page.relationships;
|
||||
const activeLoading = isApplicationsTab ? page.applicationsLoading : page.relationshipsLoading;
|
||||
const activeError = isApplicationsTab ? page.applicationsError : page.relationshipsError;
|
||||
|
||||
return (
|
||||
<AdminListPage>
|
||||
@ -34,52 +55,76 @@ export function CPConfigPage() {
|
||||
</div>
|
||||
<div className={styles.summaryActions}>
|
||||
<Button
|
||||
disabled={page.loading}
|
||||
disabled={page.loading || activeLoading}
|
||||
startIcon={<RefreshOutlined fontSize="small" />}
|
||||
onClick={page.reload}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
{page.abilities.canUpdate ? (
|
||||
<Button
|
||||
disabled={page.loading}
|
||||
startIcon={<EditOutlined fontSize="small" />}
|
||||
variant="primary"
|
||||
onClick={page.openDrawer}
|
||||
>
|
||||
编辑配置
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
disabled={page.loading}
|
||||
startIcon={page.abilities.canUpdate ? <EditOutlined fontSize="small" /> : <SettingsOutlined fontSize="small" />}
|
||||
variant="primary"
|
||||
onClick={page.openDrawer}
|
||||
>
|
||||
配置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DataState loading={page.loading} onRetry={page.reload}>
|
||||
<div className={styles.tabsBar}>
|
||||
<Tabs value={page.activeTab} onChange={(_, value) => page.setActiveTab(value)}>
|
||||
<Tab className={styles.tab} label={tabLabels.relationships} value="relationships" />
|
||||
<Tab className={styles.tab} label={tabLabels.applications} value="applications" />
|
||||
</Tabs>
|
||||
</div>
|
||||
<DataState error={activeError} loading={activeLoading} onRetry={page.reloadActiveTab}>
|
||||
<AdminListBody>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
items={relations}
|
||||
minWidth="1120px"
|
||||
rowKey={(item) => item.relationType}
|
||||
columns={isApplicationsTab ? applicationColumns : relationshipColumns}
|
||||
emptyLabel="当前无数据"
|
||||
items={activePage.items}
|
||||
minWidth={isApplicationsTab ? "1280px" : "1440px"}
|
||||
pagination={{
|
||||
page: activePage.page,
|
||||
pageSize: activePage.pageSize,
|
||||
total: activePage.total,
|
||||
onPageChange: isApplicationsTab ? page.loadApplications : page.loadRelationships,
|
||||
}}
|
||||
rowKey={(item) =>
|
||||
isApplicationsTab ? item.applicationId : item.relationshipId || `${item.userA?.userId}-${item.userB?.userId}`
|
||||
}
|
||||
total={activePage.total}
|
||||
/>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
<CPConfigDrawer page={page} />
|
||||
<CPConfigDialog page={page} />
|
||||
</AdminListPage>
|
||||
);
|
||||
}
|
||||
|
||||
function CPConfigDrawer({ page }) {
|
||||
const disabled = !page.abilities.canUpdate || page.saving || page.loading;
|
||||
function CPConfigDialog({ page }) {
|
||||
const canUpdate = page.abilities.canUpdate;
|
||||
const disabled = !canUpdate || page.saving || page.loading;
|
||||
return (
|
||||
<Drawer anchor="right" open={page.drawerOpen} onClose={page.saving ? undefined : page.closeDrawer}>
|
||||
<form className={["form-drawer", styles.configDrawer].join(" ")} onSubmit={page.submit}>
|
||||
<div className={styles.configDrawerHeader}>
|
||||
<h2>CP配置</h2>
|
||||
<Dialog
|
||||
className={styles.configDialog}
|
||||
maxWidth={false}
|
||||
open={page.drawerOpen}
|
||||
slotProps={{ paper: { className: styles.configDialogPaper } }}
|
||||
onClose={page.saving ? undefined : page.closeDrawer}
|
||||
>
|
||||
<form className={styles.configDialogForm} onSubmit={page.submit}>
|
||||
<DialogTitle className={styles.configDialogTitle}>
|
||||
<div>
|
||||
<h2>CP配置</h2>
|
||||
<span>关系等级、解除费用和 CP 周榜奖励</span>
|
||||
</div>
|
||||
<div className={styles.configDrawerStats}>
|
||||
<span>{(page.form.relations || []).length} 个关系</span>
|
||||
<span>{page.activeRelationCount} 个启用</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.configDrawerBody}>
|
||||
</DialogTitle>
|
||||
<DialogContent className={styles.configDialogContent}>
|
||||
<section className="form-drawer__section">
|
||||
<div className="form-drawer__section-title">关系与等级</div>
|
||||
<div className={styles.relationEditorList}>
|
||||
@ -105,22 +150,24 @@ function CPConfigDrawer({ page }) {
|
||||
updateWeeklyRankReward={page.updateWeeklyRankReward}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
<div className={["form-drawer__actions", styles.configDrawerActions].join(" ")}>
|
||||
</DialogContent>
|
||||
<DialogActions className={styles.configDialogActions}>
|
||||
<Button disabled={page.saving} type="button" onClick={page.closeDrawer}>
|
||||
取消
|
||||
{canUpdate ? "取消" : "关闭"}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={disabled}
|
||||
startIcon={<SaveOutlined fontSize="small" />}
|
||||
type="submit"
|
||||
variant="primary"
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
{canUpdate ? (
|
||||
<Button
|
||||
disabled={disabled}
|
||||
startIcon={page.saving ? <CircularProgress color="inherit" size={16} /> : <SaveOutlined fontSize="small" />}
|
||||
type="submit"
|
||||
variant="primary"
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
) : null}
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Drawer>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@ -309,42 +356,55 @@ function LevelEditor({ disabled, level, relationType, resourceGroups, updateLeve
|
||||
);
|
||||
}
|
||||
|
||||
function cpConfigColumns() {
|
||||
function cpRelationshipColumns(serverTimeMs) {
|
||||
return [
|
||||
{
|
||||
key: "relation",
|
||||
label: "关系",
|
||||
width: "minmax(140px, 0.45fr)",
|
||||
render: (item) => (
|
||||
<div className={styles.stack}>
|
||||
<span className={styles.name}>{item.displayName || cpRelationLabels[item.relationType]}</span>
|
||||
<span className={styles.meta}>{item.relationType}</span>
|
||||
</div>
|
||||
),
|
||||
key: "userA",
|
||||
label: "用户A",
|
||||
width: "minmax(210px, 0.9fr)",
|
||||
render: (item) => <CPUserCell user={item.userA} />,
|
||||
},
|
||||
{
|
||||
key: "maxCount",
|
||||
label: "数量上限",
|
||||
width: "minmax(120px, 0.45fr)",
|
||||
render: (item) => (item.relationType === "cp" ? "1(固定)" : formatNumber(item.maxCountPerUser)),
|
||||
key: "userB",
|
||||
label: "用户B",
|
||||
width: "minmax(210px, 0.9fr)",
|
||||
render: (item) => <CPUserCell user={item.userB} />,
|
||||
},
|
||||
{
|
||||
key: "expire",
|
||||
label: "申请过期",
|
||||
width: "minmax(130px, 0.45fr)",
|
||||
render: (item) => `${formatNumber(item.applicationExpireHours)} 小时`,
|
||||
key: "relationType",
|
||||
label: "类型",
|
||||
width: "minmax(110px, 0.4fr)",
|
||||
render: (item) => <RelationTypeBadge relationType={item.relationType} />,
|
||||
},
|
||||
{
|
||||
key: "breakupCost",
|
||||
label: "解除费用",
|
||||
width: "minmax(130px, 0.45fr)",
|
||||
render: (item) => `${formatNumber(item.breakupCostCoins)} 金币`,
|
||||
key: "days",
|
||||
label: "天数",
|
||||
width: "minmax(90px, 0.35fr)",
|
||||
render: (item) => formatRelationDays(item, serverTimeMs),
|
||||
},
|
||||
{
|
||||
key: "levels",
|
||||
label: "等级亲密值",
|
||||
width: "minmax(360px, 1.2fr)",
|
||||
render: (item) => <LevelPreview levels={item.levels || []} />,
|
||||
key: "level",
|
||||
label: "等级",
|
||||
width: "minmax(90px, 0.35fr)",
|
||||
render: (item) => (item.level ? `${formatNumber(item.level)}级` : "-"),
|
||||
},
|
||||
{
|
||||
key: "intimacy",
|
||||
label: "经验值",
|
||||
width: "minmax(130px, 0.5fr)",
|
||||
render: (item) => formatNumber(item.intimacyValue),
|
||||
},
|
||||
{
|
||||
key: "gift",
|
||||
label: "关系礼物/价值",
|
||||
width: "minmax(240px, 0.95fr)",
|
||||
render: (item) => <GiftCell gift={item.gift} />,
|
||||
},
|
||||
{
|
||||
key: "formedAt",
|
||||
label: "组成时间",
|
||||
width: "minmax(170px, 0.7fr)",
|
||||
render: (item) => <NullableTime value={item.formedAtMs} />,
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
@ -352,36 +412,102 @@ function cpConfigColumns() {
|
||||
width: "minmax(110px, 0.4fr)",
|
||||
render: (item) => <StatusBadge status={item.status} />,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function cpApplicationColumns() {
|
||||
return [
|
||||
{
|
||||
key: "updatedAt",
|
||||
label: "更新时间",
|
||||
width: "minmax(180px, 0.6fr)",
|
||||
render: (item) => (item.updatedAtMs ? formatMillis(item.updatedAtMs) : "-"),
|
||||
key: "requester",
|
||||
label: "申请方",
|
||||
width: "minmax(210px, 0.9fr)",
|
||||
render: (item) => <CPUserCell user={item.requester} />,
|
||||
},
|
||||
{
|
||||
key: "target",
|
||||
label: "接收方",
|
||||
width: "minmax(210px, 0.9fr)",
|
||||
render: (item) => <CPUserCell user={item.target} />,
|
||||
},
|
||||
{
|
||||
key: "relationType",
|
||||
label: "申请类型",
|
||||
width: "minmax(120px, 0.45fr)",
|
||||
render: (item) => <RelationTypeBadge relationType={item.relationType} />,
|
||||
},
|
||||
{
|
||||
key: "gift",
|
||||
label: "申请礼物/价值",
|
||||
width: "minmax(240px, 1fr)",
|
||||
render: (item) => <GiftCell gift={item.gift} />,
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
width: "minmax(110px, 0.45fr)",
|
||||
render: (item) => <StatusBadge status={item.status} />,
|
||||
},
|
||||
{
|
||||
key: "createdAt",
|
||||
label: "申请时间",
|
||||
width: "minmax(170px, 0.7fr)",
|
||||
render: (item) => <NullableTime value={item.createdAtMs} />,
|
||||
},
|
||||
{
|
||||
key: "decidedAt",
|
||||
label: "处理时间",
|
||||
width: "minmax(170px, 0.7fr)",
|
||||
render: (item) => <NullableTime value={item.decidedAtMs} />,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function LevelPreview({ levels }) {
|
||||
function CPUserCell({ user }) {
|
||||
if (!user?.userId && !user?.displayUserId && !user?.username) {
|
||||
return "-";
|
||||
}
|
||||
return <AdminUserIdentity openInAppUserDetail rows={[user.displayUserId, user.userId]} user={user} />;
|
||||
}
|
||||
|
||||
function GiftCell({ gift }) {
|
||||
if (!gift?.giftId && !gift?.giftName && !gift?.giftValue) {
|
||||
return "-";
|
||||
}
|
||||
const name = gift.giftName || gift.giftId || "-";
|
||||
const meta = [gift.giftCount ? `x${formatNumber(gift.giftCount)}` : "", gift.giftId].filter(Boolean).join(" · ");
|
||||
return (
|
||||
<div className={styles.levelPreview}>
|
||||
{levels.map((level) => (
|
||||
<span className={styles.levelPill} key={level.level}>
|
||||
{level.level}级 {formatNumber(level.intimacyThreshold)}
|
||||
</span>
|
||||
))}
|
||||
<div className={styles.giftCell}>
|
||||
{gift.giftIconUrl ? <img alt="" className={styles.giftIcon} src={gift.giftIconUrl} /> : null}
|
||||
<div className={styles.stack}>
|
||||
<span className={styles.name}>{name}</span>
|
||||
<span className={styles.meta}>{meta || "礼物"}</span>
|
||||
<span className={styles.amount}>{formatNumber(gift.giftValue)} 价值</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }) {
|
||||
const active = status === "active";
|
||||
function RelationTypeBadge({ relationType }) {
|
||||
return (
|
||||
<span className={[styles.statusBadge, active ? styles.statusActive : styles.statusDisabled].join(" ")}>
|
||||
{active ? "启用" : "停用"}
|
||||
<span className={[styles.relationBadge, styles[`relationBadge_${relationType}`]].filter(Boolean).join(" ")}>
|
||||
{cpRelationLabels[relationType] || relationType || "-"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }) {
|
||||
const normalized = String(status || "").toLowerCase();
|
||||
return (
|
||||
<span className={[styles.statusBadge, statusClassName(normalized)].filter(Boolean).join(" ")}>
|
||||
{statusLabel(normalized)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function NullableTime({ value }) {
|
||||
return value ? <TimeText value={value} /> : "-";
|
||||
}
|
||||
|
||||
function SummaryItem({ children, label }) {
|
||||
return (
|
||||
<div className={styles.summaryItem}>
|
||||
@ -408,6 +534,45 @@ function resourceGroupLabel(group, value) {
|
||||
return value ? `资源组 #${value}` : "未配置奖励";
|
||||
}
|
||||
|
||||
function formatRelationDays(item, serverTimeMs) {
|
||||
if (item.durationDays) {
|
||||
return `${formatNumber(item.durationDays)} 天`;
|
||||
}
|
||||
if (!item.formedAtMs) {
|
||||
return "-";
|
||||
}
|
||||
const endMs = item.endedAtMs || serverTimeMs || Date.now();
|
||||
const days = Math.max(1, Math.floor((Number(endMs) - Number(item.formedAtMs)) / 86400000) + 1);
|
||||
return `${formatNumber(days)} 天`;
|
||||
}
|
||||
|
||||
function statusLabel(status) {
|
||||
const labels = {
|
||||
accepted: "已同意",
|
||||
active: "启用",
|
||||
blocked: "已阻断",
|
||||
disabled: "停用",
|
||||
ended: "已解除",
|
||||
expired: "已过期",
|
||||
pending: "待处理",
|
||||
rejected: "已拒绝",
|
||||
};
|
||||
return labels[status] || status || "-";
|
||||
}
|
||||
|
||||
function statusClassName(status) {
|
||||
if (status === "active" || status === "accepted") {
|
||||
return styles.statusActive;
|
||||
}
|
||||
if (status === "pending") {
|
||||
return styles.statusPending;
|
||||
}
|
||||
if (status === "rejected" || status === "expired") {
|
||||
return styles.statusDanger;
|
||||
}
|
||||
return styles.statusDisabled;
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return Number(value || 0).toLocaleString("zh-CN");
|
||||
}
|
||||
|
||||
@ -1,8 +1,15 @@
|
||||
import { stageOptions } from "@/features/lucky-gift/constants.js";
|
||||
import {
|
||||
applyProbabilityMap,
|
||||
correctRTPProbabilities,
|
||||
decimal,
|
||||
expectedRTPPercent,
|
||||
formatDecimal,
|
||||
probabilityTotal,
|
||||
tierRTPContributionPercent,
|
||||
} from "@/shared/utils/rtpProbability.js";
|
||||
|
||||
export const luckyGiftReferenceCost = 100;
|
||||
export const minimumCorrectedProbabilityPercent = 0.01;
|
||||
const ppmScale = 1_000_000;
|
||||
|
||||
const defaultStageTiers = {
|
||||
novice: [
|
||||
@ -170,42 +177,20 @@ export function updateStageTier(stages, stageKey, tierIndex, patch) {
|
||||
}
|
||||
|
||||
export function correctStageTierProbabilities(stages, stageKey, targetRTPPercent) {
|
||||
const targetRTP = decimal(targetRTPPercent);
|
||||
return stages.map((stage) => {
|
||||
if (stage.stage !== stageKey) {
|
||||
return stage;
|
||||
}
|
||||
const enabledTiers = stage.tiers
|
||||
.map((item, index) => ({ index, multiplier: decimal(item.multiplier), tier: item }))
|
||||
.filter((item) => item.tier.enabled !== false);
|
||||
const baseTiers = enabledTiers.filter(
|
||||
(item) => item.tier.rewardSource === "base_rtp" && Number.isFinite(item.multiplier),
|
||||
);
|
||||
const positiveTiers = baseTiers
|
||||
.filter((item) => item.multiplier > 0)
|
||||
.sort((left, right) => left.multiplier - right.multiplier);
|
||||
const zeroTiers = baseTiers.filter((item) => item.multiplier === 0);
|
||||
if (enabledTiers.length === 0 || positiveTiers.length === 0) {
|
||||
const probabilityByIndex = correctRTPProbabilities(stage.tiers, targetRTPPercent, {
|
||||
multiplier: (item) => item.multiplier,
|
||||
participatesInRTP: (item) => item.rewardSource === "base_rtp",
|
||||
});
|
||||
if (!probabilityByIndex) {
|
||||
return stage;
|
||||
}
|
||||
const probabilityByIndex = positiveBaselineProbabilities(enabledTiers);
|
||||
const fixedProbability = sumProbabilities(probabilityByIndex);
|
||||
if (fixedProbability >= 100) {
|
||||
return { ...stage, tiers: applyCorrectionProbabilities(stage.tiers, probabilityByIndex) };
|
||||
}
|
||||
const fixedExpectedRTP = positiveTiers.reduce(
|
||||
(sum, item) => sum + item.multiplier * (probabilityByIndex.get(item.index) || 0),
|
||||
0,
|
||||
);
|
||||
const remainingProbability = 100 - fixedProbability;
|
||||
const targetExtraRTP = targetRTP - fixedExpectedRTP;
|
||||
const contributionPlan = balancedRTPContributionPlan(positiveTiers, targetExtraRTP, remainingProbability);
|
||||
addContributionProbabilities(probabilityByIndex, contributionPlan);
|
||||
assignResidualProbability(probabilityByIndex, zeroTiers, positiveTiers);
|
||||
alignExpectedRTPByWeightPPM(probabilityByIndex, baseTiers, targetRTP);
|
||||
return {
|
||||
...stage,
|
||||
tiers: applyCorrectionProbabilities(stage.tiers, probabilityByIndex),
|
||||
tiers: applyProbabilityMap(stage.tiers, probabilityByIndex),
|
||||
};
|
||||
});
|
||||
}
|
||||
@ -213,29 +198,22 @@ export function correctStageTierProbabilities(stages, stageKey, targetRTPPercent
|
||||
export function stageProbabilityTotal(stage) {
|
||||
// 与 activity-service 的发布校验保持同一口径:只有启用奖档参与 100% 概率闭合;
|
||||
// 禁用奖档只是草稿行,不能让前端显示通过但服务端按启用集合拒绝。
|
||||
return (stage?.tiers || []).reduce((sum, item) => {
|
||||
if (item?.enabled === false) {
|
||||
return sum;
|
||||
}
|
||||
return sum + decimal(item.probabilityPercent);
|
||||
}, 0);
|
||||
return probabilityTotal(stage?.tiers || []);
|
||||
}
|
||||
|
||||
export function stageExpectedRTP(stage) {
|
||||
// 阶段期望 RTP 是发布时真正校验的概率口径:只统计启用的基础 RTP 奖档,活动补贴和表现奖励不参与基础返奖率。
|
||||
return (stage?.tiers || []).reduce((sum, item) => {
|
||||
if (item?.enabled === false || item?.rewardSource !== "base_rtp") {
|
||||
return sum;
|
||||
}
|
||||
return sum + expectedContributionPercent(item.multiplier, item.probabilityPercent);
|
||||
}, 0);
|
||||
return expectedRTPPercent(stage?.tiers || [], {
|
||||
multiplier: (item) => item.multiplier,
|
||||
participatesInRTP: (item) => item.rewardSource === "base_rtp",
|
||||
});
|
||||
}
|
||||
|
||||
export function tierRTPContribution(tier) {
|
||||
if (tier?.enabled === false || tier?.rewardSource !== "base_rtp") {
|
||||
return 0;
|
||||
}
|
||||
return expectedContributionPercent(tier.multiplier, tier.probabilityPercent);
|
||||
return tierRTPContributionPercent(tier, {
|
||||
multiplier: (item) => item.multiplier,
|
||||
participatesInRTP: (item) => item.rewardSource === "base_rtp",
|
||||
});
|
||||
}
|
||||
|
||||
export function assignGeneratedTierIDs(stages) {
|
||||
@ -313,229 +291,6 @@ function cloneStages(stages) {
|
||||
return stages.map((stage) => ({ ...stage, tiers: stage.tiers.map((item) => ({ ...item })) }));
|
||||
}
|
||||
|
||||
function positiveBaselineProbabilities(enabled) {
|
||||
const probabilities = new Map();
|
||||
const floor =
|
||||
enabled.length * minimumCorrectedProbabilityPercent <= 100
|
||||
? minimumCorrectedProbabilityPercent
|
||||
: 100 / enabled.length;
|
||||
for (const item of enabled) {
|
||||
probabilities.set(item.index, roundPercent(floor));
|
||||
}
|
||||
return probabilities;
|
||||
}
|
||||
|
||||
function balancedRTPContributionPlan(positiveTiers, targetExtraRTP, remainingProbability) {
|
||||
if (targetExtraRTP <= 0) {
|
||||
return [];
|
||||
}
|
||||
const maxMultiplier = positiveTiers[positiveTiers.length - 1].multiplier;
|
||||
if (targetExtraRTP >= remainingProbability * maxMultiplier) {
|
||||
// 目标 RTP 超过当前倍率表可达上限时,所有剩余概率只能压到最大倍率;保存校验会继续提示无法达标。
|
||||
return [{ tier: positiveTiers[positiveTiers.length - 1], probability: remainingProbability }];
|
||||
}
|
||||
const alpha = minimumFeasibleContributionAlpha(positiveTiers, targetExtraRTP, remainingProbability);
|
||||
const weighted = contributionWeights(positiveTiers, alpha);
|
||||
return positiveTiers.map((tier, index) => ({
|
||||
tier,
|
||||
probability: roundPercent((targetExtraRTP * weighted[index]) / tier.multiplier),
|
||||
}));
|
||||
}
|
||||
|
||||
function minimumFeasibleContributionAlpha(positiveTiers, targetExtraRTP, remainingProbability) {
|
||||
// alpha=0 代表所有正倍率平均分 RTP 贡献;如果低倍率太多导致概率总和超过 100%,逐步提高 alpha,
|
||||
// 让更高倍率承担更多 RTP 贡献。这样 10x/100x 不会被挤成 0,又能保持整体期望返奖率。
|
||||
if (contributionProbabilityTotal(positiveTiers, targetExtraRTP, 0) <= remainingProbability) {
|
||||
return 0;
|
||||
}
|
||||
let low = 0;
|
||||
let high = 1;
|
||||
while (contributionProbabilityTotal(positiveTiers, targetExtraRTP, high) > remainingProbability && high < 64) {
|
||||
high *= 2;
|
||||
}
|
||||
for (let step = 0; step < 48; step += 1) {
|
||||
const mid = (low + high) / 2;
|
||||
if (contributionProbabilityTotal(positiveTiers, targetExtraRTP, mid) > remainingProbability) {
|
||||
low = mid;
|
||||
} else {
|
||||
high = mid;
|
||||
}
|
||||
}
|
||||
return high;
|
||||
}
|
||||
|
||||
function contributionProbabilityTotal(positiveTiers, targetExtraRTP, alpha) {
|
||||
const weights = contributionWeights(positiveTiers, alpha);
|
||||
return positiveTiers.reduce((sum, tier, index) => sum + (targetExtraRTP * weights[index]) / tier.multiplier, 0);
|
||||
}
|
||||
|
||||
function contributionWeights(positiveTiers, alpha) {
|
||||
const rawWeights = positiveTiers.map((tier) => Math.max(0, tier.multiplier) ** alpha);
|
||||
const total = rawWeights.reduce((sum, value) => sum + value, 0);
|
||||
if (total <= 0) {
|
||||
return positiveTiers.map(() => 1 / positiveTiers.length);
|
||||
}
|
||||
return rawWeights.map((value) => value / total);
|
||||
}
|
||||
|
||||
function addContributionProbabilities(probabilities, contributionPlan) {
|
||||
for (const item of contributionPlan) {
|
||||
probabilities.set(item.tier.index, roundPercent((probabilities.get(item.tier.index) || 0) + item.probability));
|
||||
}
|
||||
}
|
||||
|
||||
function assignResidualProbability(probabilities, zeroTiers, positiveTiers) {
|
||||
const residual = roundPercent(100 - sumProbabilities(probabilities));
|
||||
if (residual <= 0) {
|
||||
normalizeProbabilityTotal(probabilities, zeroTiers[0] || positiveTiers[0]);
|
||||
return;
|
||||
}
|
||||
const receiver = zeroTiers[0] || positiveTiers[0];
|
||||
if (!receiver) {
|
||||
return;
|
||||
}
|
||||
probabilities.set(receiver.index, roundPercent((probabilities.get(receiver.index) || 0) + residual));
|
||||
normalizeProbabilityTotal(probabilities, receiver);
|
||||
}
|
||||
|
||||
function normalizeProbabilityTotal(probabilities, receiver) {
|
||||
if (!receiver) {
|
||||
return;
|
||||
}
|
||||
const diff = roundPercent(100 - sumProbabilities(probabilities));
|
||||
if (diff !== 0) {
|
||||
probabilities.set(receiver.index, roundPercent((probabilities.get(receiver.index) || 0) + diff));
|
||||
}
|
||||
}
|
||||
|
||||
function alignExpectedRTPByWeightPPM(probabilities, baseTiers, targetRTP) {
|
||||
const targetRTPPPM = percentToWeightPPM(targetRTP);
|
||||
const weights = new Map();
|
||||
for (const [index, probability] of probabilities.entries()) {
|
||||
weights.set(index, percentToWeightPPM(probability));
|
||||
}
|
||||
normalizeWeightTotal(weights, baseTiers[0]);
|
||||
let currentRTPPPM = expectedRTPPPMByWeights(weights, baseTiers);
|
||||
const floorWeight = percentToWeightPPM(minimumCorrectedProbabilityPercent);
|
||||
|
||||
for (let step = 0; step < 20_000 && currentRTPPPM !== targetRTPPPM; step += 1) {
|
||||
const diff = targetRTPPPM - currentRTPPPM;
|
||||
const move = bestExpectedRTPMove(weights, baseTiers, diff, floorWeight);
|
||||
if (!move || Math.abs(diff - move.delta) >= Math.abs(diff)) {
|
||||
break;
|
||||
}
|
||||
weights.set(move.donor.index, (weights.get(move.donor.index) || 0) - 1);
|
||||
weights.set(move.receiver.index, (weights.get(move.receiver.index) || 0) + 1);
|
||||
currentRTPPPM += move.delta;
|
||||
}
|
||||
|
||||
for (const [index, weight] of weights.entries()) {
|
||||
probabilities.set(index, roundPercent(weight / 10_000));
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWeightTotal(weights, receiver) {
|
||||
if (!receiver) {
|
||||
return;
|
||||
}
|
||||
let total = 0;
|
||||
for (const weight of weights.values()) {
|
||||
total += weight;
|
||||
}
|
||||
const diff = ppmScale - total;
|
||||
if (diff !== 0) {
|
||||
weights.set(receiver.index, (weights.get(receiver.index) || 0) + diff);
|
||||
}
|
||||
}
|
||||
|
||||
function bestExpectedRTPMove(weights, baseTiers, diff, floorWeight) {
|
||||
let best = null;
|
||||
for (const donor of baseTiers) {
|
||||
const donorWeight = weights.get(donor.index) || 0;
|
||||
if (donorWeight <= floorWeight) {
|
||||
continue;
|
||||
}
|
||||
for (const receiver of baseTiers) {
|
||||
if (receiver.index === donor.index) {
|
||||
continue;
|
||||
}
|
||||
const delta = expectedMoveDeltaPPM(weights, donor, receiver);
|
||||
if ((diff > 0 && delta <= 0) || (diff < 0 && delta >= 0)) {
|
||||
continue;
|
||||
}
|
||||
if (!best || betterMove(delta, best.delta, diff)) {
|
||||
best = { delta, donor, receiver };
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function betterMove(candidateDelta, bestDelta, diff) {
|
||||
const candidateAbs = Math.abs(candidateDelta);
|
||||
const bestAbs = Math.abs(bestDelta);
|
||||
const diffAbs = Math.abs(diff);
|
||||
const candidateUnder = candidateAbs <= diffAbs;
|
||||
const bestUnder = bestAbs <= diffAbs;
|
||||
if (candidateUnder !== bestUnder) {
|
||||
return candidateUnder;
|
||||
}
|
||||
return candidateUnder ? candidateAbs > bestAbs : candidateAbs < bestAbs;
|
||||
}
|
||||
|
||||
function expectedMoveDeltaPPM(weights, donor, receiver) {
|
||||
const donorWeight = weights.get(donor.index) || 0;
|
||||
const receiverWeight = weights.get(receiver.index) || 0;
|
||||
return (
|
||||
contributionPPM(receiver.multiplier, receiverWeight + 1) -
|
||||
contributionPPM(receiver.multiplier, receiverWeight) +
|
||||
contributionPPM(donor.multiplier, donorWeight - 1) -
|
||||
contributionPPM(donor.multiplier, donorWeight)
|
||||
);
|
||||
}
|
||||
|
||||
function expectedRTPPPMByWeights(weights, baseTiers) {
|
||||
return baseTiers.reduce((sum, tier) => sum + contributionPPM(tier.multiplier, weights.get(tier.index) || 0), 0);
|
||||
}
|
||||
|
||||
function applyCorrectionProbabilities(tiers, probabilityByIndex) {
|
||||
return tiers.map((item, index) => ({
|
||||
...item,
|
||||
// 启用奖档保留最小正概率;如果运营不想让某个倍率出现,应关闭该奖档,而不是让概率为 0。
|
||||
probabilityPercent: formatDecimal(probabilityByIndex.get(index) || 0),
|
||||
}));
|
||||
}
|
||||
|
||||
function sumProbabilities(probabilities) {
|
||||
let total = 0;
|
||||
for (const value of probabilities.values()) {
|
||||
total += value;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function roundPercent(value) {
|
||||
// 后端会把 percent * 10000 转成 ppm 并要求每阶段启用奖档 ppm 精确合计 1,000,000;
|
||||
// 前端纠正结果也落到 0.0001% 粒度,避免 100.000000 这种浮点显示通过但 RPC 校验失败。
|
||||
return Math.round(value * 10_000) / 10_000;
|
||||
}
|
||||
|
||||
function expectedContributionPercent(multiplier, probabilityPercent) {
|
||||
return contributionPPM(decimal(multiplier), percentToWeightPPM(probabilityPercent)) / 10_000;
|
||||
}
|
||||
|
||||
function contributionPPM(multiplier, weightPPM) {
|
||||
return Math.trunc((multiplierToPPM(multiplier) * weightPPM) / ppmScale);
|
||||
}
|
||||
|
||||
function percentToWeightPPM(value) {
|
||||
return Math.round(decimal(value) * 10_000);
|
||||
}
|
||||
|
||||
function multiplierToPPM(value) {
|
||||
return Math.round(decimal(value) * ppmScale);
|
||||
}
|
||||
|
||||
function tier(tierId, multiplier, probabilityPercent, options = {}) {
|
||||
return {
|
||||
broadcastLevel: options.broadcastLevel || "none",
|
||||
@ -548,19 +303,6 @@ function tier(tierId, multiplier, probabilityPercent, options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function formatDecimal(value) {
|
||||
const number = Number(value || 0);
|
||||
if (!Number.isFinite(number)) {
|
||||
return "0";
|
||||
}
|
||||
return Number.isInteger(number) ? String(number) : number.toFixed(6).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
function decimal(value) {
|
||||
const number = Number(value || 0);
|
||||
return Number.isFinite(number) ? number : 0;
|
||||
}
|
||||
|
||||
function integer(value) {
|
||||
const number = Number(value || 0);
|
||||
return Number.isFinite(number) ? Math.round(number) : 0;
|
||||
|
||||
@ -108,7 +108,7 @@ describe("lucky gift config payload", () => {
|
||||
const novice = nextStages.find((stage) => stage.stage === "novice");
|
||||
|
||||
expect(stageProbabilityTotal(novice)).toBeCloseTo(100, 6);
|
||||
expect(stageExpectedRTP(novice)).toBeCloseTo(199.955, 6);
|
||||
expect(stageExpectedRTP(novice)).toBeCloseTo(199.9995, 6);
|
||||
expect(novice.tiers.every((tier) => Number(tier.probabilityPercent) > 0)).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@ -30,6 +30,7 @@ test("gift diamond ratio APIs use operations paths", async () => {
|
||||
await updateGiftDiamondRatios({
|
||||
regionId: 1001,
|
||||
ratios: { lucky: "40.00", normal: "30.00", super_lucky: "50.00" },
|
||||
returnCoinRatios: { lucky: "10.00", normal: "30.00", super_lucky: "1.00" },
|
||||
});
|
||||
|
||||
const [getUrl, getInit] = vi.mocked(fetch).mock.calls[0];
|
||||
@ -43,6 +44,7 @@ test("gift diamond ratio APIs use operations paths", async () => {
|
||||
expect(JSON.parse(String(putInit?.body))).toMatchObject({
|
||||
regionId: 1001,
|
||||
ratios: { lucky: "40.00", normal: "30.00", super_lucky: "50.00" },
|
||||
returnCoinRatios: { lucky: "10.00", normal: "30.00", super_lucky: "1.00" },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -16,6 +16,7 @@ export interface GiftDiamondRatioItem {
|
||||
effectiveRegionId: number;
|
||||
giftTypeCode: string;
|
||||
ratioPercent: string;
|
||||
returnCoinRatioPercent: string;
|
||||
regionId: number;
|
||||
status: string;
|
||||
updatedAtMs?: number;
|
||||
@ -226,6 +227,7 @@ export function getGiftDiamondRatios(regionId: string | number): Promise<GiftDia
|
||||
export function updateGiftDiamondRatios(payload: {
|
||||
regionId: number;
|
||||
ratios: Record<string, string>;
|
||||
returnCoinRatios: Record<string, string>;
|
||||
}): Promise<GiftDiamondRatioResponse> {
|
||||
return apiRequest<GiftDiamondRatioResponse, typeof payload>("/v1/admin/operations/gift-diamond-ratios", {
|
||||
body: payload,
|
||||
|
||||
@ -5,12 +5,9 @@ import { wheelConfigFormSchema } from "@/features/operations/schema";
|
||||
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
import { applyProbabilityMap, correctRTPProbabilities, decimal } from "@/shared/utils/rtpProbability.js";
|
||||
|
||||
const pageSize = 50;
|
||||
const probabilityScale = 1_000_000;
|
||||
const probabilityPercentScale = 10_000;
|
||||
const minimumTierProbabilityPPM = 100;
|
||||
const randomRewardTypes = new Set(["prop", "dress"]);
|
||||
const probabilityAffectingFields = new Set([
|
||||
"drawPriceCoins",
|
||||
"targetRTPPercent",
|
||||
@ -82,7 +79,10 @@ export function useWheelPage() {
|
||||
queryKey: ["wheel-draws", summaryFilters, page],
|
||||
});
|
||||
|
||||
const resourceGroupQueryFn = useCallback(() => listResourceGroups({ page: 1, page_size: 300, status: "active" }), []);
|
||||
const resourceGroupQueryFn = useCallback(
|
||||
() => listResourceGroups({ page: 1, page_size: 300, status: "active" }),
|
||||
[],
|
||||
);
|
||||
const resourceGroupQuery = useAdminQuery(resourceGroupQueryFn, {
|
||||
errorMessage: "获取资源组失败",
|
||||
initialData: { items: [] },
|
||||
@ -175,14 +175,23 @@ export function useWheelPage() {
|
||||
return;
|
||||
}
|
||||
setForm((current) =>
|
||||
withGeneratedTierProbabilities({ ...current, tiers: items.map((item, index) => groupItemToTier(item, wheelId, index)) }),
|
||||
withGeneratedTierProbabilities({
|
||||
...current,
|
||||
tiers: items.map((item, index) => groupItemToTier(item, wheelId, index)),
|
||||
}),
|
||||
);
|
||||
showToast("资源组已导入,可拖动排序", "success");
|
||||
};
|
||||
|
||||
const moveTier = (fromIndex, toIndex) => {
|
||||
setForm((current) => {
|
||||
if (fromIndex === toIndex || fromIndex < 0 || toIndex < 0 || fromIndex >= current.tiers.length || toIndex >= current.tiers.length) {
|
||||
if (
|
||||
fromIndex === toIndex ||
|
||||
fromIndex < 0 ||
|
||||
toIndex < 0 ||
|
||||
fromIndex >= current.tiers.length ||
|
||||
toIndex >= current.tiers.length
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
const tiers = [...current.tiers];
|
||||
@ -275,20 +284,18 @@ function configToForm(config) {
|
||||
}
|
||||
|
||||
function normalizeTiers(tiers, wheelId) {
|
||||
return (tiers || []).map(
|
||||
(tier, index) => ({
|
||||
displayName: tier.displayName || "",
|
||||
enabled: true,
|
||||
metadataJson: tier.metadataJson || "{}",
|
||||
probabilityPercent: tier.probabilityPercent || 0,
|
||||
rewardCoins: tier.rewardCoins || 0,
|
||||
rewardCount: tier.rewardCount || 0,
|
||||
rewardId: tier.rewardId || "",
|
||||
rewardType: tier.rewardType || "coin",
|
||||
rtpValueCoins: tier.rewardType === "prop" || tier.rewardType === "dress" ? 0 : tier.rtpValueCoins || 0,
|
||||
tierId: tier.tierId || `${wheelId}-${index + 1}`,
|
||||
}),
|
||||
);
|
||||
return (tiers || []).map((tier, index) => ({
|
||||
displayName: tier.displayName || "",
|
||||
enabled: true,
|
||||
metadataJson: tier.metadataJson || "{}",
|
||||
probabilityPercent: tier.probabilityPercent || 0,
|
||||
rewardCoins: tier.rewardCoins || 0,
|
||||
rewardCount: tier.rewardCount || 0,
|
||||
rewardId: tier.rewardId || "",
|
||||
rewardType: tier.rewardType || "coin",
|
||||
rtpValueCoins: tier.rewardType === "prop" || tier.rewardType === "dress" ? 0 : tier.rtpValueCoins || 0,
|
||||
tierId: tier.tierId || `${wheelId}-${index + 1}`,
|
||||
}));
|
||||
}
|
||||
|
||||
function isGeneratedPlaceholderTiers(tiers, wheelId) {
|
||||
@ -349,7 +356,7 @@ function poolLabel(wheelId) {
|
||||
return (wheelPools.find((pool) => pool.key === wheelId) || wheelPools[0]).label;
|
||||
}
|
||||
|
||||
function groupItemToTier(item, wheelId, index) {
|
||||
export function groupItemToTier(item, wheelId, index) {
|
||||
const resource = item.resource || {};
|
||||
const isCoin = item.itemType === "wallet_asset" && item.walletAssetType === "COIN";
|
||||
const rewardType = isCoin ? "coin" : resourceTypeToRewardType(resource.resourceType);
|
||||
@ -357,13 +364,20 @@ function groupItemToTier(item, wheelId, index) {
|
||||
const metadata = {
|
||||
animation_url: isCoin ? "" : resource.animationUrl || "",
|
||||
asset_url: resource.assetUrl || "",
|
||||
coinPrice: coinValue,
|
||||
coin_price: coinValue,
|
||||
displayValue: coinValue,
|
||||
display_value: coinValue,
|
||||
preview_url: resource.previewUrl || resource.assetUrl || "",
|
||||
resource_code: resource.resourceCode || "",
|
||||
resource_id: resource.resourceId || 0,
|
||||
resource_type: resource.resourceType || "",
|
||||
value: coinValue,
|
||||
};
|
||||
return {
|
||||
displayName: isCoin ? "金币" : resource.name || resource.resourceCode || `资源 ${resource.resourceId || index + 1}`,
|
||||
displayName: isCoin
|
||||
? "金币"
|
||||
: resource.name || resource.resourceCode || `资源 ${resource.resourceId || index + 1}`,
|
||||
enabled: true,
|
||||
metadataJson: JSON.stringify(metadata),
|
||||
probabilityPercent: 0,
|
||||
@ -385,144 +399,50 @@ export function generateWheelTierProbabilities(form) {
|
||||
if (!tiers.length) {
|
||||
return tiers;
|
||||
}
|
||||
|
||||
const ppm = tiers.map(() => minimumTierProbabilityPPM);
|
||||
const randomIndexes = [];
|
||||
const rtpIndexes = [];
|
||||
const rtpValues = new Map();
|
||||
|
||||
tiers.forEach((tier, index) => {
|
||||
const rewardType = tier.rewardType;
|
||||
if (randomRewardTypes.has(rewardType)) {
|
||||
randomIndexes.push(index);
|
||||
return;
|
||||
}
|
||||
const rtpValue = rtpTierValue(tier);
|
||||
if (rtpValue > 0) {
|
||||
rtpIndexes.push(index);
|
||||
rtpValues.set(index, rtpValue);
|
||||
} else {
|
||||
randomIndexes.push(index);
|
||||
}
|
||||
// 转盘没有幸运礼物的倍率字段,这里把每个奖档的 RTP 价值换算成“相对单抽价格的倍率”。
|
||||
// 道具/装扮不计入 RTP,因此只拿公共公式保留的最小正概率;全无 RTP 奖档时退化为均分,保证仍可发布配置。
|
||||
const probabilityByIndex = correctRTPProbabilities(tiers, form.targetRTPPercent, {
|
||||
baselineProbability: (tier, index, context) =>
|
||||
wheelTierBaselineProbability(tier, index, context, form.drawPriceCoins),
|
||||
fallback: "equal",
|
||||
multiplier: (tier) => wheelTierMultiplier(tier, form.drawPriceCoins),
|
||||
participatesInRTP: wheelTierParticipatesInRTP,
|
||||
});
|
||||
|
||||
const availablePPM = Math.max(0, probabilityScale - minimumTierProbabilityPPM * tiers.length);
|
||||
if (!rtpIndexes.length) {
|
||||
distributeEqual(ppm, randomIndexes.length ? randomIndexes : tiers.map((_, index) => index), availablePPM);
|
||||
return formatTierProbabilities(tiers, ppm);
|
||||
}
|
||||
|
||||
const targetExpectedCoins = (numberValue(form.drawPriceCoins) * numberValue(form.targetRTPPercent)) / 100;
|
||||
const baselineExpectedCoins = rtpIndexes.reduce(
|
||||
(sum, index) => sum + (minimumTierProbabilityPPM / probabilityScale) * (rtpValues.get(index) || 0),
|
||||
0,
|
||||
);
|
||||
const remainingTargetCoins = Math.max(0, targetExpectedCoins - baselineExpectedCoins);
|
||||
const sortedRtpIndexes = [...rtpIndexes].sort((left, right) => (rtpValues.get(left) || 0) - (rtpValues.get(right) || 0));
|
||||
const minRtpValue = rtpValues.get(sortedRtpIndexes[0]) || 0;
|
||||
const maxRtpValue = rtpValues.get(sortedRtpIndexes[sortedRtpIndexes.length - 1]) || minRtpValue;
|
||||
|
||||
// 非 RTP 奖励没有金币期望值,默认只拿一个小的随机池;如果目标 RTP 需要更多金币/礼物概率,
|
||||
// 这里会自动压缩随机池,保证提交前概率合计和期望 RTP 都尽量贴近基础配置。
|
||||
const desiredRandomPPM = randomIndexes.length ? Math.min(300_000, randomIndexes.length * 20_000) : 0;
|
||||
const minRtpExtraPPM = maxRtpValue > 0 ? Math.ceil((remainingTargetCoins * probabilityScale) / maxRtpValue) : availablePPM;
|
||||
const maxRtpExtraPPM = minRtpValue > 0 ? Math.floor((remainingTargetCoins * probabilityScale) / minRtpValue) : availablePPM;
|
||||
const desiredRtpExtraPPM = randomIndexes.length ? availablePPM - desiredRandomPPM : availablePPM;
|
||||
const rtpExtraPPM = randomIndexes.length ? clamp(desiredRtpExtraPPM, 0, availablePPM, minRtpExtraPPM, maxRtpExtraPPM) : availablePPM;
|
||||
const randomExtraPPM = availablePPM - rtpExtraPPM;
|
||||
|
||||
distributeEqual(ppm, randomIndexes, randomExtraPPM);
|
||||
distributeRtpProbability(ppm, sortedRtpIndexes, rtpValues, rtpExtraPPM, remainingTargetCoins);
|
||||
normalizeProbabilityTotal(ppm);
|
||||
return formatTierProbabilities(tiers, ppm);
|
||||
return applyProbabilityMap(tiers, probabilityByIndex, (value) => Number(value.toFixed(4)));
|
||||
}
|
||||
|
||||
function distributeRtpProbability(ppm, sortedIndexes, rtpValues, availablePPM, targetCoins) {
|
||||
if (availablePPM <= 0 || !sortedIndexes.length) {
|
||||
return;
|
||||
function wheelTierMultiplier(tier, drawPriceCoins) {
|
||||
const drawPrice = decimal(drawPriceCoins);
|
||||
if (drawPrice <= 0) {
|
||||
return 0;
|
||||
}
|
||||
if (sortedIndexes.length === 1) {
|
||||
ppm[sortedIndexes[0]] += availablePPM;
|
||||
return;
|
||||
}
|
||||
const averageTarget = targetCoins / (availablePPM / probabilityScale);
|
||||
let lowerIndex = sortedIndexes[0];
|
||||
let upperIndex = sortedIndexes[sortedIndexes.length - 1];
|
||||
|
||||
for (let index = 0; index < sortedIndexes.length - 1; index += 1) {
|
||||
const currentIndex = sortedIndexes[index];
|
||||
const nextIndex = sortedIndexes[index + 1];
|
||||
const currentValue = rtpValues.get(currentIndex) || 0;
|
||||
const nextValue = rtpValues.get(nextIndex) || currentValue;
|
||||
if (averageTarget >= currentValue && averageTarget <= nextValue) {
|
||||
lowerIndex = currentIndex;
|
||||
upperIndex = nextIndex;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const lowerValue = rtpValues.get(lowerIndex) || 0;
|
||||
const upperValue = rtpValues.get(upperIndex) || lowerValue;
|
||||
if (upperValue <= lowerValue) {
|
||||
ppm[lowerIndex] += availablePPM;
|
||||
return;
|
||||
}
|
||||
|
||||
// 在两个相邻 RTP 价值档之间做线性分配,可以同时满足“概率合计 100”和“期望返奖贴近目标 RTP”。
|
||||
const upperRatio = Math.max(0, Math.min(1, (averageTarget - lowerValue) / (upperValue - lowerValue)));
|
||||
const upperPPM = Math.round(availablePPM * upperRatio);
|
||||
ppm[upperIndex] += upperPPM;
|
||||
ppm[lowerIndex] += availablePPM - upperPPM;
|
||||
return wheelTierRTPValueCoins(tier) / drawPrice;
|
||||
}
|
||||
|
||||
function distributeEqual(ppm, indexes, totalPPM) {
|
||||
if (!indexes.length || totalPPM <= 0) {
|
||||
return;
|
||||
function wheelTierBaselineProbability(tier, index, context, drawPriceCoins) {
|
||||
if (!wheelTierParticipatesInRTP(tier)) {
|
||||
return 0;
|
||||
}
|
||||
const base = Math.floor(totalPPM / indexes.length);
|
||||
let remainder = totalPPM - base * indexes.length;
|
||||
indexes.forEach((index) => {
|
||||
ppm[index] += base + (remainder > 0 ? 1 : 0);
|
||||
remainder -= 1;
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeProbabilityTotal(ppm) {
|
||||
const delta = probabilityScale - ppm.reduce((sum, value) => sum + value, 0);
|
||||
if (delta !== 0 && ppm.length) {
|
||||
ppm[ppm.length - 1] += delta;
|
||||
const multiplier = wheelTierMultiplier(tier, drawPriceCoins);
|
||||
if (multiplier <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const maxMultiplier = Math.max(multiplier, ...(context.positiveTiers || []).map((item) => item.multiplier));
|
||||
return 0.0001 * (maxMultiplier / multiplier);
|
||||
}
|
||||
|
||||
function formatTierProbabilities(tiers, ppm) {
|
||||
return tiers.map((tier, index) => ({
|
||||
...tier,
|
||||
probabilityPercent: Number((ppm[index] / probabilityPercentScale).toFixed(4)),
|
||||
}));
|
||||
}
|
||||
|
||||
function rtpTierValue(tier) {
|
||||
function wheelTierRTPValueCoins(tier) {
|
||||
if (tier.rewardType === "coin") {
|
||||
return numberValue(tier.rewardCoins || tier.rtpValueCoins);
|
||||
return decimal(tier.rewardCoins || tier.rtpValueCoins);
|
||||
}
|
||||
if (tier.rewardType === "gift") {
|
||||
return numberValue(tier.rtpValueCoins);
|
||||
return decimal(tier.rtpValueCoins);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function numberValue(value) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : 0;
|
||||
}
|
||||
|
||||
function clamp(value, min, max, feasibleMin, feasibleMax) {
|
||||
const lower = Math.max(min, Number.isFinite(feasibleMin) ? feasibleMin : min);
|
||||
const upper = Math.min(max, Number.isFinite(feasibleMax) ? feasibleMax : max);
|
||||
if (lower > upper) {
|
||||
return Math.max(min, Math.min(max, value < lower ? lower : upper));
|
||||
}
|
||||
return Math.max(lower, Math.min(upper, value));
|
||||
function wheelTierParticipatesInRTP(tier) {
|
||||
return tier.rewardType === "coin" || tier.rewardType === "gift";
|
||||
}
|
||||
|
||||
function resourceTypeToRewardType(resourceType) {
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { wheelConfigFormSchema } from "@/features/operations/schema";
|
||||
import { generateWheelTierProbabilities, wheelPools } from "@/features/operations/hooks/useWheelPage.js";
|
||||
import {
|
||||
generateWheelTierProbabilities,
|
||||
groupItemToTier,
|
||||
wheelPools,
|
||||
} from "@/features/operations/hooks/useWheelPage.js";
|
||||
|
||||
function tier(overrides) {
|
||||
return {
|
||||
@ -22,19 +26,46 @@ function probabilityPPM(tiers) {
|
||||
return tiers.reduce((sum, item) => sum + Math.round(item.probabilityPercent * 10_000), 0);
|
||||
}
|
||||
|
||||
function expectedPayoutCoins(tiers) {
|
||||
function wheelExpectedRTP(tiers, drawPriceCoins) {
|
||||
return tiers.reduce((sum, item) => {
|
||||
if (item.rewardType === "coin") {
|
||||
return sum + (item.probabilityPercent / 100) * Number(item.rewardCoins || 0);
|
||||
return sum + (Number(item.rewardCoins || 0) / drawPriceCoins) * Number(item.probabilityPercent || 0);
|
||||
}
|
||||
if (item.rewardType === "gift") {
|
||||
return sum + (item.probabilityPercent / 100) * Number(item.rtpValueCoins || 0);
|
||||
return sum + (Number(item.rtpValueCoins || 0) / drawPriceCoins) * Number(item.probabilityPercent || 0);
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
describe("wheel prize probability generation", () => {
|
||||
test("stores resource coin price in metadata for prop and dress display", () => {
|
||||
const result = groupItemToTier(
|
||||
{
|
||||
itemType: "resource",
|
||||
quantity: 1,
|
||||
resource: {
|
||||
assetUrl: "https://example.com/a.png",
|
||||
coinPrice: 1314,
|
||||
name: "道具",
|
||||
previewUrl: "https://example.com/p.png",
|
||||
resourceCode: "prop_a",
|
||||
resourceId: 326,
|
||||
resourceType: "prop",
|
||||
},
|
||||
},
|
||||
"classic",
|
||||
0,
|
||||
);
|
||||
const metadata = JSON.parse(result.metadataJson);
|
||||
|
||||
expect(result.rewardType).toBe("prop");
|
||||
expect(result.rtpValueCoins).toBe(0);
|
||||
expect(metadata.coin_price).toBe(1314);
|
||||
expect(metadata.display_value).toBe(1314);
|
||||
expect(metadata.value).toBe(1314);
|
||||
});
|
||||
|
||||
test("uses eight prize slots for the classic wheel", () => {
|
||||
const classicPool = wheelPools.find((pool) => pool.key === "classic");
|
||||
const tiers = Array.from({ length: 8 }, (_, index) =>
|
||||
@ -78,14 +109,23 @@ describe("wheel prize probability generation", () => {
|
||||
tiers: generateWheelTierProbabilities({
|
||||
drawPriceCoins: 100,
|
||||
targetRTPPercent: 90,
|
||||
tiers: [...tiers, tier({ rewardCoins: 100, rewardId: "", rewardType: "coin", rtpValueCoins: 100, tierId: "classic-9" })],
|
||||
tiers: [
|
||||
...tiers,
|
||||
tier({
|
||||
rewardCoins: 100,
|
||||
rewardId: "",
|
||||
rewardType: "coin",
|
||||
rtpValueCoins: 100,
|
||||
tierId: "classic-9",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
wheelId: "classic",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("uses the base RTP to generate coin and gift probabilities while keeping dress and prop random", () => {
|
||||
test("uses the shared RTP formula to correct reachable wheel probabilities", () => {
|
||||
const result = generateWheelTierProbabilities({
|
||||
drawPriceCoins: 10000,
|
||||
targetRTPPercent: 90,
|
||||
@ -98,12 +138,31 @@ describe("wheel prize probability generation", () => {
|
||||
});
|
||||
|
||||
expect(probabilityPPM(result)).toBe(1_000_000);
|
||||
expect(expectedPayoutCoins(result)).toBeCloseTo(9000, 0);
|
||||
expect(result[2].probabilityPercent).toBe(result[3].probabilityPercent);
|
||||
expect(result[0].probabilityPercent + result[1].probabilityPercent).toBeGreaterThan(95);
|
||||
expect(wheelExpectedRTP(result, 10000)).toBeCloseTo(90, 3);
|
||||
expect(result[2].probabilityPercent).toBe(0.0001);
|
||||
expect(result[3].probabilityPercent).toBe(0.0001);
|
||||
});
|
||||
|
||||
test("falls back to pure random probabilities when a prize set has no RTP value", () => {
|
||||
test("clamps to the closest reachable RTP when every reward is above the target multiplier", () => {
|
||||
const result = generateWheelTierProbabilities({
|
||||
drawPriceCoins: 10000,
|
||||
targetRTPPercent: 90,
|
||||
tiers: Array.from({ length: 12 }, (_, index) =>
|
||||
tier({
|
||||
rewardId: String(index + 1),
|
||||
rewardType: "gift",
|
||||
rtpValueCoins: 50000 * (index + 1),
|
||||
tierId: `advanced-${index + 1}`,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
expect(probabilityPPM(result)).toBe(1_000_000);
|
||||
expect(wheelExpectedRTP(result, 10000)).toBeGreaterThan(90);
|
||||
expect(result.every((item) => Number(item.probabilityPercent) > 0)).toBe(true);
|
||||
});
|
||||
|
||||
test("falls back to equal probabilities when the wheel has no RTP prize", () => {
|
||||
const result = generateWheelTierProbabilities({
|
||||
drawPriceCoins: 10000,
|
||||
targetRTPPercent: 90,
|
||||
@ -114,7 +173,6 @@ describe("wheel prize probability generation", () => {
|
||||
});
|
||||
|
||||
expect(probabilityPPM(result)).toBe(1_000_000);
|
||||
expect(result[0].probabilityPercent).toBe(50);
|
||||
expect(result[1].probabilityPercent).toBe(50);
|
||||
expect(result.map((item) => item.probabilityPercent)).toEqual([50, 50]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -235,6 +235,29 @@
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.ratioTable {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(160px, 0.8fr) repeat(2, minmax(180px, 1fr));
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.ratioTableHeader {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.ratioRow {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.ratioType {
|
||||
min-width: 0;
|
||||
color: var(--text-primary);
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.wheelBody {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
@ -626,10 +649,22 @@
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.ratioGrid {
|
||||
.ratioGrid,
|
||||
.ratioTable {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.ratioTableHeader {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ratioRow {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
padding-bottom: var(--space-4);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.wheelBody,
|
||||
.wheelConfigGrid,
|
||||
.wheelSummaryGrid,
|
||||
|
||||
@ -21,8 +21,14 @@ const giftTypes = [
|
||||
|
||||
const defaultRatios = {
|
||||
normal: "100.00",
|
||||
lucky: "100.00",
|
||||
super_lucky: "100.00",
|
||||
lucky: "10.00",
|
||||
super_lucky: "1.00",
|
||||
};
|
||||
|
||||
const defaultReturnCoinRatios = {
|
||||
normal: "30.00",
|
||||
lucky: "10.00",
|
||||
super_lucky: "1.00",
|
||||
};
|
||||
|
||||
export function GiftDiamondRatioPage() {
|
||||
@ -30,7 +36,7 @@ export function GiftDiamondRatioPage() {
|
||||
const { showToast } = useToast();
|
||||
const { loadingRegions, regionOptions } = useRegionOptions();
|
||||
const [regionId, setRegionId] = useState("0");
|
||||
const [form, setForm] = useState(defaultRatios);
|
||||
const [form, setForm] = useState({ ratios: defaultRatios, returnCoinRatios: defaultReturnCoinRatios });
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const queryFn = useCallback(() => getGiftDiamondRatios(regionId), [regionId]);
|
||||
@ -40,13 +46,16 @@ export function GiftDiamondRatioPage() {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const next = { ...defaultRatios };
|
||||
const nextRatios = { ...defaultRatios };
|
||||
const nextReturnCoinRatios = { ...defaultReturnCoinRatios };
|
||||
for (const item of query.data?.items || []) {
|
||||
if (Object.prototype.hasOwnProperty.call(next, item.giftTypeCode)) {
|
||||
next[item.giftTypeCode] = item.ratioPercent || "100.00";
|
||||
if (Object.prototype.hasOwnProperty.call(nextRatios, item.giftTypeCode)) {
|
||||
nextRatios[item.giftTypeCode] = item.ratioPercent || defaultRatios[item.giftTypeCode];
|
||||
nextReturnCoinRatios[item.giftTypeCode] =
|
||||
item.returnCoinRatioPercent || defaultReturnCoinRatios[item.giftTypeCode];
|
||||
}
|
||||
}
|
||||
setForm(next);
|
||||
setForm({ ratios: nextRatios, returnCoinRatios: nextReturnCoinRatios });
|
||||
}, [query.data]);
|
||||
|
||||
const selectedRegionLabel = useMemo(() => {
|
||||
@ -56,31 +65,42 @@ export function GiftDiamondRatioPage() {
|
||||
return regionOptions.find((option) => option.value === regionId)?.label || `区域 ${regionId}`;
|
||||
}, [regionId, regionOptions]);
|
||||
|
||||
const changeRatio = (giftType, value) => {
|
||||
setForm((current) => ({ ...current, [giftType]: value }));
|
||||
const changeRatio = (field, giftType, value) => {
|
||||
setForm((current) => ({ ...current, [field]: { ...current[field], [giftType]: value } }));
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
const payload = {};
|
||||
const ratios = {};
|
||||
const returnCoinRatios = {};
|
||||
for (const giftType of giftTypes) {
|
||||
const value = String(form[giftType.value] || "").trim();
|
||||
const value = String(form.ratios[giftType.value] || "").trim();
|
||||
const numeric = Number(value);
|
||||
if (!value || !Number.isFinite(numeric) || numeric < 0 || numeric > 100) {
|
||||
showToast(`${giftType.label}比例必须在 0-100 之间`, "error");
|
||||
showToast(`${giftType.label}主播钻石比例必须在 0-100 之间`, "error");
|
||||
return;
|
||||
}
|
||||
payload[giftType.value] = numeric.toFixed(2);
|
||||
ratios[giftType.value] = numeric.toFixed(2);
|
||||
const returnCoinValue = String(form.returnCoinRatios[giftType.value] || "").trim();
|
||||
const returnCoinNumeric = Number(returnCoinValue);
|
||||
if (!returnCoinValue || !Number.isFinite(returnCoinNumeric) || returnCoinNumeric < 0 || returnCoinNumeric > 100) {
|
||||
showToast(`${giftType.label}返还金币比例必须在 0-100 之间`, "error");
|
||||
return;
|
||||
}
|
||||
returnCoinRatios[giftType.value] = returnCoinNumeric.toFixed(2);
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await updateGiftDiamondRatios({ regionId: Number(regionId), ratios: payload });
|
||||
const next = { ...defaultRatios };
|
||||
const result = await updateGiftDiamondRatios({ regionId: Number(regionId), ratios, returnCoinRatios });
|
||||
const nextRatios = { ...defaultRatios };
|
||||
const nextReturnCoinRatios = { ...defaultReturnCoinRatios };
|
||||
for (const item of result.items || []) {
|
||||
if (Object.prototype.hasOwnProperty.call(next, item.giftTypeCode)) {
|
||||
next[item.giftTypeCode] = item.ratioPercent || "100.00";
|
||||
if (Object.prototype.hasOwnProperty.call(nextRatios, item.giftTypeCode)) {
|
||||
nextRatios[item.giftTypeCode] = item.ratioPercent || defaultRatios[item.giftTypeCode];
|
||||
nextReturnCoinRatios[item.giftTypeCode] =
|
||||
item.returnCoinRatioPercent || defaultReturnCoinRatios[item.giftTypeCode];
|
||||
}
|
||||
}
|
||||
setForm(next);
|
||||
setForm({ ratios: nextRatios, returnCoinRatios: nextReturnCoinRatios });
|
||||
showToast("礼物钻石比例已保存", "success");
|
||||
await query.reload();
|
||||
} catch (err) {
|
||||
@ -124,21 +144,40 @@ export function GiftDiamondRatioPage() {
|
||||
<div className={styles.ratioHeader}>
|
||||
<h2>{selectedRegionLabel}</h2>
|
||||
</div>
|
||||
<div className={styles.ratioGrid}>
|
||||
<div className={styles.ratioTable}>
|
||||
<div className={styles.ratioTableHeader}>礼物类型</div>
|
||||
<div className={styles.ratioTableHeader}>主播钻石比例</div>
|
||||
<div className={styles.ratioTableHeader}>返还金币比例</div>
|
||||
{giftTypes.map((giftType) => (
|
||||
<TextField
|
||||
key={giftType.value}
|
||||
label={giftType.label}
|
||||
type="number"
|
||||
size="small"
|
||||
value={form[giftType.value] || ""}
|
||||
inputProps={{ max: 100, min: 0, step: "0.01" }}
|
||||
InputProps={{
|
||||
endAdornment: <InputAdornment position="end">%</InputAdornment>,
|
||||
}}
|
||||
disabled={!abilities.canUpdate || saving}
|
||||
onChange={(event) => changeRatio(giftType.value, event.target.value)}
|
||||
/>
|
||||
<div key={giftType.value} className={styles.ratioRow}>
|
||||
<div className={styles.ratioType}>{giftType.label}</div>
|
||||
<TextField
|
||||
label="主播钻石比例"
|
||||
type="number"
|
||||
size="small"
|
||||
value={form.ratios[giftType.value] || ""}
|
||||
inputProps={{ max: 100, min: 0, step: "0.01" }}
|
||||
InputProps={{
|
||||
endAdornment: <InputAdornment position="end">%</InputAdornment>,
|
||||
}}
|
||||
disabled={!abilities.canUpdate || saving}
|
||||
onChange={(event) => changeRatio("ratios", giftType.value, event.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="返还金币比例"
|
||||
type="number"
|
||||
size="small"
|
||||
value={form.returnCoinRatios[giftType.value] || ""}
|
||||
inputProps={{ max: 100, min: 0, step: "0.01" }}
|
||||
InputProps={{
|
||||
endAdornment: <InputAdornment position="end">%</InputAdornment>,
|
||||
}}
|
||||
disabled={!abilities.canUpdate || saving}
|
||||
onChange={(event) =>
|
||||
changeRatio("returnCoinRatios", giftType.value, event.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -22,6 +22,7 @@ import {
|
||||
listResourceGrants,
|
||||
listGifts,
|
||||
listResourceShopItems,
|
||||
listResourceShopPurchaseOrders,
|
||||
listResources,
|
||||
lookupResourceGrantTarget,
|
||||
updateGift,
|
||||
@ -327,6 +328,12 @@ test("resource shop APIs use generated admin paths", async () => {
|
||||
),
|
||||
);
|
||||
|
||||
await listResourceShopPurchaseOrders({
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
resource_type: "avatar_frame",
|
||||
user_keyword: "163000",
|
||||
});
|
||||
await listResourceShopItems({ page: 1, page_size: 10, resource_type: "avatar_frame", status: "active" });
|
||||
await upsertResourceShopItems({
|
||||
items: [
|
||||
@ -343,11 +350,16 @@ test("resource shop APIs use generated admin paths", async () => {
|
||||
await enableResourceShopItem(99);
|
||||
await disableResourceShopItem(99);
|
||||
|
||||
const [listUrl, listInit] = vi.mocked(fetch).mock.calls[0];
|
||||
const [upsertUrl, upsertInit] = vi.mocked(fetch).mock.calls[1];
|
||||
const [enableUrl, enableInit] = vi.mocked(fetch).mock.calls[2];
|
||||
const [disableUrl, disableInit] = vi.mocked(fetch).mock.calls[3];
|
||||
const [purchaseUrl, purchaseInit] = vi.mocked(fetch).mock.calls[0];
|
||||
const [listUrl, listInit] = vi.mocked(fetch).mock.calls[1];
|
||||
const [upsertUrl, upsertInit] = vi.mocked(fetch).mock.calls[2];
|
||||
const [enableUrl, enableInit] = vi.mocked(fetch).mock.calls[3];
|
||||
const [disableUrl, disableInit] = vi.mocked(fetch).mock.calls[4];
|
||||
|
||||
expect(String(purchaseUrl)).toContain("/api/v1/admin/resource-shop/purchase-orders?");
|
||||
expect(String(purchaseUrl)).toContain("resource_type=avatar_frame");
|
||||
expect(String(purchaseUrl)).toContain("user_keyword=163000");
|
||||
expect(purchaseInit?.method).toBe("GET");
|
||||
expect(String(listUrl)).toContain("/api/v1/admin/resource-shop/items?");
|
||||
expect(String(listUrl)).toContain("resource_type=avatar_frame");
|
||||
expect(String(listUrl)).toContain("status=active");
|
||||
|
||||
@ -124,6 +124,25 @@ export interface ResourceShopItemDto {
|
||||
updatedAtMs?: number;
|
||||
}
|
||||
|
||||
export interface ResourceShopPurchaseOrderDto {
|
||||
appCode?: string;
|
||||
orderId: string;
|
||||
commandId?: string;
|
||||
userId?: string;
|
||||
user?: ResourceGrantUserDto;
|
||||
shopItemId?: number;
|
||||
resourceId: number;
|
||||
resource?: ResourceDto;
|
||||
durationDays?: number;
|
||||
priceCoin?: number;
|
||||
status?: string;
|
||||
walletTransactionId?: string;
|
||||
resourceGrantId?: string;
|
||||
entitlementId?: string;
|
||||
createdAtMs?: number;
|
||||
updatedAtMs?: number;
|
||||
}
|
||||
|
||||
export interface GiftDto {
|
||||
appCode?: string;
|
||||
giftId: string;
|
||||
@ -352,6 +371,17 @@ export function listResourceShopItems(query: PageQuery = {}): Promise<ApiPage<Re
|
||||
});
|
||||
}
|
||||
|
||||
export function listResourceShopPurchaseOrders(query: PageQuery = {}): Promise<ApiPage<ResourceShopPurchaseOrderDto>> {
|
||||
const endpoint = API_ENDPOINTS.listResourceShopPurchaseOrders;
|
||||
return apiRequest<ApiPage<ResourceShopPurchaseOrderDto>>(
|
||||
apiEndpointPath(API_OPERATIONS.listResourceShopPurchaseOrders),
|
||||
{
|
||||
method: endpoint.method,
|
||||
query,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function upsertResourceShopItems(payload: ResourceShopItemsPayload): Promise<ResourceShopItemDto[]> {
|
||||
const endpoint = API_ENDPOINTS.upsertResourceShopItems;
|
||||
return apiRequest<ResourceShopItemDto[], ResourceShopItemsPayload>(
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { parseForm } from "@/shared/forms/validation";
|
||||
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
|
||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||
@ -27,6 +27,7 @@ import {
|
||||
listResourceGrants,
|
||||
listResourceGroups,
|
||||
listResourceShopItems,
|
||||
listResourceShopPurchaseOrders,
|
||||
listResources,
|
||||
lookupResourceGrantTarget,
|
||||
updateGift,
|
||||
@ -482,6 +483,11 @@ export function useResourceShopPage() {
|
||||
const [resourceType, setResourceType] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [purchaseDrawerOpen, setPurchaseDrawerOpen] = useState(false);
|
||||
const [purchasePage, setPurchasePage] = useState(1);
|
||||
const [purchaseUserKeyword, setPurchaseUserKeyword] = useState("");
|
||||
const [purchaseResourceType, setPurchaseResourceType] = useState("");
|
||||
const [purchaseKeyword, setPurchaseKeyword] = useState("");
|
||||
const [drawerQuery, setDrawerQuery] = useState("");
|
||||
const [drawerResourceType, setDrawerResourceType] = useState("");
|
||||
const [batchDurationDays, setBatchDurationDays] = useState("1");
|
||||
@ -505,6 +511,31 @@ export function useResourceShopPage() {
|
||||
pageSize,
|
||||
queryKey: ["resource-shop", filters, page],
|
||||
});
|
||||
const purchaseFilters = useMemo(
|
||||
() => ({
|
||||
keyword: purchaseKeyword,
|
||||
resource_type: purchaseResourceType,
|
||||
user_keyword: purchaseUserKeyword,
|
||||
}),
|
||||
[purchaseKeyword, purchaseResourceType, purchaseUserKeyword],
|
||||
);
|
||||
const fetchPurchaseOrders = useCallback(
|
||||
(requestQuery) => {
|
||||
if (!purchaseDrawerOpen) {
|
||||
return Promise.resolve(emptyData);
|
||||
}
|
||||
return listResourceShopPurchaseOrders(requestQuery);
|
||||
},
|
||||
[purchaseDrawerOpen],
|
||||
);
|
||||
const purchaseResult = usePaginatedQuery({
|
||||
errorMessage: "加载售卖记录失败",
|
||||
fetcher: fetchPurchaseOrders,
|
||||
filters: purchaseFilters,
|
||||
page: purchasePage,
|
||||
pageSize,
|
||||
queryKey: ["resource-shop-purchase-orders", purchaseDrawerOpen, purchaseFilters, purchasePage],
|
||||
});
|
||||
const shopItemsByResourceId = useMemo(() => {
|
||||
const items = new Map();
|
||||
(result.data?.items || []).forEach((item) => {
|
||||
@ -579,6 +610,15 @@ export function useResourceShopPage() {
|
||||
setDraftByResourceId({});
|
||||
};
|
||||
|
||||
const openPurchaseDrawer = () => {
|
||||
setPurchaseDrawerOpen(true);
|
||||
setPurchasePage(1);
|
||||
};
|
||||
|
||||
const closePurchaseDrawer = () => {
|
||||
setPurchaseDrawerOpen(false);
|
||||
};
|
||||
|
||||
const toggleDraftResource = (resource) => {
|
||||
if (!resource?.resourceId) {
|
||||
return;
|
||||
@ -692,12 +732,24 @@ export function useResourceShopPage() {
|
||||
changeResourceType: resetSetter(setResourceType, setPage),
|
||||
changeStatus: resetSetter(setStatus, setPage),
|
||||
closeShopDrawer,
|
||||
closePurchaseDrawer,
|
||||
drawerOpen,
|
||||
drawerQuery,
|
||||
drawerResourceType,
|
||||
filteredResourceOptions,
|
||||
loadingAction,
|
||||
openShopDrawer,
|
||||
openPurchaseDrawer,
|
||||
purchaseData: purchaseResult.data,
|
||||
purchaseDrawerOpen,
|
||||
purchaseError: purchaseResult.error,
|
||||
purchaseKeyword,
|
||||
purchaseLoading: purchaseResult.loading,
|
||||
purchasePage,
|
||||
purchaseResourceType,
|
||||
purchaseUserKeyword,
|
||||
reloadPurchaseOrders: purchaseResult.reload,
|
||||
setPurchasePage,
|
||||
resourceOptionsLoading,
|
||||
resourceType,
|
||||
resetFilters,
|
||||
@ -707,6 +759,9 @@ export function useResourceShopPage() {
|
||||
submitShopItems,
|
||||
toggleDraftResource,
|
||||
toggleShopItem,
|
||||
changePurchaseKeyword: resetSetter(setPurchaseKeyword, setPurchasePage),
|
||||
changePurchaseResourceType: resetSetter(setPurchaseResourceType, setPurchasePage),
|
||||
changePurchaseUserKeyword: resetSetter(setPurchaseUserKeyword, setPurchasePage),
|
||||
updateDraftItem,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import Add from "@mui/icons-material/Add";
|
||||
import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined";
|
||||
import StorefrontOutlined from "@mui/icons-material/StorefrontOutlined";
|
||||
import Checkbox from "@mui/material/Checkbox";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
@ -70,6 +72,39 @@ const shopColumns = [
|
||||
},
|
||||
];
|
||||
|
||||
const purchaseColumns = [
|
||||
{
|
||||
key: "user",
|
||||
label: "用户信息",
|
||||
width: "minmax(210px, 1fr)",
|
||||
render: (item) => <PurchaseUser order={item} />,
|
||||
},
|
||||
{
|
||||
key: "resourceType",
|
||||
label: "道具类型",
|
||||
width: "minmax(120px, 0.55fr)",
|
||||
render: (item) => resourceTypeLabels[item.resource?.resourceType] || item.resource?.resourceType || "-",
|
||||
},
|
||||
{
|
||||
key: "resource",
|
||||
label: "购买素材",
|
||||
width: "minmax(260px, 1.1fr)",
|
||||
render: (item) => <ResourceIdentity resource={item.resource} />,
|
||||
},
|
||||
{
|
||||
key: "price",
|
||||
label: "购买价格",
|
||||
width: "minmax(130px, 0.55fr)",
|
||||
render: (item) => coinPriceLabel(item.priceCoin),
|
||||
},
|
||||
{
|
||||
key: "time",
|
||||
label: "购买时间",
|
||||
width: "minmax(170px, 0.7fr)",
|
||||
render: (item) => formatMillis(item.createdAtMs),
|
||||
},
|
||||
];
|
||||
|
||||
export function ResourceShopPage() {
|
||||
const page = useResourceShopPage();
|
||||
const items = page.data.items || [];
|
||||
@ -115,11 +150,18 @@ export function ResourceShopPage() {
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
actions={
|
||||
page.abilities.canUpdateShop ? (
|
||||
<AdminActionIconButton label="添加售卖资源" primary onClick={page.openShopDrawer}>
|
||||
<Add fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null
|
||||
<>
|
||||
{page.abilities.canViewShop ? (
|
||||
<AdminActionIconButton label="售卖记录" onClick={page.openPurchaseDrawer}>
|
||||
<ReceiptLongOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
{page.abilities.canUpdateShop ? (
|
||||
<AdminActionIconButton label="添加售卖资源" primary onClick={page.openShopDrawer}>
|
||||
<Add fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
@ -143,6 +185,7 @@ export function ResourceShopPage() {
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
<ResourceShopDrawer page={page} />
|
||||
<ResourceShopPurchaseDrawer page={page} />
|
||||
</AdminListPage>
|
||||
);
|
||||
}
|
||||
@ -227,6 +270,69 @@ function ResourceShopDrawer({ page }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ResourceShopPurchaseDrawer({ page }) {
|
||||
const data = page.purchaseData || {};
|
||||
const items = data.items || [];
|
||||
const total = data.total || 0;
|
||||
const columns = purchaseColumns.map((column) => {
|
||||
if (column.key === "user") {
|
||||
return {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索用户ID、短号、靓号、昵称",
|
||||
value: page.purchaseUserKeyword,
|
||||
onChange: page.changePurchaseUserKeyword,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "resourceType") {
|
||||
return {
|
||||
...column,
|
||||
filter: createOptionsColumnFilter({
|
||||
options: resourceShopTypeFilters,
|
||||
placeholder: "搜索道具类型",
|
||||
value: page.purchaseResourceType,
|
||||
onChange: page.changePurchaseResourceType,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "resource") {
|
||||
return {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索素材名称、素材编码、订单号",
|
||||
value: page.purchaseKeyword,
|
||||
onChange: page.changePurchaseKeyword,
|
||||
}),
|
||||
};
|
||||
}
|
||||
return column;
|
||||
});
|
||||
|
||||
return (
|
||||
<SideDrawer open={page.purchaseDrawerOpen} title="售卖记录" width="wide" onClose={page.closePurchaseDrawer}>
|
||||
<DataState error={page.purchaseError} loading={page.purchaseLoading} onRetry={page.reloadPurchaseOrders}>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
items={items}
|
||||
minWidth="980px"
|
||||
pagination={
|
||||
total > 0
|
||||
? {
|
||||
page: page.purchasePage,
|
||||
pageSize: data.pageSize || 50,
|
||||
total,
|
||||
onPageChange: page.setPurchasePage,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
rowKey={(item) => item.orderId}
|
||||
/>
|
||||
</DataState>
|
||||
</SideDrawer>
|
||||
);
|
||||
}
|
||||
|
||||
function ResourceSelectionRow({ disabled, page, resource }) {
|
||||
const resourceId = String(resource.resourceId);
|
||||
const selected = page.selectedResourceIds.has(resourceId);
|
||||
@ -316,6 +422,14 @@ function ResourceIdentity({ resource = {} }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PurchaseUser({ order }) {
|
||||
const user = order.user || { userId: order.userId };
|
||||
if (!user?.userId && !user?.displayUserId && !user?.username) {
|
||||
return "-";
|
||||
}
|
||||
return <AdminUserIdentity openInAppUserDetail user={user} />;
|
||||
}
|
||||
|
||||
function SaleSpec({ item }) {
|
||||
return (
|
||||
<div className={styles.stack}>
|
||||
@ -342,3 +456,10 @@ function resourcePriceLabel(item = {}) {
|
||||
}
|
||||
return resourcePriceTypeLabels[item.priceType] || "-";
|
||||
}
|
||||
|
||||
function coinPriceLabel(value) {
|
||||
if (value === 0 || value) {
|
||||
return `${Number(value).toLocaleString("zh-CN")}金币`;
|
||||
}
|
||||
return "-";
|
||||
}
|
||||
|
||||
@ -46,6 +46,8 @@ function emptyForm() {
|
||||
function emptyHumanConfigForm() {
|
||||
return {
|
||||
candidateRoomMaxOnline: "5",
|
||||
countryLimitEnabled: false,
|
||||
countryRules: [],
|
||||
enabled: false,
|
||||
giftIds: [],
|
||||
luckyComboMax: "3",
|
||||
@ -66,6 +68,13 @@ function emptyHumanConfigForm() {
|
||||
};
|
||||
}
|
||||
|
||||
function emptyHumanCountryRule(countryOptions = [], existingRules = []) {
|
||||
return {
|
||||
countryCode: firstAvailableCountryCode(countryOptions, existingRules),
|
||||
maxRoomCount: "1",
|
||||
};
|
||||
}
|
||||
|
||||
export function useRobotRoomsPage() {
|
||||
const abilities = useRoomAbilities();
|
||||
const { countryOptions, loadingCountries } = useCountryOptions();
|
||||
@ -174,6 +183,16 @@ export function useRobotRoomsPage() {
|
||||
setHumanConfigForm(humanConfigToForm(humanConfigState.config));
|
||||
};
|
||||
|
||||
const openHumanCountryConfig = () => {
|
||||
const form = humanConfigToForm(humanConfigState.config);
|
||||
setActiveAction("human-config");
|
||||
setHumanConfigForm({
|
||||
...form,
|
||||
countryLimitEnabled: true,
|
||||
countryRules: form.countryRules.length ? form.countryRules : [emptyHumanCountryRule(countryOptions)],
|
||||
});
|
||||
};
|
||||
|
||||
const closeAction = () => {
|
||||
setActiveAction("");
|
||||
};
|
||||
@ -232,6 +251,41 @@ export function useRobotRoomsPage() {
|
||||
setHumanConfigForm((current) => ({ ...current, [key]: gifts.map(giftId).filter(Boolean) }));
|
||||
};
|
||||
|
||||
const setHumanCountryLimitEnabled = (enabled) => {
|
||||
setHumanConfigForm((current) => ({
|
||||
...current,
|
||||
countryLimitEnabled: enabled,
|
||||
countryRules:
|
||||
enabled && current.countryRules.length === 0
|
||||
? [emptyHumanCountryRule(countryOptions, current.countryRules)]
|
||||
: current.countryRules,
|
||||
}));
|
||||
};
|
||||
|
||||
const addHumanCountryRule = () => {
|
||||
setHumanConfigForm((current) => ({
|
||||
...current,
|
||||
countryLimitEnabled: true,
|
||||
countryRules: [...current.countryRules, emptyHumanCountryRule(countryOptions, current.countryRules)],
|
||||
}));
|
||||
};
|
||||
|
||||
const updateHumanCountryRule = (index, patch) => {
|
||||
setHumanConfigForm((current) => ({
|
||||
...current,
|
||||
countryRules: current.countryRules.map((rule, currentIndex) =>
|
||||
currentIndex === index ? { ...rule, ...patch } : rule,
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const removeHumanCountryRule = (index) => {
|
||||
setHumanConfigForm((current) => ({
|
||||
...current,
|
||||
countryRules: current.countryRules.filter((_, currentIndex) => currentIndex !== index),
|
||||
}));
|
||||
};
|
||||
|
||||
const submitHumanConfig = async (event) => {
|
||||
event.preventDefault();
|
||||
await runAction("human-config", "真人房间机器人配置已保存", async () => {
|
||||
@ -326,7 +380,9 @@ export function useRobotRoomsPage() {
|
||||
loading,
|
||||
loadingAction,
|
||||
loadingRobotLocationOptions: loadingCountries || loadingRegions,
|
||||
addHumanCountryRule,
|
||||
openCreate,
|
||||
openHumanCountryConfig,
|
||||
openHumanConfig,
|
||||
page,
|
||||
reload,
|
||||
@ -343,12 +399,15 @@ export function useRobotRoomsPage() {
|
||||
robotCountryLabels: countryLabels,
|
||||
robotRegionLabels: regionLabels,
|
||||
setForm,
|
||||
setHumanCountryLimitEnabled,
|
||||
setHumanConfigForm,
|
||||
setPage,
|
||||
setRoomStatus,
|
||||
status,
|
||||
submitCreate,
|
||||
submitHumanConfig,
|
||||
updateHumanCountryRule,
|
||||
removeHumanCountryRule,
|
||||
};
|
||||
}
|
||||
|
||||
@ -360,6 +419,11 @@ function humanConfigToForm(config = null) {
|
||||
return {
|
||||
...defaults,
|
||||
candidateRoomMaxOnline: String(config.candidateRoomMaxOnline ?? defaults.candidateRoomMaxOnline),
|
||||
countryLimitEnabled: Boolean(config.countryLimitEnabled),
|
||||
countryRules: (config.countryRules || []).map((rule) => ({
|
||||
countryCode: String(rule.countryCode || "").toUpperCase(),
|
||||
maxRoomCount: String(rule.maxRoomCount ?? "1"),
|
||||
})),
|
||||
enabled: Boolean(config.enabled),
|
||||
giftIds: (config.giftIds || []).map(String),
|
||||
luckyComboMax: String(config.luckyComboMax ?? defaults.luckyComboMax),
|
||||
@ -388,6 +452,12 @@ function humanConfigToForm(config = null) {
|
||||
};
|
||||
}
|
||||
|
||||
function firstAvailableCountryCode(countryOptions = [], existingRules = []) {
|
||||
const used = new Set(existingRules.map((rule) => String(rule.countryCode || "").toUpperCase()).filter(Boolean));
|
||||
const option = countryOptions.find((country) => country.value && !used.has(String(country.value).toUpperCase()));
|
||||
return option?.value || "";
|
||||
}
|
||||
|
||||
export function robotOptionLabel(robot) {
|
||||
if (!robot) {
|
||||
return "";
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||
import BedroomParentOutlined from "@mui/icons-material/BedroomParentOutlined";
|
||||
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||
import TuneOutlined from "@mui/icons-material/TuneOutlined";
|
||||
import Autocomplete from "@mui/material/Autocomplete";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
@ -410,14 +412,24 @@ function HumanConfigPanel({ page }) {
|
||||
]}
|
||||
/>
|
||||
{page.abilities.canRobotUpdate ? (
|
||||
<Button
|
||||
loading={page.humanConfigLoading}
|
||||
startIcon={<TuneOutlined fontSize="small" />}
|
||||
variant="secondary"
|
||||
onClick={page.openHumanConfig}
|
||||
>
|
||||
配置
|
||||
</Button>
|
||||
<div className={styles.humanConfigActions}>
|
||||
<Button
|
||||
loading={page.humanConfigLoading}
|
||||
startIcon={<TuneOutlined fontSize="small" />}
|
||||
variant="secondary"
|
||||
onClick={page.openHumanCountryConfig}
|
||||
>
|
||||
{config.countryLimitEnabled ? "国家配置" : "国家启用"}
|
||||
</Button>
|
||||
<Button
|
||||
loading={page.humanConfigLoading}
|
||||
startIcon={<TuneOutlined fontSize="small" />}
|
||||
variant="secondary"
|
||||
onClick={page.openHumanConfig}
|
||||
>
|
||||
配置
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
@ -446,6 +458,15 @@ function HumanConfigDialog({ page }) {
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.switchField}>
|
||||
<AdminSwitch
|
||||
checked={Boolean(page.humanConfigForm.countryLimitEnabled)}
|
||||
checkedLabel="启用"
|
||||
label="启用国家配置"
|
||||
uncheckedLabel="关闭"
|
||||
onChange={(_, checked) => page.setHumanCountryLimitEnabled(checked)}
|
||||
/>
|
||||
</label>
|
||||
<TextField
|
||||
label="真人房间低于人数"
|
||||
required
|
||||
@ -608,16 +629,77 @@ function HumanConfigDialog({ page }) {
|
||||
value={page.selectedHumanGiftOptions.superLuckyGiftIds}
|
||||
onChange={(options) => page.selectHumanGiftIds("superLuckyGiftIds", options)}
|
||||
/>
|
||||
{page.humanConfigForm.countryLimitEnabled ? <HumanCountryRules page={page} /> : null}
|
||||
<section className={styles.robotAutoPoolNotice}>
|
||||
<div className={styles.primaryText}>机器人池自动获取</div>
|
||||
<div className={styles.meta}>
|
||||
系统会读取所有启用的全站机器人,并按机器人账号国家进入同国家真人房间。
|
||||
{page.humanConfigForm.countryLimitEnabled
|
||||
? "国家配置启用后,只会读取已配置国家的机器人,并限制每个国家同时进入的真人房数量。"
|
||||
: "系统会读取所有启用的全站机器人,并按机器人账号国家进入同国家真人房间。"}
|
||||
</div>
|
||||
</section>
|
||||
</AdminFormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function HumanCountryRules({ page }) {
|
||||
const rows = page.humanConfigForm.countryRules || [];
|
||||
return (
|
||||
<section className={styles.countryRulesSection}>
|
||||
<div className={styles.countryRulesHeader}>
|
||||
<div className={styles.primaryText}>国家进房配置</div>
|
||||
<Button
|
||||
startIcon={<AddOutlined fontSize="small" />}
|
||||
variant="secondary"
|
||||
onClick={page.addHumanCountryRule}
|
||||
>
|
||||
添加国家
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.countryRuleRows}>
|
||||
{rows.map((rule, index) => (
|
||||
<div className={styles.countryRuleRow} key={`${rule.countryCode || "country"}-${index}`}>
|
||||
<TextField
|
||||
label="国家"
|
||||
required
|
||||
select
|
||||
size="small"
|
||||
value={rule.countryCode || ""}
|
||||
onChange={(event) =>
|
||||
page.updateHumanCountryRule(index, { countryCode: event.target.value })
|
||||
}
|
||||
>
|
||||
{countrySelectOptions(page.countryOptions, rule.countryCode).map((option) => (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
label="进房数量"
|
||||
required
|
||||
size="small"
|
||||
type="number"
|
||||
value={rule.maxRoomCount}
|
||||
onChange={(event) =>
|
||||
page.updateHumanCountryRule(index, { maxRoomCount: event.target.value })
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
startIcon={<DeleteOutlineOutlined fontSize="small" />}
|
||||
variant="secondary"
|
||||
onClick={() => page.removeHumanCountryRule(index)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{rows.length === 0 ? <div className={styles.meta}>当前没有国家配置</div> : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function GiftAutocomplete({ label, loading, onChange, options, required = false, value }) {
|
||||
return (
|
||||
<Autocomplete
|
||||
@ -682,6 +764,21 @@ function RequiredLabel({ children }) {
|
||||
);
|
||||
}
|
||||
|
||||
function countrySelectOptions(options = [], currentValue = "") {
|
||||
const current = String(currentValue || "").toUpperCase();
|
||||
const merged = options.map((option) => ({
|
||||
label: option.label,
|
||||
value: option.value,
|
||||
}));
|
||||
if (current && !merged.some((option) => option.value === current)) {
|
||||
merged.unshift({ label: current, value: current });
|
||||
}
|
||||
if (!merged.length) {
|
||||
return current ? [{ label: current, value: current }] : [{ label: "暂无可用国家", value: "" }];
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function RobotRoomStatus({ page, room }) {
|
||||
const checked = room.status === "active";
|
||||
return (
|
||||
|
||||
@ -64,6 +64,13 @@
|
||||
background: var(--bg-card-strong);
|
||||
}
|
||||
|
||||
.humanConfigActions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.formGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@ -288,6 +295,47 @@
|
||||
background: var(--bg-card-strong);
|
||||
}
|
||||
|
||||
.countryRulesSection {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--bg-card-strong);
|
||||
}
|
||||
|
||||
.countryRulesHeader {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.countryRuleRows {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.countryRuleRow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 1fr) minmax(140px, 0.6fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.countryRulesHeader,
|
||||
.humanConfigActions {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.countryRuleRow {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@ -40,6 +40,8 @@ describe("human room robot config schema", () => {
|
||||
test("preserves robot ids and parses enabled config", () => {
|
||||
const payload = parseForm(humanRoomRobotConfigSchema, {
|
||||
candidateRoomMaxOnline: "5",
|
||||
countryLimitEnabled: true,
|
||||
countryRules: [{ countryCode: "sa", maxRoomCount: "2" }],
|
||||
enabled: true,
|
||||
giftIds: ["84"],
|
||||
luckyComboMax: "3",
|
||||
@ -65,6 +67,38 @@ describe("human room robot config schema", () => {
|
||||
expect(payload.normalGiftIntervalMinMs).toBe(1000);
|
||||
expect(payload.normalGiftIntervalMaxMs).toBe(20000);
|
||||
expect(payload.giftIds).toEqual(["84"]);
|
||||
expect(payload.countryLimitEnabled).toBe(true);
|
||||
expect(payload.countryRules).toEqual([{ countryCode: "SA", maxRoomCount: 2 }]);
|
||||
});
|
||||
|
||||
test("rejects duplicated country rules", () => {
|
||||
expect(() =>
|
||||
parseForm(humanRoomRobotConfigSchema, {
|
||||
candidateRoomMaxOnline: "5",
|
||||
countryLimitEnabled: true,
|
||||
countryRules: [
|
||||
{ countryCode: "SA", maxRoomCount: "2" },
|
||||
{ countryCode: "sa", maxRoomCount: "3" },
|
||||
],
|
||||
enabled: true,
|
||||
giftIds: ["84"],
|
||||
luckyComboMax: "3",
|
||||
luckyComboMin: "1",
|
||||
luckyGiftIds: ["28"],
|
||||
luckyPauseMaxMs: "20000",
|
||||
luckyPauseMinMs: "5000",
|
||||
maxGiftSenders: "1",
|
||||
normalGiftIntervalMaxMs: "20000",
|
||||
normalGiftIntervalMinMs: "1000",
|
||||
robotReplaceMaxMs: "180000",
|
||||
robotReplaceMinMs: "60000",
|
||||
robotStayMaxMs: "600000",
|
||||
robotStayMinMs: "60000",
|
||||
roomTargetMaxOnline: "10",
|
||||
roomTargetMinOnline: "8",
|
||||
superLuckyGiftIds: [],
|
||||
}),
|
||||
).toThrow("国家不能重复");
|
||||
});
|
||||
|
||||
test("requires gift pools when enabled", () => {
|
||||
|
||||
@ -7,6 +7,11 @@ const robotUserIdSchema = (message: string) =>
|
||||
.union([z.string(), z.number()])
|
||||
.transform((value) => String(value).trim())
|
||||
.refine((value) => /^[1-9]\d*$/.test(value), message);
|
||||
const countryCodeSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((value) => value.toUpperCase())
|
||||
.refine((value) => /^[A-Z]{2,3}$/.test(value), "国家码不正确");
|
||||
const pinTypeSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
@ -137,6 +142,15 @@ export const robotRoomCreateSchema = z
|
||||
export const humanRoomRobotConfigSchema = z
|
||||
.object({
|
||||
candidateRoomMaxOnline: z.coerce.number().int().min(1, "真人房间筛选人数不正确"),
|
||||
countryLimitEnabled: z.coerce.boolean().default(false),
|
||||
countryRules: z
|
||||
.array(
|
||||
z.object({
|
||||
countryCode: countryCodeSchema,
|
||||
maxRoomCount: z.coerce.number().int().min(1, "国家进房数量不正确"),
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
enabled: z.coerce.boolean().default(false),
|
||||
giftIds: z.array(z.string().trim().min(1)).default([]),
|
||||
luckyComboMax: z.coerce.number().int().min(1, "幸运礼物连击范围不正确"),
|
||||
@ -193,6 +207,26 @@ export const humanRoomRobotConfigSchema = z
|
||||
.refine((value) => !value.enabled || value.luckyGiftIds.length + value.superLuckyGiftIds.length > 0, {
|
||||
message: "启用后请选择幸运礼物或超级幸运礼物合集",
|
||||
path: ["luckyGiftIds"],
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
if (value.enabled && value.countryLimitEnabled && value.countryRules.length === 0) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "启用国家配置后至少选择一个国家",
|
||||
path: ["countryRules"],
|
||||
});
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
value.countryRules.forEach((rule, index) => {
|
||||
if (seen.has(rule.countryCode)) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "国家不能重复",
|
||||
path: ["countryRules", index, "countryCode"],
|
||||
});
|
||||
}
|
||||
seen.add(rule.countryCode);
|
||||
});
|
||||
});
|
||||
|
||||
export type RoomUpdateForm = z.infer<typeof roomUpdateSchema>;
|
||||
|
||||
@ -145,6 +145,8 @@ export const API_OPERATIONS = {
|
||||
listCoinSellerLedger: "listCoinSellerLedger",
|
||||
listCoinSellers: "listCoinSellers",
|
||||
listCountries: "listCountries",
|
||||
listCPApplications: "listCPApplications",
|
||||
listCPRelationships: "listCPRelationships",
|
||||
listCumulativeRechargeRewardGrants: "listCumulativeRechargeRewardGrants",
|
||||
listDiceRobots: "listDiceRobots",
|
||||
listEmojiPackCategories: "listEmojiPackCategories",
|
||||
@ -180,6 +182,7 @@ export const API_OPERATIONS = {
|
||||
listResourceGroups: "listResourceGroups",
|
||||
listResources: "listResources",
|
||||
listResourceShopItems: "listResourceShopItems",
|
||||
listResourceShopPurchaseOrders: "listResourceShopPurchaseOrders",
|
||||
listRobotRooms: "listRobotRooms",
|
||||
listRoles: "listRoles",
|
||||
listRoomPins: "listRoomPins",
|
||||
@ -1211,6 +1214,20 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "country:view",
|
||||
permissions: ["country:view"]
|
||||
},
|
||||
listCPApplications: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listCPApplications,
|
||||
path: "/v1/admin/activity/cp/applications",
|
||||
permission: "cp-config:view",
|
||||
permissions: ["cp-config:view"]
|
||||
},
|
||||
listCPRelationships: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listCPRelationships,
|
||||
path: "/v1/admin/activity/cp/relationships",
|
||||
permission: "cp-config:view",
|
||||
permissions: ["cp-config:view"]
|
||||
},
|
||||
listCumulativeRechargeRewardGrants: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listCumulativeRechargeRewardGrants,
|
||||
@ -1456,6 +1473,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "resource-shop:view",
|
||||
permissions: ["resource-shop:view"]
|
||||
},
|
||||
listResourceShopPurchaseOrders: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listResourceShopPurchaseOrders,
|
||||
path: "/v1/admin/resource-shop/purchase-orders",
|
||||
permission: "resource-shop:view",
|
||||
permissions: ["resource-shop:view"]
|
||||
},
|
||||
listRobotRooms: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listRobotRooms,
|
||||
|
||||
136
src/shared/api/generated/schema.d.ts
vendored
136
src/shared/api/generated/schema.d.ts
vendored
@ -116,6 +116,38 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/activity/cp/relationships": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["listCPRelationships"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/activity/cp/applications": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["listCPApplications"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/activity/red-packets": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -1988,6 +2020,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/resource-shop/purchase-orders": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["listResourceShopPurchaseOrders"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/resource-shop/items/{shop_item_id}/disable": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -3186,6 +3234,9 @@ export interface components {
|
||||
ApiResponseHostProfilePage: components["schemas"]["Envelope"] & {
|
||||
data?: components["schemas"]["ApiPageHostProfile"];
|
||||
};
|
||||
ApiResponseHumanRoomRobotConfig: components["schemas"]["Envelope"] & {
|
||||
data?: components["schemas"]["HumanRoomRobotConfig"];
|
||||
};
|
||||
ApiResponseLogPage: components["schemas"]["Envelope"] & {
|
||||
data?: components["schemas"]["ApiPageLog"];
|
||||
};
|
||||
@ -3298,6 +3349,36 @@ export interface components {
|
||||
updatedAtMs?: number;
|
||||
userId: string;
|
||||
};
|
||||
HumanRoomRobotCountryRule: {
|
||||
countryCode: string;
|
||||
maxRoomCount: number;
|
||||
};
|
||||
HumanRoomRobotConfig: {
|
||||
candidateRoomMaxOnline?: number;
|
||||
countryLimitEnabled?: boolean;
|
||||
countryRules?: components["schemas"]["HumanRoomRobotCountryRule"][];
|
||||
enabled?: boolean;
|
||||
giftIds?: string[];
|
||||
luckyComboMax?: number;
|
||||
luckyComboMin?: number;
|
||||
luckyGiftIds?: string[];
|
||||
luckyPauseMaxMs?: number;
|
||||
luckyPauseMinMs?: number;
|
||||
maxGiftSenders?: number;
|
||||
normalGiftIntervalMaxMs?: number;
|
||||
normalGiftIntervalMinMs?: number;
|
||||
robotReplaceMaxMs?: number;
|
||||
robotReplaceMinMs?: number;
|
||||
robotStayMaxMs?: number;
|
||||
robotStayMinMs?: number;
|
||||
roomFullStopOnline?: number;
|
||||
roomTargetMaxOnline?: number;
|
||||
roomTargetMinOnline?: number;
|
||||
superLuckyGiftIds?: string[];
|
||||
updatedAtMs?: number;
|
||||
updatedByAdminId?: number;
|
||||
};
|
||||
HumanRoomRobotConfigInput: components["schemas"]["HumanRoomRobotConfig"];
|
||||
Log: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
@ -3406,6 +3487,15 @@ export interface components {
|
||||
};
|
||||
};
|
||||
/** @description OK */
|
||||
HumanRoomRobotConfigResponse: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ApiResponseHumanRoomRobotConfig"];
|
||||
};
|
||||
};
|
||||
/** @description OK */
|
||||
LogPageResponse: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
@ -3733,6 +3823,30 @@ export interface operations {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
listCPRelationships: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
listCPApplications: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
listRedPackets: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -5966,6 +6080,18 @@ export interface operations {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
listResourceShopPurchaseOrders: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
disableResourceShopItem: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -6339,7 +6465,7 @@ export interface operations {
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
200: components["responses"]["HumanRoomRobotConfigResponse"];
|
||||
};
|
||||
};
|
||||
updateHumanRoomRobotConfig: {
|
||||
@ -6349,9 +6475,13 @@ export interface operations {
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
requestBody?: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["HumanRoomRobotConfigInput"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
200: components["responses"]["HumanRoomRobotConfigResponse"];
|
||||
};
|
||||
};
|
||||
startRobotRoom: {
|
||||
|
||||
@ -1035,6 +1035,8 @@ export interface RobotRoomPayload {
|
||||
|
||||
export interface HumanRoomRobotConfigDto {
|
||||
candidateRoomMaxOnline?: number;
|
||||
countryLimitEnabled?: boolean;
|
||||
countryRules?: HumanRoomRobotCountryRuleDto[];
|
||||
enabled?: boolean;
|
||||
giftIds?: string[];
|
||||
luckyComboMax?: number;
|
||||
@ -1058,8 +1060,15 @@ export interface HumanRoomRobotConfigDto {
|
||||
updatedByAdminId?: number;
|
||||
}
|
||||
|
||||
export interface HumanRoomRobotCountryRuleDto {
|
||||
countryCode: string;
|
||||
maxRoomCount: number;
|
||||
}
|
||||
|
||||
export interface HumanRoomRobotConfigPayload {
|
||||
candidateRoomMaxOnline: number;
|
||||
countryLimitEnabled: boolean;
|
||||
countryRules: HumanRoomRobotCountryRuleDto[];
|
||||
enabled: boolean;
|
||||
giftIds: string[];
|
||||
luckyComboMax: number;
|
||||
|
||||
339
src/shared/utils/rtpProbability.js
Normal file
339
src/shared/utils/rtpProbability.js
Normal file
@ -0,0 +1,339 @@
|
||||
export const minimumCorrectedProbabilityPercent = 0.0001;
|
||||
|
||||
const ppmScale = 1_000_000;
|
||||
|
||||
export function correctRTPProbabilities(tiers, targetRTPPercent, options = {}) {
|
||||
const enabled = options.enabled || ((item) => item?.enabled !== false);
|
||||
const multiplier = options.multiplier || ((item) => item?.multiplier);
|
||||
const participatesInRTP = options.participatesInRTP || (() => true);
|
||||
const fallback = options.fallback || "unchanged";
|
||||
const targetRTP = decimal(targetRTPPercent);
|
||||
const enabledTiers = (tiers || [])
|
||||
.map((item, index) => ({ index, multiplier: decimal(multiplier(item, index)), tier: item }))
|
||||
.filter((item) => enabled(item.tier, item.index));
|
||||
const baseTiers = enabledTiers.filter(
|
||||
(item) => participatesInRTP(item.tier, item.index) && Number.isFinite(item.multiplier),
|
||||
);
|
||||
const positiveTiers = baseTiers
|
||||
.filter((item) => item.multiplier > 0)
|
||||
.sort((left, right) => left.multiplier - right.multiplier);
|
||||
const zeroTiers = baseTiers.filter((item) => item.multiplier === 0);
|
||||
|
||||
if (enabledTiers.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
if (positiveTiers.length === 0) {
|
||||
return fallback === "equal" ? equalProbabilities(enabledTiers) : null;
|
||||
}
|
||||
|
||||
const probabilityByIndex = baselineProbabilities(enabledTiers, {
|
||||
baseTiers,
|
||||
baselineProbability: options.baselineProbability,
|
||||
positiveTiers,
|
||||
});
|
||||
const floorWeights = floorWeightsFromProbabilities(probabilityByIndex);
|
||||
const fixedProbability = sumProbabilities(probabilityByIndex);
|
||||
if (fixedProbability >= 100) {
|
||||
return probabilityByIndex;
|
||||
}
|
||||
|
||||
const fixedExpectedRTP = positiveTiers.reduce(
|
||||
(sum, item) => sum + item.multiplier * (probabilityByIndex.get(item.index) || 0),
|
||||
0,
|
||||
);
|
||||
const remainingProbability = 100 - fixedProbability;
|
||||
const targetExtraRTP = targetRTP - fixedExpectedRTP;
|
||||
const contributionPlan = balancedRTPContributionPlan(positiveTiers, targetExtraRTP, remainingProbability);
|
||||
|
||||
addContributionProbabilities(probabilityByIndex, contributionPlan);
|
||||
assignResidualProbability(probabilityByIndex, zeroTiers, positiveTiers);
|
||||
alignExpectedRTPByWeightPPM(probabilityByIndex, baseTiers, targetRTP, floorWeights);
|
||||
return probabilityByIndex;
|
||||
}
|
||||
|
||||
export function applyProbabilityMap(tiers, probabilityByIndex, format = formatDecimal) {
|
||||
if (!probabilityByIndex) {
|
||||
return tiers;
|
||||
}
|
||||
return (tiers || []).map((item, index) => ({
|
||||
...item,
|
||||
probabilityPercent: format(probabilityByIndex.get(index) || 0),
|
||||
}));
|
||||
}
|
||||
|
||||
export function probabilityTotal(tiers, options = {}) {
|
||||
const enabled = options.enabled || ((item) => item?.enabled !== false);
|
||||
return (tiers || []).reduce((sum, item, index) => {
|
||||
if (!enabled(item, index)) {
|
||||
return sum;
|
||||
}
|
||||
return sum + decimal(options.probability ? options.probability(item, index) : item?.probabilityPercent);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export function expectedRTPPercent(tiers, options = {}) {
|
||||
const enabled = options.enabled || ((item) => item?.enabled !== false);
|
||||
const multiplier = options.multiplier || ((item) => item?.multiplier);
|
||||
const participatesInRTP = options.participatesInRTP || (() => true);
|
||||
return (tiers || []).reduce((sum, item, index) => {
|
||||
if (!enabled(item, index) || !participatesInRTP(item, index)) {
|
||||
return sum;
|
||||
}
|
||||
return sum + expectedContributionPercent(multiplier(item, index), item?.probabilityPercent);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export function tierRTPContributionPercent(tier, options = {}) {
|
||||
const enabled = options.enabled || ((item) => item?.enabled !== false);
|
||||
const multiplier = options.multiplier || ((item) => item?.multiplier);
|
||||
const participatesInRTP = options.participatesInRTP || (() => true);
|
||||
if (!enabled(tier, 0) || !participatesInRTP(tier, 0)) {
|
||||
return 0;
|
||||
}
|
||||
return expectedContributionPercent(multiplier(tier, 0), tier?.probabilityPercent);
|
||||
}
|
||||
|
||||
export function percentToWeightPPM(value) {
|
||||
return Math.round(decimal(value) * 10_000);
|
||||
}
|
||||
|
||||
export function formatDecimal(value) {
|
||||
const number = Number(value || 0);
|
||||
if (!Number.isFinite(number)) {
|
||||
return "0";
|
||||
}
|
||||
return Number.isInteger(number) ? String(number) : number.toFixed(6).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
export function decimal(value) {
|
||||
const number = Number(value || 0);
|
||||
return Number.isFinite(number) ? number : 0;
|
||||
}
|
||||
|
||||
function equalProbabilities(enabledTiers) {
|
||||
const probabilities = new Map();
|
||||
const base = Math.floor(ppmScale / enabledTiers.length);
|
||||
let remainder = ppmScale - base * enabledTiers.length;
|
||||
for (const item of enabledTiers) {
|
||||
const ppm = base + (remainder > 0 ? 1 : 0);
|
||||
remainder -= 1;
|
||||
probabilities.set(item.index, roundPercent(ppm / 10_000));
|
||||
}
|
||||
return probabilities;
|
||||
}
|
||||
|
||||
function baselineProbabilities(enabledTiers, context) {
|
||||
const probabilities = new Map();
|
||||
const defaultFloor =
|
||||
enabledTiers.length * minimumCorrectedProbabilityPercent <= 100
|
||||
? minimumCorrectedProbabilityPercent
|
||||
: 100 / enabledTiers.length;
|
||||
for (const item of enabledTiers) {
|
||||
const customFloor = context.baselineProbability?.(item.tier, item.index, context);
|
||||
const floor = Math.max(defaultFloor, decimal(customFloor || 0));
|
||||
probabilities.set(item.index, roundPercent(floor));
|
||||
}
|
||||
return sumProbabilities(probabilities) <= 100 ? probabilities : equalProbabilities(enabledTiers);
|
||||
}
|
||||
|
||||
function floorWeightsFromProbabilities(probabilities) {
|
||||
const weights = new Map();
|
||||
for (const [index, probability] of probabilities.entries()) {
|
||||
weights.set(index, percentToWeightPPM(probability));
|
||||
}
|
||||
return weights;
|
||||
}
|
||||
|
||||
function balancedRTPContributionPlan(positiveTiers, targetExtraRTP, remainingProbability) {
|
||||
if (targetExtraRTP <= 0) {
|
||||
return [];
|
||||
}
|
||||
const maxMultiplier = positiveTiers[positiveTiers.length - 1].multiplier;
|
||||
if (targetExtraRTP >= remainingProbability * maxMultiplier) {
|
||||
return [{ tier: positiveTiers[positiveTiers.length - 1], probability: remainingProbability }];
|
||||
}
|
||||
const alpha = minimumFeasibleContributionAlpha(positiveTiers, targetExtraRTP, remainingProbability);
|
||||
const weighted = contributionWeights(positiveTiers, alpha);
|
||||
return positiveTiers.map((tier, index) => ({
|
||||
tier,
|
||||
probability: roundPercent((targetExtraRTP * weighted[index]) / tier.multiplier),
|
||||
}));
|
||||
}
|
||||
|
||||
function minimumFeasibleContributionAlpha(positiveTiers, targetExtraRTP, remainingProbability) {
|
||||
if (contributionProbabilityTotal(positiveTiers, targetExtraRTP, 0) <= remainingProbability) {
|
||||
return 0;
|
||||
}
|
||||
let low = 0;
|
||||
let high = 1;
|
||||
while (contributionProbabilityTotal(positiveTiers, targetExtraRTP, high) > remainingProbability && high < 64) {
|
||||
high *= 2;
|
||||
}
|
||||
for (let step = 0; step < 48; step += 1) {
|
||||
const mid = (low + high) / 2;
|
||||
if (contributionProbabilityTotal(positiveTiers, targetExtraRTP, mid) > remainingProbability) {
|
||||
low = mid;
|
||||
} else {
|
||||
high = mid;
|
||||
}
|
||||
}
|
||||
return high;
|
||||
}
|
||||
|
||||
function contributionProbabilityTotal(positiveTiers, targetExtraRTP, alpha) {
|
||||
const weights = contributionWeights(positiveTiers, alpha);
|
||||
return positiveTiers.reduce((sum, tier, index) => sum + (targetExtraRTP * weights[index]) / tier.multiplier, 0);
|
||||
}
|
||||
|
||||
function contributionWeights(positiveTiers, alpha) {
|
||||
const rawWeights = positiveTiers.map((tier) => Math.max(0, tier.multiplier) ** alpha);
|
||||
const total = rawWeights.reduce((sum, value) => sum + value, 0);
|
||||
if (total <= 0) {
|
||||
return positiveTiers.map(() => 1 / positiveTiers.length);
|
||||
}
|
||||
return rawWeights.map((value) => value / total);
|
||||
}
|
||||
|
||||
function addContributionProbabilities(probabilities, contributionPlan) {
|
||||
for (const item of contributionPlan) {
|
||||
probabilities.set(item.tier.index, roundPercent((probabilities.get(item.tier.index) || 0) + item.probability));
|
||||
}
|
||||
}
|
||||
|
||||
function assignResidualProbability(probabilities, zeroTiers, positiveTiers) {
|
||||
const residual = roundPercent(100 - sumProbabilities(probabilities));
|
||||
if (residual <= 0) {
|
||||
normalizeProbabilityTotal(probabilities, zeroTiers[0] || positiveTiers[0]);
|
||||
return;
|
||||
}
|
||||
const receiver = zeroTiers[0] || positiveTiers[0];
|
||||
if (!receiver) {
|
||||
return;
|
||||
}
|
||||
probabilities.set(receiver.index, roundPercent((probabilities.get(receiver.index) || 0) + residual));
|
||||
normalizeProbabilityTotal(probabilities, receiver);
|
||||
}
|
||||
|
||||
function normalizeProbabilityTotal(probabilities, receiver) {
|
||||
if (!receiver) {
|
||||
return;
|
||||
}
|
||||
const diff = roundPercent(100 - sumProbabilities(probabilities));
|
||||
if (diff !== 0) {
|
||||
probabilities.set(receiver.index, roundPercent((probabilities.get(receiver.index) || 0) + diff));
|
||||
}
|
||||
}
|
||||
|
||||
function alignExpectedRTPByWeightPPM(probabilities, baseTiers, targetRTP, floorWeights) {
|
||||
const targetRTPPPM = percentToWeightPPM(targetRTP);
|
||||
const weights = new Map();
|
||||
for (const [index, probability] of probabilities.entries()) {
|
||||
weights.set(index, percentToWeightPPM(probability));
|
||||
}
|
||||
normalizeWeightTotal(weights, baseTiers[0]);
|
||||
let currentRTPPPM = expectedRTPPPMByWeights(weights, baseTiers);
|
||||
|
||||
for (let step = 0; step < 20_000 && currentRTPPPM !== targetRTPPPM; step += 1) {
|
||||
const diff = targetRTPPPM - currentRTPPPM;
|
||||
const move = bestExpectedRTPMove(weights, baseTiers, diff, floorWeights);
|
||||
if (!move || Math.abs(diff - move.delta) >= Math.abs(diff)) {
|
||||
break;
|
||||
}
|
||||
weights.set(move.donor.index, (weights.get(move.donor.index) || 0) - 1);
|
||||
weights.set(move.receiver.index, (weights.get(move.receiver.index) || 0) + 1);
|
||||
currentRTPPPM += move.delta;
|
||||
}
|
||||
|
||||
for (const [index, weight] of weights.entries()) {
|
||||
probabilities.set(index, roundPercent(weight / 10_000));
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWeightTotal(weights, receiver) {
|
||||
if (!receiver) {
|
||||
return;
|
||||
}
|
||||
let total = 0;
|
||||
for (const weight of weights.values()) {
|
||||
total += weight;
|
||||
}
|
||||
const diff = ppmScale - total;
|
||||
if (diff !== 0) {
|
||||
weights.set(receiver.index, (weights.get(receiver.index) || 0) + diff);
|
||||
}
|
||||
}
|
||||
|
||||
function bestExpectedRTPMove(weights, baseTiers, diff, floorWeights) {
|
||||
let best = null;
|
||||
for (const donor of baseTiers) {
|
||||
const donorWeight = weights.get(donor.index) || 0;
|
||||
const floorWeight = floorWeights.get(donor.index) ?? percentToWeightPPM(minimumCorrectedProbabilityPercent);
|
||||
if (donorWeight <= floorWeight) {
|
||||
continue;
|
||||
}
|
||||
for (const receiver of baseTiers) {
|
||||
if (receiver.index === donor.index) {
|
||||
continue;
|
||||
}
|
||||
const delta = expectedMoveDeltaPPM(weights, donor, receiver);
|
||||
if ((diff > 0 && delta <= 0) || (diff < 0 && delta >= 0)) {
|
||||
continue;
|
||||
}
|
||||
if (!best || betterMove(delta, best.delta, diff)) {
|
||||
best = { delta, donor, receiver };
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function betterMove(candidateDelta, bestDelta, diff) {
|
||||
const candidateAbs = Math.abs(candidateDelta);
|
||||
const bestAbs = Math.abs(bestDelta);
|
||||
const diffAbs = Math.abs(diff);
|
||||
const candidateUnder = candidateAbs <= diffAbs;
|
||||
const bestUnder = bestAbs <= diffAbs;
|
||||
if (candidateUnder !== bestUnder) {
|
||||
return candidateUnder;
|
||||
}
|
||||
return candidateUnder ? candidateAbs > bestAbs : candidateAbs < bestAbs;
|
||||
}
|
||||
|
||||
function expectedMoveDeltaPPM(weights, donor, receiver) {
|
||||
const donorWeight = weights.get(donor.index) || 0;
|
||||
const receiverWeight = weights.get(receiver.index) || 0;
|
||||
return (
|
||||
contributionPPM(receiver.multiplier, receiverWeight + 1) -
|
||||
contributionPPM(receiver.multiplier, receiverWeight) +
|
||||
contributionPPM(donor.multiplier, donorWeight - 1) -
|
||||
contributionPPM(donor.multiplier, donorWeight)
|
||||
);
|
||||
}
|
||||
|
||||
function expectedRTPPPMByWeights(weights, baseTiers) {
|
||||
return baseTiers.reduce((sum, tier) => sum + contributionPPM(tier.multiplier, weights.get(tier.index) || 0), 0);
|
||||
}
|
||||
|
||||
function sumProbabilities(probabilities) {
|
||||
let total = 0;
|
||||
for (const value of probabilities.values()) {
|
||||
total += value;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function roundPercent(value) {
|
||||
return Math.round(value * 10_000) / 10_000;
|
||||
}
|
||||
|
||||
function expectedContributionPercent(multiplier, probabilityPercent) {
|
||||
return contributionPPM(decimal(multiplier), percentToWeightPPM(probabilityPercent)) / 10_000;
|
||||
}
|
||||
|
||||
function contributionPPM(multiplier, weightPPM) {
|
||||
return Math.trunc((multiplierToPPM(multiplier) * weightPPM) / ppmScale);
|
||||
}
|
||||
|
||||
function multiplierToPPM(value) {
|
||||
return Math.round(decimal(value) * ppmScale);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user