增加首页的展示
This commit is contained in:
parent
f597cb6110
commit
2bdc48e63c
@ -3987,7 +3987,13 @@
|
|||||||
{ "in": "query", "name": "start_ms", "schema": { "type": "integer", "format": "int64" } },
|
{ "in": "query", "name": "start_ms", "schema": { "type": "integer", "format": "int64" } },
|
||||||
{ "in": "query", "name": "end_ms", "schema": { "type": "integer", "format": "int64" } },
|
{ "in": "query", "name": "end_ms", "schema": { "type": "integer", "format": "int64" } },
|
||||||
{ "in": "query", "name": "app_codes", "schema": { "type": "string" } },
|
{ "in": "query", "name": "app_codes", "schema": { "type": "string" } },
|
||||||
{ "in": "query", "name": "region_id", "schema": { "type": "integer", "format": "int64" } }
|
{ "in": "query", "name": "region_id", "schema": { "type": "integer", "format": "int64" } },
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "region_ids",
|
||||||
|
"description": "逗号分隔的大区 ID;显式区域筛选时用于按 Finance 口径补逐日渠道。",
|
||||||
|
"schema": { "type": "string" }
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
@ -4447,6 +4453,21 @@
|
|||||||
"x-permissions": ["finance-order:coin-seller-recharge:create"]
|
"x-permissions": ["finance-order:coin-seller-recharge:create"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/admin/finance/orders/coin-seller-recharges/quotes": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "quoteFinanceCoinSellerRechargeOrder",
|
||||||
|
"requestBody": {
|
||||||
|
"$ref": "#/components/requestBodies/FinanceCoinSellerRechargeQuoteRequest"
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/FinanceCoinSellerRechargeQuoteResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "finance-order:coin-seller-recharge:create",
|
||||||
|
"x-permissions": ["finance-order:coin-seller-recharge:create"]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/admin/finance/orders/coin-seller-recharges/receipt-verifications": {
|
"/admin/finance/orders/coin-seller-recharges/receipt-verifications": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "verifyFinanceCoinSellerRechargeReceipt",
|
"operationId": "verifyFinanceCoinSellerRechargeReceipt",
|
||||||
@ -4506,6 +4527,51 @@
|
|||||||
"x-permissions": ["finance-order:coin-seller-recharge:grant"]
|
"x-permissions": ["finance-order:coin-seller-recharge:grant"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/admin/finance/coin-seller-recharge-exchange-rates/{app_code}": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "getFinanceCoinSellerRechargeExchangeRate",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "app_code",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/FinanceCoinSellerRechargeExchangeRateResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "coin-seller:exchange-rate",
|
||||||
|
"x-permissions": ["coin-seller:exchange-rate"]
|
||||||
|
},
|
||||||
|
"put": {
|
||||||
|
"operationId": "replaceFinanceCoinSellerRechargeExchangeRate",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "app_code",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"$ref": "#/components/requestBodies/FinanceCoinSellerRechargeExchangeRateRequest"
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/FinanceCoinSellerRechargeExchangeRateResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "coin-seller:exchange-rate",
|
||||||
|
"x-permissions": ["coin-seller:exchange-rate"]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/admin/payment/temporary-links": {
|
"/admin/payment/temporary-links": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listTemporaryPaymentLinks",
|
"operationId": "listTemporaryPaymentLinks",
|
||||||
@ -6021,6 +6087,54 @@
|
|||||||
"x-permissions": ["overview:view"]
|
"x-permissions": ["overview:view"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/dashboard/user-profile-overview": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "dashboardUserProfileOverview",
|
||||||
|
"x-permission": "overview:view",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "app_code",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "stat_tz",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "start_ms",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "end_ms",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/DashboardUserProfileOverviewResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permissions": ["overview:view"]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/statistics/overview": {
|
"/statistics/overview": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "statisticsOverview",
|
"operationId": "statisticsOverview",
|
||||||
@ -7256,6 +7370,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"FinanceCoinSellerRechargeQuoteRequest": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/FinanceCoinSellerRechargeQuoteInput"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"FinanceCoinSellerRechargeExchangeRateRequest": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/FinanceCoinSellerRechargeExchangeRateInput"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"FinanceCoinSellerRechargeReceiptVerificationRequest": {
|
"FinanceCoinSellerRechargeReceiptVerificationRequest": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
@ -7675,6 +7807,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"DashboardUserProfileOverviewResponse": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiResponseDashboardUserProfileOverview"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"VipProgramResponse": {
|
"VipProgramResponse": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
"content": {
|
"content": {
|
||||||
@ -7755,6 +7897,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"FinanceCoinSellerRechargeQuoteResponse": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiResponseFinanceCoinSellerRechargeQuote"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"FinanceCoinSellerRechargeExchangeRateResponse": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiResponseFinanceCoinSellerRechargeExchangeRate"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"FinanceCoinSellerRechargeReceiptVerificationResponse": {
|
"FinanceCoinSellerRechargeReceiptVerificationResponse": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
"content": {
|
"content": {
|
||||||
@ -9470,6 +9632,21 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"ApiResponseDashboardUserProfileOverview": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/Envelope"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"data": {
|
||||||
|
"$ref": "#/components/schemas/DashboardUserProfileOverview"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"ApiResponseEmpty": {
|
"ApiResponseEmpty": {
|
||||||
"$ref": "#/components/schemas/Envelope"
|
"$ref": "#/components/schemas/Envelope"
|
||||||
},
|
},
|
||||||
@ -9687,6 +9864,36 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"ApiResponseFinanceCoinSellerRechargeQuote": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/Envelope"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"data": {
|
||||||
|
"$ref": "#/components/schemas/FinanceCoinSellerRechargeQuote"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ApiResponseFinanceCoinSellerRechargeExchangeRate": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/Envelope"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"data": {
|
||||||
|
"$ref": "#/components/schemas/FinanceCoinSellerRechargeExchangeRate"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"ApiResponseFinanceCoinSellerRechargeReceiptVerification": {
|
"ApiResponseFinanceCoinSellerRechargeReceiptVerification": {
|
||||||
"allOf": [
|
"allOf": [
|
||||||
{
|
{
|
||||||
@ -10829,6 +11036,70 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": true
|
"additionalProperties": true
|
||||||
},
|
},
|
||||||
|
"DashboardUserDistributionItem": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["key", "label", "count"],
|
||||||
|
"properties": {
|
||||||
|
"key": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"count": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"DashboardUserProfileOverview": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"appCode",
|
||||||
|
"totalUsers",
|
||||||
|
"newUsers",
|
||||||
|
"activeUsers",
|
||||||
|
"genderDistribution",
|
||||||
|
"appVersionDistribution",
|
||||||
|
"updatedAtMs"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"appCode": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"totalUsers": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"description": "已完成资料且排除机器人、快捷账号的自然用户总数"
|
||||||
|
},
|
||||||
|
"newUsers": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"activeUsers": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"genderDistribution": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "已完成资料且排除机器人、快捷账号的自然用户性别分布",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/DashboardUserDistributionItem"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"appVersionDistribution": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "自然用户最近一次成功登录时上报的 App 版本号分布",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/DashboardUserDistributionItem"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"updatedAtMs": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Envelope": {
|
"Envelope": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -11846,7 +12117,7 @@
|
|||||||
},
|
},
|
||||||
"FinanceCoinSellerRechargeOrderInput": {
|
"FinanceCoinSellerRechargeOrderInput": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["appCode", "targetUserId", "usdAmount", "coinAmount", "providerCode", "externalOrderNo"],
|
"required": ["appCode", "targetUserId", "usdAmount", "providerCode", "externalOrderNo"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"appCode": {
|
"appCode": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@ -11858,9 +12129,9 @@
|
|||||||
"type": "number",
|
"type": "number",
|
||||||
"minimum": 0
|
"minimum": 0
|
||||||
},
|
},
|
||||||
"coinAmount": {
|
"isMakeup": {
|
||||||
"type": "integer",
|
"type": "boolean",
|
||||||
"minimum": 0
|
"description": "补单时为 true;订单正常计入充值,但服务端强制金币数为 0"
|
||||||
},
|
},
|
||||||
"providerCode": {
|
"providerCode": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@ -11886,6 +12157,154 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"FinanceCoinSellerRechargeQuoteInput": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["appCode", "targetUserId", "usdAmount"],
|
||||||
|
"properties": {
|
||||||
|
"appCode": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"targetUserId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"usdAmount": {
|
||||||
|
"type": "number",
|
||||||
|
"exclusiveMinimum": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"FinanceCoinSellerRechargeQuote": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["appCode", "targetUserId", "usdAmount", "coinAmount", "coinsPerUsd", "rateSource"],
|
||||||
|
"properties": {
|
||||||
|
"appCode": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"targetUserId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"targetDisplayUserId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"usdAmount": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"coinAmount": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"coinsPerUsd": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"rateSource": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["tier", "whitelist"]
|
||||||
|
},
|
||||||
|
"matchedUserId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"minUsdAmount": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"maxUsdAmount": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"FinanceCoinSellerRechargeExchangeRateTier": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["minUsdAmount", "maxUsdAmount", "coinsPerUsd"],
|
||||||
|
"properties": {
|
||||||
|
"minUsdAmount": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"maxUsdAmount": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"coinsPerUsd": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"FinanceCoinSellerRechargeExchangeRateWhitelist": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["userId", "coinsPerUsd"],
|
||||||
|
"properties": {
|
||||||
|
"userId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"coinsPerUsd": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"FinanceCoinSellerRechargeExchangeRate": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["appCode", "tiers", "whitelist", "updatedAtMs"],
|
||||||
|
"properties": {
|
||||||
|
"appCode": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"tiers": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/FinanceCoinSellerRechargeExchangeRateTier"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"whitelist": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/FinanceCoinSellerRechargeExchangeRateWhitelist"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"updatedAtMs": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"FinanceCoinSellerRechargeExchangeRateInput": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["tiers", "whitelist"],
|
||||||
|
"properties": {
|
||||||
|
"tiers": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["minUsdAmount", "maxUsdAmount", "coinsPerUsd"],
|
||||||
|
"properties": {
|
||||||
|
"minUsdAmount": {
|
||||||
|
"type": "number",
|
||||||
|
"exclusiveMinimum": 0
|
||||||
|
},
|
||||||
|
"maxUsdAmount": {
|
||||||
|
"type": "number",
|
||||||
|
"exclusiveMinimum": 0
|
||||||
|
},
|
||||||
|
"coinsPerUsd": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"whitelist": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["userId", "coinsPerUsd"],
|
||||||
|
"properties": {
|
||||||
|
"userId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"coinsPerUsd": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"FinanceCoinSellerRechargeReceiptVerification": {
|
"FinanceCoinSellerRechargeReceiptVerification": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["verified"],
|
"required": ["verified"],
|
||||||
|
|||||||
@ -335,7 +335,7 @@ test("routes databi social page to Social BI and loads real overview rows", asyn
|
|||||||
expect(fetchSocialBiMaster).toHaveBeenCalledTimes(1);
|
expect(fetchSocialBiMaster).toHaveBeenCalledTimes(1);
|
||||||
expect(fetchSocialBiOverview).toHaveBeenCalledWith(expect.objectContaining({ statTz: "Asia/Shanghai" }));
|
expect(fetchSocialBiOverview).toHaveBeenCalledWith(expect.objectContaining({ statTz: "Asia/Shanghai" }));
|
||||||
expect(fetchSocialBiFunnel).not.toHaveBeenCalled();
|
expect(fetchSocialBiFunnel).not.toHaveBeenCalled();
|
||||||
expect(fetchSocialBiKpi).toHaveBeenCalledWith(expect.objectContaining({ statTz: "Asia/Shanghai" }));
|
expect(fetchSocialBiKpi).not.toHaveBeenCalled();
|
||||||
|
|
||||||
// 经营概览:App 名、Hero 汇总充值(12,300 + 5,000 USD 分 = $173)、
|
// 经营概览:App 名、Hero 汇总充值(12,300 + 5,000 USD 分 = $173)、
|
||||||
// App 对比里 Lalu 的单 App 充值($123)、区域 Top5 里的中东大区。
|
// App 对比里 Lalu 的单 App 充值($123)、区域 Top5 里的中东大区。
|
||||||
@ -359,7 +359,10 @@ test("routes databi social page to Social BI and loads real overview rows", asyn
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
within(viewTabs).getByRole("tab", { name: "运营中心" }).click();
|
within(viewTabs).getByRole("tab", { name: "运营中心" }).click();
|
||||||
});
|
});
|
||||||
|
await flushEffects();
|
||||||
expect(within(viewTabs).getByRole("tab", { name: "运营中心" })).toHaveAttribute("aria-selected", "true");
|
expect(within(viewTabs).getByRole("tab", { name: "运营中心" })).toHaveAttribute("aria-selected", "true");
|
||||||
|
expect(fetchSocialBiKpi).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchSocialBiKpi).toHaveBeenCalledWith(expect.objectContaining({ statTz: "Asia/Shanghai" }));
|
||||||
expect(screen.getAllByText("Omar").length).toBeGreaterThan(0);
|
expect(screen.getAllByText("Omar").length).toBeGreaterThan(0);
|
||||||
expect(screen.getAllByText("$900").length).toBeGreaterThan(0);
|
expect(screen.getAllByText("$900").length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
@ -419,6 +422,9 @@ test("renders social BI data requirements table with section filters and CSV exp
|
|||||||
render(<DatabiApp />);
|
render(<DatabiApp />);
|
||||||
await flushEffects();
|
await flushEffects();
|
||||||
|
|
||||||
|
expect(fetchSocialBiOverview).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ appCodes: ["lalu"], regionIds: [9, 11], statTz: "Asia/Shanghai" })
|
||||||
|
);
|
||||||
const viewTabs = screen.getByRole("tablist", { name: "数据视图" });
|
const viewTabs = screen.getByRole("tablist", { name: "数据视图" });
|
||||||
expect(within(viewTabs).getByRole("tab", { name: "数据明细" })).toHaveAttribute("aria-selected", "true");
|
expect(within(viewTabs).getByRole("tab", { name: "数据明细" })).toHaveAttribute("aria-selected", "true");
|
||||||
|
|
||||||
|
|||||||
@ -160,7 +160,7 @@ export async function fetchSocialBiMaster() {
|
|||||||
return fetchDatabiData(apiEndpointPath(API_OPERATIONS.getSocialBiMaster));
|
return fetchDatabiData(apiEndpointPath(API_OPERATIONS.getSocialBiMaster));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSocialBiOverview({ appCodes, endMs, regionId, startMs, statTz }) {
|
export async function fetchSocialBiOverview({ appCodes, endMs, regionId, regionIds, startMs, statTz }) {
|
||||||
const query = {};
|
const query = {};
|
||||||
if (statTz) {
|
if (statTz) {
|
||||||
query.stat_tz = statTz;
|
query.stat_tz = statTz;
|
||||||
@ -176,6 +176,8 @@ export async function fetchSocialBiOverview({ appCodes, endMs, regionId, startMs
|
|||||||
}
|
}
|
||||||
if (regionId) {
|
if (regionId) {
|
||||||
query.region_id = String(regionId);
|
query.region_id = String(regionId);
|
||||||
|
} else if (regionIds?.length) {
|
||||||
|
query.region_ids = regionIds.join(",");
|
||||||
}
|
}
|
||||||
return fetchDatabiData(apiEndpointPath(API_OPERATIONS.getSocialBiOverview), { query });
|
return fetchDatabiData(apiEndpointPath(API_OPERATIONS.getSocialBiOverview), { query });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { afterEach, expect, test, vi } from "vitest";
|
|||||||
import {
|
import {
|
||||||
fetchFilterOptions,
|
fetchFilterOptions,
|
||||||
fetchSocialBiFilterOptions,
|
fetchSocialBiFilterOptions,
|
||||||
|
fetchSocialBiOverview,
|
||||||
fetchSocialBiRequirements,
|
fetchSocialBiRequirements,
|
||||||
fetchStatisticsOverview,
|
fetchStatisticsOverview,
|
||||||
getCurrentAppCode,
|
getCurrentAppCode,
|
||||||
@ -120,6 +121,23 @@ test("loads social BI requirements with section role and payer filters", async (
|
|||||||
expect(data.total.recharge_users).toBe(2);
|
expect(data.total.recharge_users).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("loads social BI overview with an explicit multi-region Finance scope", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ apps: [] })));
|
||||||
|
|
||||||
|
await fetchSocialBiOverview({
|
||||||
|
appCodes: ["aslan"],
|
||||||
|
endMs: 20,
|
||||||
|
regionIds: [9, 11],
|
||||||
|
startMs: 10,
|
||||||
|
statTz: "Asia/Shanghai"
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestURL = new URL(String(fetch.mock.calls[0][0]));
|
||||||
|
expect(requestURL.pathname).toBe("/api/v1/admin/databi/social/overview");
|
||||||
|
expect(requestURL.searchParams.get("app_codes")).toBe("aslan");
|
||||||
|
expect(requestURL.searchParams.get("region_ids")).toBe("9,11");
|
||||||
|
});
|
||||||
|
|
||||||
test("omits app scope headers and query params for all databi apps", async () => {
|
test("omits app scope headers and query params for all databi apps", async () => {
|
||||||
vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ updated_at_ms: 1 })));
|
vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ updated_at_ms: 1 })));
|
||||||
|
|
||||||
|
|||||||
@ -53,7 +53,10 @@ export function SocialBiApp() {
|
|||||||
const initial = useMemo(() => readStateFromURL(), []);
|
const initial = useMemo(() => readStateFromURL(), []);
|
||||||
const [filters, setFilters] = useState(initial.filters);
|
const [filters, setFilters] = useState(initial.filters);
|
||||||
const [viewKey, setViewKey] = useState(() => (VIEWS.some((view) => view.key === initial.view) ? initial.view : "overview"));
|
const [viewKey, setViewKey] = useState(() => (VIEWS.some((view) => view.key === initial.view) ? initial.view : "overview"));
|
||||||
const data = useSocialBiData(filters, { includeFunnel: viewKey === "funnel" });
|
const data = useSocialBiData(filters, {
|
||||||
|
includeFunnel: viewKey === "funnel",
|
||||||
|
includeKpi: viewKey === "team"
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
writeStateToURL(filters, viewKey);
|
writeStateToURL(filters, viewKey);
|
||||||
|
|||||||
@ -8,11 +8,19 @@ export const METRIC_DEFS = {
|
|||||||
active_users: { label: "日活 (DAU)", tooltip: "当日活跃去重用户;跨天合计为人天", type: "count" },
|
active_users: { label: "日活 (DAU)", tooltip: "当日活跃去重用户;跨天合计为人天", type: "count" },
|
||||||
paid_users: { label: "付费用户", tooltip: "发生成功充值的去重用户", type: "count" },
|
paid_users: { label: "付费用户", tooltip: "发生成功充值的去重用户", type: "count" },
|
||||||
recharge_users: { label: "充值用户", tooltip: "与付费用户同口径", type: "count" },
|
recharge_users: { label: "充值用户", tooltip: "与付费用户同口径", type: "count" },
|
||||||
recharge_usd_minor: { label: "充值 ($)", tooltip: "全部渠道充值合计(USD)", type: "money" },
|
recharge_usd_minor: {
|
||||||
new_user_recharge_usd_minor: { label: "新用户充值", tooltip: "当日注册用户当日的充值金额", type: "money" },
|
label: "充值 ($)",
|
||||||
google_recharge_usd_minor: { label: "谷歌充值", tooltip: "Google Play 渠道充值", type: "money" },
|
tooltip: "Finance 口径成功充值合计(谷歌 + 非谷歌三方 + 币商,USD)",
|
||||||
mifapay_recharge_usd_minor: { label: "三方充值", tooltip: "非谷歌的三方渠道充值合计", type: "money" },
|
type: "money"
|
||||||
coin_seller_recharge_usd_minor: { label: "币商充值", tooltip: "币商进货充值金额", type: "money" },
|
},
|
||||||
|
new_user_recharge_usd_minor: { label: "新用户充值", tooltip: "Finance 暂无新用户充值拆分,当前不展示", type: "money" },
|
||||||
|
google_recharge_usd_minor: { label: "谷歌充值", tooltip: "Finance 口径 Google 成功充值", type: "money" },
|
||||||
|
mifapay_recharge_usd_minor: { label: "三方充值", tooltip: "Finance 口径非 Google 三方成功充值", type: "money" },
|
||||||
|
coin_seller_recharge_usd_minor: {
|
||||||
|
label: "币商充值",
|
||||||
|
tooltip: "Finance 口径已核验币商充值订单 + H5 币商身份成功充值",
|
||||||
|
type: "money"
|
||||||
|
},
|
||||||
coin_seller_stock_coin: { label: "币商充值金币", tooltip: "币商进货获得的金币", type: "coin" },
|
coin_seller_stock_coin: { label: "币商充值金币", tooltip: "币商进货获得的金币", type: "coin" },
|
||||||
coin_seller_transfer_coin: { label: "币商出货金币", tooltip: "币商转给用户的金币(legacy 按 SELLER_AGENT 流水)", type: "coin" },
|
coin_seller_transfer_coin: { label: "币商出货金币", tooltip: "币商转给用户的金币(legacy 按 SELLER_AGENT 流水)", type: "coin" },
|
||||||
game_turnover: { label: "游戏流水", tooltip: "游戏下注金币流水", type: "coin" },
|
game_turnover: { label: "游戏流水", tooltip: "游戏下注金币流水", type: "coin" },
|
||||||
@ -33,8 +41,8 @@ export const METRIC_DEFS = {
|
|||||||
salary_transfer_coin: { label: "工资兑换金币", tooltip: "工资兑换成金币的数量", type: "coin" },
|
salary_transfer_coin: { label: "工资兑换金币", tooltip: "工资兑换成金币的数量", type: "coin" },
|
||||||
avg_mic_online_ms: { label: "平均麦上时间", tooltip: "人均麦上时长", type: "duration" },
|
avg_mic_online_ms: { label: "平均麦上时间", tooltip: "人均麦上时长", type: "duration" },
|
||||||
mic_online_ms: { label: "麦上总时长", tooltip: "全部用户麦上时长合计", type: "duration" },
|
mic_online_ms: { label: "麦上总时长", tooltip: "全部用户麦上时长合计", type: "duration" },
|
||||||
arpu_usd_minor: { label: "ARPU ($)", tooltip: "充值 / 日活", type: "money" },
|
arpu_usd_minor: { label: "ARPU ($)", tooltip: "Finance 充值没有同口径活跃人数,当前不展示", type: "money" },
|
||||||
arppu_usd_minor: { label: "ARPPU ($)", tooltip: "充值 / 付费用户", type: "money" },
|
arppu_usd_minor: { label: "ARPPU ($)", tooltip: "Finance 币商/H5 充值没有同口径付费人数,当前不展示", type: "money" },
|
||||||
paid_conversion_rate: { label: "付费转化率", tooltip: "付费用户 / 日活", type: "ratio" },
|
paid_conversion_rate: { label: "付费转化率", tooltip: "付费用户 / 日活", type: "ratio" },
|
||||||
recharge_conversion_rate: { label: "充值转化率", tooltip: "充值用户 / 日活", type: "ratio" },
|
recharge_conversion_rate: { label: "充值转化率", tooltip: "充值用户 / 日活", type: "ratio" },
|
||||||
d1_retention_rate: { label: "次日留存", tooltip: "注册次日仍活跃的比例", type: "ratio" },
|
d1_retention_rate: { label: "次日留存", tooltip: "注册次日仍活跃的比例", type: "ratio" },
|
||||||
@ -201,6 +209,10 @@ const SNAPSHOT_METRIC_KEYS = new Set(["coin_total", "salary_usd_minor"]);
|
|||||||
// 行集跨多天时传 { acrossTime: true },快照字段只累计最后一天的行。
|
// 行集跨多天时传 { acrossTime: true },快照字段只累计最后一天的行。
|
||||||
export function aggregateMetricMaps(rows, { acrossTime = false } = {}) {
|
export function aggregateMetricMaps(rows, { acrossTime = false } = {}) {
|
||||||
const out = {};
|
const out = {};
|
||||||
|
// Finance 币商/H5 金额没有与旧 paid_users 一致的去重人数;标记需跨聚合传播,
|
||||||
|
// 避免前端把精确金额再次除以旧人数,重新制造混合口径 ARPU/ARPPU。
|
||||||
|
const financeRechargeAligned = rows.some((row) => row?.finance_recharge_aligned === true);
|
||||||
|
out.finance_recharge_aligned = financeRechargeAligned;
|
||||||
let latestDay = "";
|
let latestDay = "";
|
||||||
if (acrossTime) {
|
if (acrossTime) {
|
||||||
rows.forEach((row) => {
|
rows.forEach((row) => {
|
||||||
@ -228,8 +240,8 @@ export function aggregateMetricMaps(rows, { acrossTime = false } = {}) {
|
|||||||
const recharge = out.recharge_usd_minor;
|
const recharge = out.recharge_usd_minor;
|
||||||
const active = out.active_users;
|
const active = out.active_users;
|
||||||
const paid = out.paid_users;
|
const paid = out.paid_users;
|
||||||
out.arpu_usd_minor = recharge !== null && active ? Math.round(recharge / active) : null;
|
out.arpu_usd_minor = !financeRechargeAligned && recharge !== null && active ? Math.round(recharge / active) : null;
|
||||||
out.arppu_usd_minor = recharge !== null && paid ? Math.round(recharge / paid) : null;
|
out.arppu_usd_minor = !financeRechargeAligned && recharge !== null && paid ? Math.round(recharge / paid) : null;
|
||||||
out.paid_conversion_rate = paid !== null && active ? paid / active : null;
|
out.paid_conversion_rate = paid !== null && active ? paid / active : null;
|
||||||
out.recharge_conversion_rate = out.recharge_users !== null && active ? out.recharge_users / active : null;
|
out.recharge_conversion_rate = out.recharge_users !== null && active ? out.recharge_users / active : null;
|
||||||
out.game_profit_rate = out.game_turnover ? (out.game_turnover - (out.game_payout ?? 0)) / out.game_turnover : null;
|
out.game_profit_rate = out.game_turnover ? (out.game_turnover - (out.game_payout ?? 0)) / out.game_turnover : null;
|
||||||
|
|||||||
22
databi/src/social/metrics.test.js
Normal file
22
databi/src/social/metrics.test.js
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { expect, test } from "vitest";
|
||||||
|
import { aggregateMetricMaps, metricTooltip } from "./metrics.js";
|
||||||
|
|
||||||
|
test("describes recharge metrics with the Finance source of truth", () => {
|
||||||
|
expect(metricTooltip("recharge_usd_minor")).toContain("Finance 口径");
|
||||||
|
expect(metricTooltip("google_recharge_usd_minor")).toContain("Google 成功充值");
|
||||||
|
expect(metricTooltip("mifapay_recharge_usd_minor")).toContain("非 Google 三方成功充值");
|
||||||
|
expect(metricTooltip("coin_seller_recharge_usd_minor")).toContain("已核验币商充值订单 + H5 币商身份成功充值");
|
||||||
|
expect(metricTooltip("arppu_usd_minor")).toContain("当前不展示");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps ARPU and ARPPU unavailable after aggregating Finance-aligned recharge", () => {
|
||||||
|
const metrics = aggregateMetricMaps([
|
||||||
|
{ active_users: 10, finance_recharge_aligned: true, paid_users: 2, recharge_usd_minor: 600 },
|
||||||
|
{ active_users: 5, finance_recharge_aligned: true, paid_users: 1, recharge_usd_minor: 300 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(metrics.recharge_usd_minor).toBe(900);
|
||||||
|
expect(metrics.finance_recharge_aligned).toBe(true);
|
||||||
|
expect(metrics.arpu_usd_minor).toBeNull();
|
||||||
|
expect(metrics.arppu_usd_minor).toBeNull();
|
||||||
|
});
|
||||||
@ -3,13 +3,13 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { fetchSocialBiFunnel, fetchSocialBiKpi, fetchSocialBiMaster, fetchSocialBiOverview, fetchSocialBiRequirements } from "../api.js";
|
import { fetchSocialBiFunnel, fetchSocialBiKpi, fetchSocialBiMaster, fetchSocialBiOverview, fetchSocialBiRequirements } from "../api.js";
|
||||||
import { DEFAULT_TIME_ZONE, rangeEndMs, rangeStartMs } from "../utils/time.js";
|
import { DEFAULT_TIME_ZONE, rangeExclusiveEndMs, rangeStartMs } from "../utils/time.js";
|
||||||
import { ALL, appSelectionMatches, regionSelectionMatches, resolveDateRange } from "./state.js";
|
import { ALL, appSelectionMatches, regionSelectionMatches, resolveDateRange } from "./state.js";
|
||||||
|
|
||||||
export const SOCIAL_BI_TZ = DEFAULT_TIME_ZONE;
|
export const SOCIAL_BI_TZ = DEFAULT_TIME_ZONE;
|
||||||
export const SOCIAL_BI_FUNNEL_APP_CODES = ["lalu", "huwaa", "fami"];
|
export const SOCIAL_BI_FUNNEL_APP_CODES = ["lalu", "huwaa", "fami"];
|
||||||
|
|
||||||
export function useSocialBiData(filters, { includeFunnel = false } = {}) {
|
export function useSocialBiData(filters, { includeFunnel = false, includeKpi = false } = {}) {
|
||||||
const [master, setMaster] = useState(null);
|
const [master, setMaster] = useState(null);
|
||||||
// 数据请求等 master 结果落地后再发:否则会先用兜底 App 列表打一轮,master 到达后立刻重打一轮(整轮浪费)。
|
// 数据请求等 master 结果落地后再发:否则会先用兜底 App 列表打一轮,master 到达后立刻重打一轮(整轮浪费)。
|
||||||
const [isMasterSettled, setIsMasterSettled] = useState(false);
|
const [isMasterSettled, setIsMasterSettled] = useState(false);
|
||||||
@ -69,6 +69,10 @@ export function useSocialBiData(filters, { includeFunnel = false } = {}) {
|
|||||||
const selected = filters.apps.filter((appCode) => available.includes(appCode));
|
const selected = filters.apps.filter((appCode) => available.includes(appCode));
|
||||||
return selected.length ? selected : available;
|
return selected.length ? selected : available;
|
||||||
}, [filters.apps, master]);
|
}, [filters.apps, master]);
|
||||||
|
const overviewRequestScopes = useMemo(
|
||||||
|
() => buildOverviewRequestScopes(appCodes, filters.regions, master?.regions || []),
|
||||||
|
[appCodes, filters.regions, master?.regions]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isMasterSettled) {
|
if (!isMasterSettled) {
|
||||||
@ -76,17 +80,28 @@ export function useSocialBiData(filters, { includeFunnel = false } = {}) {
|
|||||||
}
|
}
|
||||||
const sequence = ++requestSeq.current;
|
const sequence = ++requestSeq.current;
|
||||||
const startMs = rangeStartMs(range, SOCIAL_BI_TZ);
|
const startMs = rangeStartMs(range, SOCIAL_BI_TZ);
|
||||||
const endMs = rangeEndMs(range, SOCIAL_BI_TZ);
|
const endMs = rangeExclusiveEndMs(range, SOCIAL_BI_TZ);
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
// 漏斗会执行 cohort 聚合,只在用户进入漏斗视图时请求;其他视图不能为不可见数据承担查询成本或错误提示。
|
// 漏斗会执行 cohort 聚合,只在用户进入漏斗视图时请求;其他视图不能为不可见数据承担查询成本或错误提示。
|
||||||
const funnelRequest = includeFunnel
|
const funnelRequest = includeFunnel
|
||||||
? fetchSocialBiFunnel({ appCodes: funnelAppCodes, endMs, startMs, statTz: SOCIAL_BI_TZ })
|
? fetchSocialBiFunnel({ appCodes: funnelAppCodes, endMs, startMs, statTz: SOCIAL_BI_TZ })
|
||||||
: Promise.resolve(null);
|
: Promise.resolve(null);
|
||||||
Promise.allSettled([
|
// KPI 会执行当前区间和自然月两组负责范围聚合,只在运营中心可见时请求,避免其他视图承担额外查询成本。
|
||||||
fetchSocialBiOverview({ appCodes, endMs, startMs, statTz: SOCIAL_BI_TZ }),
|
const kpiRequest = includeKpi ? fetchSocialBiKpi({ appCodes, endMs, startMs, statTz: SOCIAL_BI_TZ }) : Promise.resolve(null);
|
||||||
funnelRequest,
|
// 默认全量只请求一次;用户显式选择区域时按 App 下发已选区域,让后端只补这些区域的 Finance 逐日渠道。
|
||||||
fetchSocialBiKpi({ appCodes, endMs, startMs, statTz: SOCIAL_BI_TZ })
|
const overviewRequest = overviewRequestScopes.length
|
||||||
]).then(([overviewResult, funnelResult, kpiResult]) => {
|
? Promise.all(
|
||||||
|
overviewRequestScopes.map((scope) =>
|
||||||
|
fetchSocialBiOverview({
|
||||||
|
...scope,
|
||||||
|
endMs,
|
||||||
|
startMs,
|
||||||
|
statTz: SOCIAL_BI_TZ
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).then(mergeOverviewResponses)
|
||||||
|
: Promise.resolve({ access: master?.access || null, apps: [] });
|
||||||
|
Promise.allSettled([overviewRequest, funnelRequest, kpiRequest]).then(([overviewResult, funnelResult, kpiResult]) => {
|
||||||
if (sequence !== requestSeq.current) {
|
if (sequence !== requestSeq.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -115,7 +130,9 @@ export function useSocialBiData(filters, { includeFunnel = false } = {}) {
|
|||||||
setFunnel(null);
|
setFunnel(null);
|
||||||
nextErrors.push(`埋点漏斗加载失败: ${funnelResult.reason?.message || "未知错误"}`);
|
nextErrors.push(`埋点漏斗加载失败: ${funnelResult.reason?.message || "未知错误"}`);
|
||||||
}
|
}
|
||||||
if (kpiResult.status === "fulfilled") {
|
if (!includeKpi) {
|
||||||
|
setKpi(null);
|
||||||
|
} else if (kpiResult.status === "fulfilled") {
|
||||||
setKpi(kpiResult.value);
|
setKpi(kpiResult.value);
|
||||||
Object.entries(kpiResult.value?.app_errors || {}).forEach(([appCode, message]) => {
|
Object.entries(kpiResult.value?.app_errors || {}).forEach(([appCode, message]) => {
|
||||||
nextErrors.push(`${appCode}: ${message}`);
|
nextErrors.push(`${appCode}: ${message}`);
|
||||||
@ -127,7 +144,7 @@ export function useSocialBiData(filters, { includeFunnel = false } = {}) {
|
|||||||
setErrors(nextErrors);
|
setErrors(nextErrors);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
});
|
});
|
||||||
}, [appCodes, funnelAppCodes, includeFunnel, isMasterSettled, range, refreshToken]);
|
}, [appCodes, funnelAppCodes, includeFunnel, includeKpi, isMasterSettled, master?.access, overviewRequestScopes, range, refreshToken]);
|
||||||
|
|
||||||
const loadRequirements = useCallback(
|
const loadRequirements = useCallback(
|
||||||
({ newUserType, payerType, section, userRole } = {}) => {
|
({ newUserType, payerType, section, userRole } = {}) => {
|
||||||
@ -136,7 +153,7 @@ export function useSocialBiData(filters, { includeFunnel = false } = {}) {
|
|||||||
}
|
}
|
||||||
const sequence = ++requirementsSeq.current;
|
const sequence = ++requirementsSeq.current;
|
||||||
const startMs = rangeStartMs(range, SOCIAL_BI_TZ);
|
const startMs = rangeStartMs(range, SOCIAL_BI_TZ);
|
||||||
const endMs = rangeEndMs(range, SOCIAL_BI_TZ);
|
const endMs = rangeExclusiveEndMs(range, SOCIAL_BI_TZ);
|
||||||
const requestScopes = buildRequirementRequestScopes(appCodes, filters.regions);
|
const requestScopes = buildRequirementRequestScopes(appCodes, filters.regions);
|
||||||
setIsRequirementsLoading(true);
|
setIsRequirementsLoading(true);
|
||||||
setRequirementsError("");
|
setRequirementsError("");
|
||||||
@ -353,6 +370,81 @@ function buildRequirementRequestScopes(appCodes, regions) {
|
|||||||
return scopes;
|
return scopes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildOverviewRequestScopes(appCodes, regions, masterRegions) {
|
||||||
|
const selectedApps = new Set((appCodes || []).filter(Boolean));
|
||||||
|
if (!regions?.length || regions.includes(ALL)) {
|
||||||
|
return [{ appCodes: [...selectedApps] }];
|
||||||
|
}
|
||||||
|
const configuredByApp = new Map();
|
||||||
|
(masterRegions || []).forEach((region) => {
|
||||||
|
const appCode = String(region?.app_code || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
const regionID = Number(region?.region_id);
|
||||||
|
if (!appCode || !Number.isFinite(regionID) || regionID <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!configuredByApp.has(appCode)) {
|
||||||
|
configuredByApp.set(appCode, new Set());
|
||||||
|
}
|
||||||
|
configuredByApp.get(appCode).add(regionID);
|
||||||
|
});
|
||||||
|
|
||||||
|
const byApp = new Map();
|
||||||
|
regions.forEach((selection) => {
|
||||||
|
const value = String(selection || "").trim();
|
||||||
|
if (!value || value === ALL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value.startsWith("app:")) {
|
||||||
|
const appCode = value.slice(4).trim().toLowerCase();
|
||||||
|
if (appCode) {
|
||||||
|
byApp.set(appCode, new Set(configuredByApp.get(appCode) || []));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [rawAppCode, rawRegionID] = value.split(":");
|
||||||
|
const appCode = String(rawAppCode || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
const regionID = Number(rawRegionID);
|
||||||
|
if (!appCode || !Number.isFinite(regionID) || regionID <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!byApp.has(appCode)) {
|
||||||
|
byApp.set(appCode, new Set());
|
||||||
|
}
|
||||||
|
byApp.get(appCode).add(regionID);
|
||||||
|
});
|
||||||
|
|
||||||
|
const scopes = [];
|
||||||
|
byApp.forEach((regionIDs, appCode) => {
|
||||||
|
if (selectedApps.size && !selectedApps.has(appCode)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ids = [...regionIDs].sort((left, right) => left - right);
|
||||||
|
if (ids.length === 1) {
|
||||||
|
scopes.push({ appCodes: [appCode], regionId: ids[0] });
|
||||||
|
} else if (ids.length > 1) {
|
||||||
|
scopes.push({ appCodes: [appCode], regionIds: ids });
|
||||||
|
} else {
|
||||||
|
// 区域目录暂不可用时仍限制到该 App,后端会按权限返回可见范围。
|
||||||
|
scopes.push({ appCodes: [appCode] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return scopes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeOverviewResponses(results) {
|
||||||
|
const merged = { access: results.find((item) => item?.access)?.access || null, apps: [] };
|
||||||
|
results.forEach((item) => {
|
||||||
|
if (Array.isArray(item?.apps)) {
|
||||||
|
merged.apps.push(...item.apps);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
function mergeRequirementResponses(results) {
|
function mergeRequirementResponses(results) {
|
||||||
const merged = { access: results.find((item) => item?.access)?.access || null, apps: [] };
|
const merged = { access: results.find((item) => item?.access)?.access || null, apps: [] };
|
||||||
results.forEach((item) => {
|
results.forEach((item) => {
|
||||||
|
|||||||
@ -63,6 +63,13 @@ export function rangeEndMs(range, timeZone = DEFAULT_TIME_ZONE) {
|
|||||||
return dateTimeMs(range?.end, range?.endTime || "23:59:59", timeZone);
|
return dateTimeMs(range?.end, range?.endTime || "23:59:59", timeZone);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Finance/Statistics 查询统一使用 [start_ms, end_ms);筛选器只有秒精度,
|
||||||
|
// 因此把可见结束秒推进 1 秒作为排他上界,避免漏掉结束日 23:59:59.xxx 的账单。
|
||||||
|
export function rangeExclusiveEndMs(range, timeZone = DEFAULT_TIME_ZONE) {
|
||||||
|
const endMs = rangeEndMs(range, timeZone);
|
||||||
|
return endMs === "" ? "" : Number(endMs) + 1000;
|
||||||
|
}
|
||||||
|
|
||||||
export function rangeStartMs(range, timeZone = DEFAULT_TIME_ZONE) {
|
export function rangeStartMs(range, timeZone = DEFAULT_TIME_ZONE) {
|
||||||
return dateTimeMs(range?.start, range?.startTime || "00:00:00", timeZone);
|
return dateTimeMs(range?.start, range?.startTime || "00:00:00", timeZone);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { rangeEndMs, rangeStartMs } from "./time.js";
|
import { rangeEndMs, rangeExclusiveEndMs, rangeStartMs } from "./time.js";
|
||||||
|
|
||||||
test("converts the selected date range with UTC boundaries", () => {
|
test("converts the selected date range with UTC boundaries", () => {
|
||||||
const range = { end: "2026-06-06", endTime: "23:59:59", start: "2026-06-06", startTime: "00:00:00" };
|
const range = { end: "2026-06-06", endTime: "23:59:59", start: "2026-06-06", startTime: "00:00:00" };
|
||||||
@ -14,3 +14,10 @@ test("converts the selected date range with Beijing time boundaries", () => {
|
|||||||
expect(rangeStartMs(range, "Asia/Shanghai")).toBe(Date.UTC(2026, 5, 5, 16, 0, 0));
|
expect(rangeStartMs(range, "Asia/Shanghai")).toBe(Date.UTC(2026, 5, 5, 16, 0, 0));
|
||||||
expect(rangeEndMs(range, "Asia/Shanghai")).toBe(Date.UTC(2026, 5, 6, 15, 59, 59));
|
expect(rangeEndMs(range, "Asia/Shanghai")).toBe(Date.UTC(2026, 5, 6, 15, 59, 59));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("uses the next second as the exclusive Finance query boundary", () => {
|
||||||
|
const range = { end: "2026-06-06", endTime: "23:59:59", start: "2026-06-06", startTime: "00:00:00" };
|
||||||
|
|
||||||
|
expect(rangeExclusiveEndMs(range, "UTC")).toBe(Date.UTC(2026, 5, 7, 0, 0, 0));
|
||||||
|
expect(rangeExclusiveEndMs(range, "Asia/Shanghai")).toBe(Date.UTC(2026, 5, 6, 16, 0, 0));
|
||||||
|
});
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import {
|
|||||||
fetchFinanceSession,
|
fetchFinanceSession,
|
||||||
filterOperationsByPermissions,
|
filterOperationsByPermissions,
|
||||||
grantFinanceCoinSellerRechargeOrder,
|
grantFinanceCoinSellerRechargeOrder,
|
||||||
|
getFinanceCoinSellerRechargeExchangeRate,
|
||||||
listFinanceApplications,
|
listFinanceApplications,
|
||||||
listFinanceCoinSellerRechargeOrders,
|
listFinanceCoinSellerRechargeOrders,
|
||||||
listFinanceRechargeDetails,
|
listFinanceRechargeDetails,
|
||||||
@ -22,8 +23,10 @@ import {
|
|||||||
listMyFinanceApplications,
|
listMyFinanceApplications,
|
||||||
listFinanceWithdrawalApplications,
|
listFinanceWithdrawalApplications,
|
||||||
refreshFinanceGoogleRechargePaid,
|
refreshFinanceGoogleRechargePaid,
|
||||||
|
replaceFinanceCoinSellerRechargeExchangeRate,
|
||||||
rejectFinanceApplication,
|
rejectFinanceApplication,
|
||||||
rejectFinanceWithdrawalApplication,
|
rejectFinanceWithdrawalApplication,
|
||||||
|
quoteFinanceCoinSellerRechargeOrder,
|
||||||
verifyFinanceCoinSellerRechargeReceipt,
|
verifyFinanceCoinSellerRechargeReceipt,
|
||||||
verifyFinanceCoinSellerRechargeOrder,
|
verifyFinanceCoinSellerRechargeOrder,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
@ -660,6 +663,25 @@ export function FinanceApp() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const quoteCoinSellerRechargeOrder = useCallback(
|
||||||
|
(payload) => quoteFinanceCoinSellerRechargeOrder(cleanPayload(payload)),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadCoinSellerRechargeExchangeRate = useCallback(
|
||||||
|
(appCode) => getFinanceCoinSellerRechargeExchangeRate(appCode),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const saveCoinSellerRechargeExchangeRate = useCallback(
|
||||||
|
async (appCode, payload) => {
|
||||||
|
const saved = await replaceFinanceCoinSellerRechargeExchangeRate(appCode, cleanPayload(payload));
|
||||||
|
showToast("金币汇率配置已保存", "success");
|
||||||
|
return saved;
|
||||||
|
},
|
||||||
|
[showToast],
|
||||||
|
);
|
||||||
|
|
||||||
const verifyCoinSellerRechargeReceipt = async (payload) => {
|
const verifyCoinSellerRechargeReceipt = async (payload) => {
|
||||||
if (!session?.canVerifyCoinSellerRechargeOrder) {
|
if (!session?.canVerifyCoinSellerRechargeOrder) {
|
||||||
showToast("无校验币商充值订单权限", "error");
|
showToast("无校验币商充值订单权限", "error");
|
||||||
@ -874,6 +896,7 @@ export function FinanceApp() {
|
|||||||
actionLoading={coinSellerRechargeOrderActionLoading}
|
actionLoading={coinSellerRechargeOrderActionLoading}
|
||||||
apps={rechargeApps}
|
apps={rechargeApps}
|
||||||
canCreate={session.canCreateCoinSellerRechargeOrder}
|
canCreate={session.canCreateCoinSellerRechargeOrder}
|
||||||
|
canConfigureExchangeRate={session.canConfigureCoinSellerRechargeExchangeRate}
|
||||||
canGrant={session.canGrantCoinSellerRechargeOrder}
|
canGrant={session.canGrantCoinSellerRechargeOrder}
|
||||||
canVerify={session.canVerifyCoinSellerRechargeOrder}
|
canVerify={session.canVerifyCoinSellerRechargeOrder}
|
||||||
error={coinSellerRechargeOrdersState.error}
|
error={coinSellerRechargeOrdersState.error}
|
||||||
@ -881,9 +904,12 @@ export function FinanceApp() {
|
|||||||
loading={coinSellerRechargeOrdersState.loading}
|
loading={coinSellerRechargeOrdersState.loading}
|
||||||
orders={coinSellerRechargeOrdersState.data}
|
orders={coinSellerRechargeOrdersState.data}
|
||||||
onCreate={submitCoinSellerRechargeOrder}
|
onCreate={submitCoinSellerRechargeOrder}
|
||||||
|
onLoadExchangeRate={loadCoinSellerRechargeExchangeRate}
|
||||||
onFiltersChange={updateCoinSellerRechargeOrderFilters}
|
onFiltersChange={updateCoinSellerRechargeOrderFilters}
|
||||||
onGrant={grantCoinSellerRechargeOrder}
|
onGrant={grantCoinSellerRechargeOrder}
|
||||||
onReload={loadCoinSellerRechargeOrders}
|
onReload={loadCoinSellerRechargeOrders}
|
||||||
|
onQuote={quoteCoinSellerRechargeOrder}
|
||||||
|
onSaveExchangeRate={saveCoinSellerRechargeExchangeRate}
|
||||||
onVerify={verifyCoinSellerRechargeOrder}
|
onVerify={verifyCoinSellerRechargeOrder}
|
||||||
onVerifyReceipt={verifyCoinSellerRechargeReceipt}
|
onVerifyReceipt={verifyCoinSellerRechargeReceipt}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -30,6 +30,7 @@ vi.mock("./api.js", () => ({
|
|||||||
fetchFinanceSession: vi.fn(),
|
fetchFinanceSession: vi.fn(),
|
||||||
filterOperationsByPermissions: vi.fn((operations, permissions) => operations.filter((item) => permissions.includes(item.permissionCode))),
|
filterOperationsByPermissions: vi.fn((operations, permissions) => operations.filter((item) => permissions.includes(item.permissionCode))),
|
||||||
grantFinanceCoinSellerRechargeOrder: vi.fn(),
|
grantFinanceCoinSellerRechargeOrder: vi.fn(),
|
||||||
|
getFinanceCoinSellerRechargeExchangeRate: vi.fn(),
|
||||||
listFinanceApplications: vi.fn(),
|
listFinanceApplications: vi.fn(),
|
||||||
listFinanceCoinSellerRechargeOrders: vi.fn(),
|
listFinanceCoinSellerRechargeOrders: vi.fn(),
|
||||||
listFinanceRechargeDetails: vi.fn(),
|
listFinanceRechargeDetails: vi.fn(),
|
||||||
@ -39,12 +40,16 @@ vi.mock("./api.js", () => ({
|
|||||||
rejectFinanceApplication: vi.fn(),
|
rejectFinanceApplication: vi.fn(),
|
||||||
rejectFinanceWithdrawalApplication: vi.fn(),
|
rejectFinanceWithdrawalApplication: vi.fn(),
|
||||||
refreshFinanceGoogleRechargePaid: vi.fn(),
|
refreshFinanceGoogleRechargePaid: vi.fn(),
|
||||||
verifyFinanceCoinSellerRechargeOrder: vi.fn()
|
replaceFinanceCoinSellerRechargeExchangeRate: vi.fn(),
|
||||||
|
quoteFinanceCoinSellerRechargeOrder: vi.fn(),
|
||||||
|
verifyFinanceCoinSellerRechargeOrder: vi.fn(),
|
||||||
|
verifyFinanceCoinSellerRechargeReceipt: vi.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const defaultSession = {
|
const defaultSession = {
|
||||||
canAccessFinance: true,
|
canAccessFinance: true,
|
||||||
canAuditApplication: false,
|
canAuditApplication: false,
|
||||||
|
canConfigureCoinSellerRechargeExchangeRate: false,
|
||||||
canCreateApplication: true,
|
canCreateApplication: true,
|
||||||
canCreateCoinSellerRechargeOrder: false,
|
canCreateCoinSellerRechargeOrder: false,
|
||||||
canGrantCoinSellerRechargeOrder: false,
|
canGrantCoinSellerRechargeOrder: false,
|
||||||
|
|||||||
@ -30,6 +30,7 @@ export async function fetchFinanceSession() {
|
|||||||
const canCreateCoinSellerRechargeOrder = permissions.includes(FINANCE_PERMISSIONS.createCoinSellerRechargeOrder);
|
const canCreateCoinSellerRechargeOrder = permissions.includes(FINANCE_PERMISSIONS.createCoinSellerRechargeOrder);
|
||||||
const canVerifyCoinSellerRechargeOrder = permissions.includes(FINANCE_PERMISSIONS.verifyCoinSellerRechargeOrder);
|
const canVerifyCoinSellerRechargeOrder = permissions.includes(FINANCE_PERMISSIONS.verifyCoinSellerRechargeOrder);
|
||||||
const canGrantCoinSellerRechargeOrder = permissions.includes(FINANCE_PERMISSIONS.grantCoinSellerRechargeOrder);
|
const canGrantCoinSellerRechargeOrder = permissions.includes(FINANCE_PERMISSIONS.grantCoinSellerRechargeOrder);
|
||||||
|
const canConfigureCoinSellerRechargeExchangeRate = permissions.includes(FINANCE_PERMISSIONS.configureCoinSellerRechargeExchangeRate);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...session,
|
...session,
|
||||||
@ -41,6 +42,7 @@ export async function fetchFinanceSession() {
|
|||||||
canViewCoinSellerRechargeOrders,
|
canViewCoinSellerRechargeOrders,
|
||||||
canAuditApplication,
|
canAuditApplication,
|
||||||
canAuditWithdrawalApplications,
|
canAuditWithdrawalApplications,
|
||||||
|
canConfigureCoinSellerRechargeExchangeRate,
|
||||||
canCreateApplication,
|
canCreateApplication,
|
||||||
canCreateCoinSellerRechargeOrder,
|
canCreateCoinSellerRechargeOrder,
|
||||||
canGrantCoinSellerRechargeOrder,
|
canGrantCoinSellerRechargeOrder,
|
||||||
@ -114,6 +116,29 @@ export async function createFinanceCoinSellerRechargeOrder(payload) {
|
|||||||
return normalizeCoinSellerRechargeOrder(data);
|
return normalizeCoinSellerRechargeOrder(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function quoteFinanceCoinSellerRechargeOrder(payload) {
|
||||||
|
const endpoint = API_ENDPOINTS.quoteFinanceCoinSellerRechargeOrder;
|
||||||
|
return apiRequest(apiEndpointPath(API_OPERATIONS.quoteFinanceCoinSellerRechargeOrder), {
|
||||||
|
body: cleanPayload(payload),
|
||||||
|
method: endpoint.method
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFinanceCoinSellerRechargeExchangeRate(appCode) {
|
||||||
|
const endpoint = API_ENDPOINTS.getFinanceCoinSellerRechargeExchangeRate;
|
||||||
|
return apiRequest(apiEndpointPath(API_OPERATIONS.getFinanceCoinSellerRechargeExchangeRate, { app_code: appCode }), {
|
||||||
|
method: endpoint.method
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function replaceFinanceCoinSellerRechargeExchangeRate(appCode, payload) {
|
||||||
|
const endpoint = API_ENDPOINTS.replaceFinanceCoinSellerRechargeExchangeRate;
|
||||||
|
return apiRequest(apiEndpointPath(API_OPERATIONS.replaceFinanceCoinSellerRechargeExchangeRate, { app_code: appCode }), {
|
||||||
|
body: cleanPayload(payload),
|
||||||
|
method: endpoint.method
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function verifyFinanceCoinSellerRechargeReceipt(payload) {
|
export async function verifyFinanceCoinSellerRechargeReceipt(payload) {
|
||||||
const endpoint = API_ENDPOINTS.verifyFinanceCoinSellerRechargeReceipt;
|
const endpoint = API_ENDPOINTS.verifyFinanceCoinSellerRechargeReceipt;
|
||||||
return apiRequest(apiEndpointPath(API_OPERATIONS.verifyFinanceCoinSellerRechargeReceipt), {
|
return apiRequest(apiEndpointPath(API_OPERATIONS.verifyFinanceCoinSellerRechargeReceipt), {
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
createFinanceApplication,
|
createFinanceApplication,
|
||||||
createFinanceCoinSellerRechargeOrder,
|
createFinanceCoinSellerRechargeOrder,
|
||||||
fetchFinanceApplicationOptions,
|
fetchFinanceApplicationOptions,
|
||||||
|
getFinanceCoinSellerRechargeExchangeRate,
|
||||||
filterOperationsByPermissions,
|
filterOperationsByPermissions,
|
||||||
grantFinanceCoinSellerRechargeOrder,
|
grantFinanceCoinSellerRechargeOrder,
|
||||||
listFinanceApplications,
|
listFinanceApplications,
|
||||||
@ -15,6 +16,8 @@ import {
|
|||||||
listFinanceWithdrawalApplications,
|
listFinanceWithdrawalApplications,
|
||||||
rejectFinanceApplication,
|
rejectFinanceApplication,
|
||||||
rejectFinanceWithdrawalApplication,
|
rejectFinanceWithdrawalApplication,
|
||||||
|
quoteFinanceCoinSellerRechargeOrder,
|
||||||
|
replaceFinanceCoinSellerRechargeExchangeRate,
|
||||||
verifyFinanceCoinSellerRechargeReceipt,
|
verifyFinanceCoinSellerRechargeReceipt,
|
||||||
verifyFinanceCoinSellerRechargeOrder
|
verifyFinanceCoinSellerRechargeOrder
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
@ -111,7 +114,6 @@ test("finance coin seller recharge order APIs use generated endpoint paths", asy
|
|||||||
await listFinanceCoinSellerRechargeOrders({ page: 1, page_size: 50, provider_code: "mifapay" });
|
await listFinanceCoinSellerRechargeOrders({ page: 1, page_size: 50, provider_code: "mifapay" });
|
||||||
await createFinanceCoinSellerRechargeOrder({
|
await createFinanceCoinSellerRechargeOrder({
|
||||||
appCode: "lalu",
|
appCode: "lalu",
|
||||||
coinAmount: 100000,
|
|
||||||
externalOrderNo: "MIFA-001",
|
externalOrderNo: "MIFA-001",
|
||||||
providerAmountMinor: 125050,
|
providerAmountMinor: 125050,
|
||||||
providerCode: "mifapay",
|
providerCode: "mifapay",
|
||||||
@ -138,7 +140,6 @@ test("finance coin seller recharge order APIs use generated endpoint paths", asy
|
|||||||
expect(calls[1][1]?.method).toBe("POST");
|
expect(calls[1][1]?.method).toBe("POST");
|
||||||
expect(JSON.parse(String(calls[1][1]?.body))).toEqual({
|
expect(JSON.parse(String(calls[1][1]?.body))).toEqual({
|
||||||
appCode: "lalu",
|
appCode: "lalu",
|
||||||
coinAmount: 100000,
|
|
||||||
externalOrderNo: "MIFA-001",
|
externalOrderNo: "MIFA-001",
|
||||||
providerAmountMinor: 125050,
|
providerAmountMinor: 125050,
|
||||||
providerCode: "mifapay",
|
providerCode: "mifapay",
|
||||||
@ -162,6 +163,29 @@ test("finance coin seller recharge order APIs use generated endpoint paths", asy
|
|||||||
expect(calls[4][1]?.method).toBe("POST");
|
expect(calls[4][1]?.method).toBe("POST");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("finance coin seller quote and exchange rate APIs use generated endpoint paths", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () => new Response(JSON.stringify({ code: 0, data: { appCode: "lalu", tiers: [], whitelist: [] } })))
|
||||||
|
);
|
||||||
|
|
||||||
|
await quoteFinanceCoinSellerRechargeOrder({ appCode: "lalu", targetUserId: "123456", usdAmount: 150 });
|
||||||
|
await getFinanceCoinSellerRechargeExchangeRate("lalu");
|
||||||
|
await replaceFinanceCoinSellerRechargeExchangeRate("lalu", {
|
||||||
|
tiers: [{ minUsdAmount: 100, maxUsdAmount: 200, coinsPerUsd: 80000 }],
|
||||||
|
whitelist: [{ userId: "123456", coinsPerUsd: 100000 }]
|
||||||
|
});
|
||||||
|
|
||||||
|
const calls = vi.mocked(fetch).mock.calls;
|
||||||
|
expect(String(calls[0][0])).toContain("/api/v1/admin/finance/orders/coin-seller-recharges/quotes");
|
||||||
|
expect(calls[0][1]?.method).toBe("POST");
|
||||||
|
expect(JSON.parse(String(calls[0][1]?.body))).toEqual({ appCode: "lalu", targetUserId: "123456", usdAmount: 150 });
|
||||||
|
expect(String(calls[1][0])).toContain("/api/v1/admin/finance/coin-seller-recharge-exchange-rates/lalu");
|
||||||
|
expect(calls[1][1]?.method).toBe("GET");
|
||||||
|
expect(String(calls[2][0])).toContain("/api/v1/admin/finance/coin-seller-recharge-exchange-rates/lalu");
|
||||||
|
expect(calls[2][1]?.method).toBe("PUT");
|
||||||
|
});
|
||||||
|
|
||||||
test("filters operation options by backend-configured permissions", () => {
|
test("filters operation options by backend-configured permissions", () => {
|
||||||
const allowed = filterOperationsByPermissions(FINANCE_OPERATIONS, [
|
const allowed = filterOperationsByPermissions(FINANCE_OPERATIONS, [
|
||||||
"finance-operation:user-coin-credit",
|
"finance-operation:user-coin-credit",
|
||||||
|
|||||||
245
finance/src/components/FinanceCoinSellerExchangeRateDialog.jsx
Normal file
245
finance/src/components/FinanceCoinSellerExchangeRateDialog.jsx
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||||
|
import Alert from "@mui/material/Alert";
|
||||||
|
import Button from "@mui/material/Button";
|
||||||
|
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 MenuItem from "@mui/material/MenuItem";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
buildCoinSellerRechargeExchangeRatePayload,
|
||||||
|
validateCoinSellerRechargeExchangeRatePayload,
|
||||||
|
} from "../format.js";
|
||||||
|
|
||||||
|
const EMPTY_CONFIG = { tiers: [], whitelist: [] };
|
||||||
|
|
||||||
|
export function FinanceCoinSellerExchangeRateDialog({ apps, initialAppCode, open, onClose, onLoad, onSave }) {
|
||||||
|
const rowSequence = useRef(0);
|
||||||
|
const [appCode, setAppCode] = useState("");
|
||||||
|
const [config, setConfig] = useState(EMPTY_CONFIG);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setAppCode(initialAppCode || apps[0]?.appCode || "");
|
||||||
|
setError("");
|
||||||
|
}
|
||||||
|
}, [apps, initialAppCode, open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !appCode) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
let active = true;
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
onLoad(appCode)
|
||||||
|
.then((data) => {
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConfig({
|
||||||
|
tiers: (data?.tiers || []).map((item) => withRowID(item, rowSequence)),
|
||||||
|
whitelist: (data?.whitelist || []).map((item) => withRowID(item, rowSequence)),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((loadError) => {
|
||||||
|
if (active) {
|
||||||
|
setConfig(EMPTY_CONFIG);
|
||||||
|
setError(loadError.message || "加载金币汇率配置失败");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (active) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [appCode, onLoad, open]);
|
||||||
|
|
||||||
|
const payload = useMemo(() => buildCoinSellerRechargeExchangeRatePayload(config), [config]);
|
||||||
|
|
||||||
|
const submit = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const validationMessage = validateCoinSellerRechargeExchangeRatePayload(payload);
|
||||||
|
if (validationMessage) {
|
||||||
|
setError(validationMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await onSave(appCode, payload);
|
||||||
|
onClose();
|
||||||
|
} catch (saveError) {
|
||||||
|
setError(saveError.message || "保存金币汇率配置失败");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateRow = (group, id, key, value) => {
|
||||||
|
setConfig((current) => ({
|
||||||
|
...current,
|
||||||
|
[group]: current[group].map((item) => (item.id === id ? { ...item, [key]: value } : item)),
|
||||||
|
}));
|
||||||
|
setError("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const addRow = (group) => {
|
||||||
|
const item =
|
||||||
|
group === "tiers"
|
||||||
|
? { coinsPerUsd: "", maxUsdAmount: "", minUsdAmount: "" }
|
||||||
|
: { coinsPerUsd: "", userId: "" };
|
||||||
|
setConfig((current) => ({ ...current, [group]: [...current[group], withRowID(item, rowSequence)] }));
|
||||||
|
setError("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeRow = (group, id) => {
|
||||||
|
setConfig((current) => ({ ...current, [group]: current[group].filter((item) => item.id !== id) }));
|
||||||
|
setError("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const disabled = loading || saving;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog fullWidth maxWidth="md" open={open} onClose={disabled ? undefined : onClose}>
|
||||||
|
<DialogTitle>币商充值金币汇率</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<form className="finance-rate-config" id="coin-seller-exchange-rate-form" onSubmit={submit}>
|
||||||
|
{error ? <Alert severity="error">{error}</Alert> : null}
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
label="APP"
|
||||||
|
select
|
||||||
|
value={appCode}
|
||||||
|
onChange={(event) => setAppCode(event.target.value)}
|
||||||
|
>
|
||||||
|
{apps.map((app) => (
|
||||||
|
<MenuItem key={app.appCode} value={app.appCode}>
|
||||||
|
{app.appName || app.appCode}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<RateSection
|
||||||
|
actionLabel="添加区间"
|
||||||
|
columns={["最低 USD", "最高 USD", "1 USD 兑换金币"]}
|
||||||
|
disabled={disabled}
|
||||||
|
rows={config.tiers}
|
||||||
|
title="金额区间"
|
||||||
|
onAdd={() => addRow("tiers")}
|
||||||
|
onRemove={(id) => removeRow("tiers", id)}
|
||||||
|
renderRow={(item) => (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
slotProps={{ htmlInput: { "aria-label": "最低 USD", min: 0.01, step: "0.01" } }}
|
||||||
|
type="number"
|
||||||
|
value={item.minUsdAmount}
|
||||||
|
onChange={(event) => updateRow("tiers", item.id, "minUsdAmount", event.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
slotProps={{ htmlInput: { "aria-label": "最高 USD", min: 0.01, step: "0.01" } }}
|
||||||
|
type="number"
|
||||||
|
value={item.maxUsdAmount}
|
||||||
|
onChange={(event) => updateRow("tiers", item.id, "maxUsdAmount", event.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
slotProps={{ htmlInput: { "aria-label": "区间金币汇率", min: 1, step: "1" } }}
|
||||||
|
type="number"
|
||||||
|
value={item.coinsPerUsd}
|
||||||
|
onChange={(event) => updateRow("tiers", item.id, "coinsPerUsd", event.target.value)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RateSection
|
||||||
|
actionLabel="添加白名单"
|
||||||
|
columns={["用户 ID", "1 USD 兑换金币"]}
|
||||||
|
disabled={disabled}
|
||||||
|
rows={config.whitelist}
|
||||||
|
title="用户 ID 白名单"
|
||||||
|
onAdd={() => addRow("whitelist")}
|
||||||
|
onRemove={(id) => removeRow("whitelist", id)}
|
||||||
|
renderRow={(item) => (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
slotProps={{ htmlInput: { "aria-label": "白名单用户 ID" } }}
|
||||||
|
value={item.userId}
|
||||||
|
onChange={(event) => updateRow("whitelist", item.id, "userId", event.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
slotProps={{ htmlInput: { "aria-label": "白名单金币汇率", min: 1, step: "1" } }}
|
||||||
|
type="number"
|
||||||
|
value={item.coinsPerUsd}
|
||||||
|
onChange={(event) => updateRow("whitelist", item.id, "coinsPerUsd", event.target.value)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button disabled={disabled} variant="outlined" onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button disabled={disabled || !appCode} form="coin-seller-exchange-rate-form" type="submit" variant="contained">
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RateSection({ actionLabel, columns, disabled, onAdd, onRemove, renderRow, rows, title }) {
|
||||||
|
return (
|
||||||
|
<section className="finance-rate-config__section">
|
||||||
|
<div className="finance-rate-config__heading">
|
||||||
|
<h3>{title}</h3>
|
||||||
|
<Button disabled={disabled} size="small" startIcon={<AddOutlined fontSize="small" />} onClick={onAdd}>
|
||||||
|
{actionLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="finance-rate-config__table">
|
||||||
|
<div className={`finance-rate-config__row finance-rate-config__row--head finance-rate-config__row--${columns.length}`}>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<span key={column}>{column}</span>
|
||||||
|
))}
|
||||||
|
<span>操作</span>
|
||||||
|
</div>
|
||||||
|
{rows.length ? (
|
||||||
|
rows.map((item) => (
|
||||||
|
<div
|
||||||
|
className={`finance-rate-config__row finance-rate-config__row--${columns.length}`}
|
||||||
|
key={item.id}
|
||||||
|
>
|
||||||
|
{renderRow(item)}
|
||||||
|
<Button color="error" disabled={disabled} size="small" onClick={() => onRemove(item.id)}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="finance-rate-config__empty">当前无数据</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function withRowID(item, rowSequence) {
|
||||||
|
rowSequence.current += 1;
|
||||||
|
return { ...item, id: `rate-row-${rowSequence.current}` };
|
||||||
|
}
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { expect, test, vi } from "vitest";
|
||||||
|
import { FinanceCoinSellerExchangeRateDialog } from "./FinanceCoinSellerExchangeRateDialog.jsx";
|
||||||
|
|
||||||
|
test("edits app amount tiers and user whitelist rates", async () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const onLoad = vi.fn(async () => ({
|
||||||
|
appCode: "lalu",
|
||||||
|
tiers: [{ coinsPerUsd: 80000, maxUsdAmount: "200.00", minUsdAmount: "100.00" }],
|
||||||
|
whitelist: [],
|
||||||
|
}));
|
||||||
|
const onSave = vi.fn(async () => ({}));
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FinanceCoinSellerExchangeRateDialog
|
||||||
|
apps={[{ appCode: "lalu", appName: "Lalu" }]}
|
||||||
|
initialAppCode="lalu"
|
||||||
|
open
|
||||||
|
onClose={onClose}
|
||||||
|
onLoad={onLoad}
|
||||||
|
onSave={onSave}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByRole("spinbutton", { name: "区间金币汇率" })).toHaveValue(80000));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "添加白名单" }));
|
||||||
|
fireEvent.change(screen.getByRole("textbox", { name: "白名单用户 ID" }), { target: { value: "123456" } });
|
||||||
|
fireEvent.change(screen.getByRole("spinbutton", { name: "白名单金币汇率" }), {
|
||||||
|
target: { value: "100000" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "保存" }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(onSave).toHaveBeenCalledWith("lalu", {
|
||||||
|
tiers: [{ coinsPerUsd: 80000, maxUsdAmount: 200, minUsdAmount: 100 }],
|
||||||
|
whitelist: [{ coinsPerUsd: 100000, userId: "123456" }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
@ -2,6 +2,7 @@ import AddOutlined from "@mui/icons-material/AddOutlined";
|
|||||||
import FactCheckOutlined from "@mui/icons-material/FactCheckOutlined";
|
import FactCheckOutlined from "@mui/icons-material/FactCheckOutlined";
|
||||||
import PaidOutlined from "@mui/icons-material/PaidOutlined";
|
import PaidOutlined from "@mui/icons-material/PaidOutlined";
|
||||||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||||
|
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
|
||||||
import Alert from "@mui/material/Alert";
|
import Alert from "@mui/material/Alert";
|
||||||
import Autocomplete from "@mui/material/Autocomplete";
|
import Autocomplete from "@mui/material/Autocomplete";
|
||||||
import Button from "@mui/material/Button";
|
import Button from "@mui/material/Button";
|
||||||
@ -12,7 +13,7 @@ import DialogTitle from "@mui/material/DialogTitle";
|
|||||||
import InputAdornment from "@mui/material/InputAdornment";
|
import InputAdornment from "@mui/material/InputAdornment";
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
COIN_SELLER_RECHARGE_CHAINS,
|
COIN_SELLER_RECHARGE_CHAINS,
|
||||||
COIN_SELLER_RECHARGE_PROVIDER_CURRENCIES,
|
COIN_SELLER_RECHARGE_PROVIDER_CURRENCIES,
|
||||||
@ -23,6 +24,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
buildCoinSellerRechargeReceiptPayload,
|
buildCoinSellerRechargeReceiptPayload,
|
||||||
buildCoinSellerRechargeOrderPayload,
|
buildCoinSellerRechargeOrderPayload,
|
||||||
|
buildCoinSellerRechargeQuotePayload,
|
||||||
coinSellerRechargeChainLabel,
|
coinSellerRechargeChainLabel,
|
||||||
coinSellerRechargeGrantStatusLabel,
|
coinSellerRechargeGrantStatusLabel,
|
||||||
coinSellerRechargeProviderLabel,
|
coinSellerRechargeProviderLabel,
|
||||||
@ -36,20 +38,25 @@ import {
|
|||||||
isCoinSellerRechargeVerified,
|
isCoinSellerRechargeVerified,
|
||||||
validateCoinSellerRechargeOrderPayload,
|
validateCoinSellerRechargeOrderPayload,
|
||||||
} from "../format.js";
|
} from "../format.js";
|
||||||
|
import { FinanceCoinSellerExchangeRateDialog } from "./FinanceCoinSellerExchangeRateDialog.jsx";
|
||||||
|
|
||||||
export function FinanceCoinSellerRechargeOrderList({
|
export function FinanceCoinSellerRechargeOrderList({
|
||||||
actionLoading,
|
actionLoading,
|
||||||
apps,
|
apps,
|
||||||
canCreate,
|
canCreate,
|
||||||
|
canConfigureExchangeRate,
|
||||||
canGrant,
|
canGrant,
|
||||||
canVerify,
|
canVerify,
|
||||||
error,
|
error,
|
||||||
filters,
|
filters,
|
||||||
loading,
|
loading,
|
||||||
onCreate,
|
onCreate,
|
||||||
|
onLoadExchangeRate,
|
||||||
onFiltersChange,
|
onFiltersChange,
|
||||||
onGrant,
|
onGrant,
|
||||||
onReload,
|
onReload,
|
||||||
|
onQuote,
|
||||||
|
onSaveExchangeRate,
|
||||||
onVerify,
|
onVerify,
|
||||||
onVerifyReceipt,
|
onVerifyReceipt,
|
||||||
orders,
|
orders,
|
||||||
@ -57,6 +64,10 @@ export function FinanceCoinSellerRechargeOrderList({
|
|||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [form, setForm] = useState(DEFAULT_COIN_SELLER_RECHARGE_ORDER_FORM);
|
const [form, setForm] = useState(DEFAULT_COIN_SELLER_RECHARGE_ORDER_FORM);
|
||||||
const [formError, setFormError] = useState("");
|
const [formError, setFormError] = useState("");
|
||||||
|
const [quote, setQuote] = useState(null);
|
||||||
|
const [quoteError, setQuoteError] = useState("");
|
||||||
|
const [quoteLoading, setQuoteLoading] = useState(false);
|
||||||
|
const [rateConfigOpen, setRateConfigOpen] = useState(false);
|
||||||
const [receiptVerification, setReceiptVerification] = useState(null);
|
const [receiptVerification, setReceiptVerification] = useState(null);
|
||||||
const [receiptLoading, setReceiptLoading] = useState(false);
|
const [receiptLoading, setReceiptLoading] = useState(false);
|
||||||
const items = orders.items || [];
|
const items = orders.items || [];
|
||||||
@ -64,6 +75,57 @@ export function FinanceCoinSellerRechargeOrderList({
|
|||||||
const pageSize = Number(orders.pageSize || 50);
|
const pageSize = Number(orders.pageSize || 50);
|
||||||
const total = Number(orders.total || 0);
|
const total = Number(orders.total || 0);
|
||||||
const hasNextPage = page * pageSize < total;
|
const hasNextPage = page * pageSize < total;
|
||||||
|
const quoteAppCode = form.appCode;
|
||||||
|
const quoteTargetUserID = form.targetUserId;
|
||||||
|
const quoteUSDAmount = form.usdAmount;
|
||||||
|
const isMakeup = form.isMakeup;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const payload = buildCoinSellerRechargeQuotePayload({
|
||||||
|
appCode: quoteAppCode,
|
||||||
|
targetUserId: quoteTargetUserID,
|
||||||
|
usdAmount: quoteUSDAmount,
|
||||||
|
});
|
||||||
|
if (isMakeup) {
|
||||||
|
setQuote(null);
|
||||||
|
setQuoteError("");
|
||||||
|
setQuoteLoading(false);
|
||||||
|
setForm((current) => (current.coinAmount === "0" ? current : { ...current, coinAmount: "0" }));
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!createOpen || !payload.appCode || !payload.targetUserId || !Number.isFinite(payload.usdAmount) || payload.usdAmount <= 0) {
|
||||||
|
setQuote(null);
|
||||||
|
setQuoteError("");
|
||||||
|
setQuoteLoading(false);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
let active = true;
|
||||||
|
const timer = window.setTimeout(async () => {
|
||||||
|
setQuoteLoading(true);
|
||||||
|
setQuoteError("");
|
||||||
|
try {
|
||||||
|
const result = await onQuote(payload);
|
||||||
|
if (active) {
|
||||||
|
setQuote(result);
|
||||||
|
setForm((current) => ({ ...current, coinAmount: String(result?.coinAmount ?? "") }));
|
||||||
|
}
|
||||||
|
} catch (quoteRequestError) {
|
||||||
|
if (active) {
|
||||||
|
setQuote(null);
|
||||||
|
setForm((current) => ({ ...current, coinAmount: "" }));
|
||||||
|
setQuoteError(quoteRequestError.message || "未能生成充值金币");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (active) {
|
||||||
|
setQuoteLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [createOpen, isMakeup, onQuote, quoteAppCode, quoteTargetUserID, quoteUSDAmount]);
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
setForm({
|
setForm({
|
||||||
@ -71,11 +133,28 @@ export function FinanceCoinSellerRechargeOrderList({
|
|||||||
appCode: filters.appCode || apps[0]?.appCode || "",
|
appCode: filters.appCode || apps[0]?.appCode || "",
|
||||||
});
|
});
|
||||||
setFormError("");
|
setFormError("");
|
||||||
|
setQuote(null);
|
||||||
|
setQuoteError("");
|
||||||
setReceiptVerification(null);
|
setReceiptVerification(null);
|
||||||
setCreateOpen(true);
|
setCreateOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateForm = (key, value) => {
|
const updateForm = (key, value) => {
|
||||||
|
if (key === "isMakeup") {
|
||||||
|
setQuote(null);
|
||||||
|
setQuoteError("");
|
||||||
|
setForm((current) => ({ ...current, coinAmount: value ? "0" : "", isMakeup: value }));
|
||||||
|
setFormError("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (["appCode", "targetUserId", "usdAmount"].includes(key)) {
|
||||||
|
setQuote(null);
|
||||||
|
setQuoteError("");
|
||||||
|
setForm((current) => ({ ...current, [key]: value, coinAmount: current.isMakeup ? "0" : "" }));
|
||||||
|
setFormError("");
|
||||||
|
setReceiptVerification(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (key === "providerCode") {
|
if (key === "providerCode") {
|
||||||
setForm((current) => ({
|
setForm((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@ -100,6 +179,10 @@ export function FinanceCoinSellerRechargeOrderList({
|
|||||||
setFormError("请先校验订单号");
|
setFormError("请先校验订单号");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!form.isMakeup && !quote?.coinAmount) {
|
||||||
|
setFormError(quoteError || "请等待系统生成充值金币");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const payload = buildCoinSellerRechargeOrderPayload(form);
|
const payload = buildCoinSellerRechargeOrderPayload(form);
|
||||||
const validationMessage = validateCoinSellerRechargeOrderPayload(payload);
|
const validationMessage = validateCoinSellerRechargeOrderPayload(payload);
|
||||||
if (validationMessage) {
|
if (validationMessage) {
|
||||||
@ -117,8 +200,6 @@ export function FinanceCoinSellerRechargeOrderList({
|
|||||||
const payload = buildCoinSellerRechargeReceiptPayload(form);
|
const payload = buildCoinSellerRechargeReceiptPayload(form);
|
||||||
const validationMessage = validateCoinSellerRechargeOrderPayload({
|
const validationMessage = validateCoinSellerRechargeOrderPayload({
|
||||||
...buildCoinSellerRechargeOrderPayload(form),
|
...buildCoinSellerRechargeOrderPayload(form),
|
||||||
coinAmount: 1,
|
|
||||||
targetUserId: "verification-only",
|
|
||||||
});
|
});
|
||||||
if (validationMessage) {
|
if (validationMessage) {
|
||||||
setFormError(validationMessage);
|
setFormError(validationMessage);
|
||||||
@ -205,6 +286,11 @@ export function FinanceCoinSellerRechargeOrderList({
|
|||||||
<Button startIcon={<RefreshOutlined fontSize="small" />} variant="outlined" onClick={onReload}>
|
<Button startIcon={<RefreshOutlined fontSize="small" />} variant="outlined" onClick={onReload}>
|
||||||
刷新
|
刷新
|
||||||
</Button>
|
</Button>
|
||||||
|
{canConfigureExchangeRate ? (
|
||||||
|
<Button startIcon={<SettingsOutlined fontSize="small" />} variant="outlined" onClick={() => setRateConfigOpen(true)}>
|
||||||
|
金币汇率配置
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
disabled={!canCreate}
|
disabled={!canCreate}
|
||||||
startIcon={<AddOutlined fontSize="small" />}
|
startIcon={<AddOutlined fontSize="small" />}
|
||||||
@ -377,15 +463,28 @@ export function FinanceCoinSellerRechargeOrderList({
|
|||||||
open={createOpen}
|
open={createOpen}
|
||||||
receiptLoading={receiptLoading}
|
receiptLoading={receiptLoading}
|
||||||
receiptVerification={receiptVerification}
|
receiptVerification={receiptVerification}
|
||||||
|
quote={quote}
|
||||||
|
quoteError={quoteError}
|
||||||
|
quoteLoading={quoteLoading}
|
||||||
onChange={updateForm}
|
onChange={updateForm}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
setReceiptVerification(null);
|
setReceiptVerification(null);
|
||||||
setFormError("");
|
setFormError("");
|
||||||
|
setQuote(null);
|
||||||
|
setQuoteError("");
|
||||||
}}
|
}}
|
||||||
onSubmit={submitCreate}
|
onSubmit={submitCreate}
|
||||||
onVerifyReceipt={verifyCreateReceipt}
|
onVerifyReceipt={verifyCreateReceipt}
|
||||||
/>
|
/>
|
||||||
|
<FinanceCoinSellerExchangeRateDialog
|
||||||
|
apps={apps}
|
||||||
|
initialAppCode={filters.appCode}
|
||||||
|
open={rateConfigOpen}
|
||||||
|
onClose={() => setRateConfigOpen(false)}
|
||||||
|
onLoad={onLoadExchangeRate}
|
||||||
|
onSave={onSaveExchangeRate}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -402,9 +501,12 @@ function CreateDialog({
|
|||||||
open,
|
open,
|
||||||
receiptLoading,
|
receiptLoading,
|
||||||
receiptVerification,
|
receiptVerification,
|
||||||
|
quote,
|
||||||
|
quoteError,
|
||||||
|
quoteLoading,
|
||||||
}) {
|
}) {
|
||||||
const disabled = loading || receiptLoading;
|
const disabled = loading || receiptLoading;
|
||||||
const canCreate = receiptVerification?.verified === true && !disabled;
|
const canCreate = receiptVerification?.verified === true && (form.isMakeup || Boolean(quote?.coinAmount)) && !disabled;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@ -470,15 +572,23 @@ function CreateDialog({
|
|||||||
value={form.usdAmount}
|
value={form.usdAmount}
|
||||||
onChange={(event) => onChange("usdAmount", event.target.value)}
|
onChange={(event) => onChange("usdAmount", event.target.value)}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<Button
|
||||||
|
aria-pressed={form.isMakeup}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
type="button"
|
||||||
|
variant={form.isMakeup ? "contained" : "outlined"}
|
||||||
|
onClick={() => onChange("isMakeup", !form.isMakeup)}
|
||||||
|
>
|
||||||
|
补单
|
||||||
|
</Button>
|
||||||
|
<TextField
|
||||||
|
disabled
|
||||||
InputProps={{ endAdornment: <InputAdornment position="end">金币</InputAdornment> }}
|
InputProps={{ endAdornment: <InputAdornment position="end">金币</InputAdornment> }}
|
||||||
inputProps={{ min: 0, step: "1" }}
|
helperText={quoteHelperText(quote, quoteError, quoteLoading, form.isMakeup)}
|
||||||
label="充值金币"
|
label="充值金币"
|
||||||
required
|
required
|
||||||
type="number"
|
type="number"
|
||||||
value={form.coinAmount}
|
value={form.coinAmount}
|
||||||
onChange={(event) => onChange("coinAmount", event.target.value)}
|
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@ -587,6 +697,23 @@ function currencyOptionLabel(option) {
|
|||||||
return option || "";
|
return option || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function quoteHelperText(quote, error, loading, isMakeup) {
|
||||||
|
if (isMakeup) {
|
||||||
|
return "补单不发放金币,充值金额仍正常计入统计";
|
||||||
|
}
|
||||||
|
if (loading) {
|
||||||
|
return "正在计算金币";
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
if (!quote) {
|
||||||
|
return "输入 APP、用户 ID 和 USD 金额后自动生成";
|
||||||
|
}
|
||||||
|
const source = quote.rateSource === "whitelist" ? `白名单 ${quote.matchedUserId || ""}`.trim() : `${quote.minUsdAmount}-${quote.maxUsdAmount} USD`;
|
||||||
|
return `${source} · 1 USD = ${formatAmount(quote.coinsPerUsd)} 金币`;
|
||||||
|
}
|
||||||
|
|
||||||
function ReceiptVerificationBanner({ verification }) {
|
function ReceiptVerificationBanner({ verification }) {
|
||||||
const detail = [
|
const detail = [
|
||||||
verification.status && `状态 ${verification.status}`,
|
verification.status && `状态 ${verification.status}`,
|
||||||
|
|||||||
@ -8,6 +8,12 @@ afterEach(() => {
|
|||||||
|
|
||||||
test("coin seller recharge order create dialog verifies receipt before create", async () => {
|
test("coin seller recharge order create dialog verifies receipt before create", async () => {
|
||||||
const onCreate = vi.fn(async () => ({ id: "order-2" }));
|
const onCreate = vi.fn(async () => ({ id: "order-2" }));
|
||||||
|
const onQuote = vi.fn(async () => ({
|
||||||
|
coinAmount: 1000000,
|
||||||
|
coinsPerUsd: 100000,
|
||||||
|
matchedUserId: "10001",
|
||||||
|
rateSource: "whitelist",
|
||||||
|
}));
|
||||||
const onVerifyReceipt = vi.fn(async () => ({
|
const onVerifyReceipt = vi.fn(async () => ({
|
||||||
currencyCode: "PHP",
|
currencyCode: "PHP",
|
||||||
providerAmountMinor: 4800,
|
providerAmountMinor: 4800,
|
||||||
@ -16,7 +22,12 @@ test("coin seller recharge order create dialog verifies receipt before create",
|
|||||||
verified: true,
|
verified: true,
|
||||||
}));
|
}));
|
||||||
render(
|
render(
|
||||||
<FinanceCoinSellerRechargeOrderList {...baseProps()} onCreate={onCreate} onVerifyReceipt={onVerifyReceipt} />,
|
<FinanceCoinSellerRechargeOrderList
|
||||||
|
{...baseProps()}
|
||||||
|
onCreate={onCreate}
|
||||||
|
onQuote={onQuote}
|
||||||
|
onVerifyReceipt={onVerifyReceipt}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "创建币商充值" }));
|
fireEvent.click(screen.getByRole("button", { name: "创建币商充值" }));
|
||||||
@ -30,7 +41,9 @@ test("coin seller recharge order create dialog verifies receipt before create",
|
|||||||
|
|
||||||
fireEvent.change(screen.getByRole("textbox", { name: "目标用户 ID" }), { target: { value: "10001" } });
|
fireEvent.change(screen.getByRole("textbox", { name: "目标用户 ID" }), { target: { value: "10001" } });
|
||||||
fireEvent.change(screen.getByRole("spinbutton", { name: "USD 金额" }), { target: { value: "10" } });
|
fireEvent.change(screen.getByRole("spinbutton", { name: "USD 金额" }), { target: { value: "10" } });
|
||||||
fireEvent.change(screen.getByRole("spinbutton", { name: "充值金币" }), { target: { value: "0" } });
|
await waitFor(() => expect(screen.getByRole("spinbutton", { name: "充值金币" })).toHaveValue(1000000));
|
||||||
|
expect(screen.getByRole("spinbutton", { name: "充值金币" })).toBeDisabled();
|
||||||
|
expect(screen.getByText(/白名单 10001.*1 USD = 100,000 金币/)).toBeInTheDocument();
|
||||||
fireEvent.mouseDown(screen.getByRole("combobox", { name: "币种" }));
|
fireEvent.mouseDown(screen.getByRole("combobox", { name: "币种" }));
|
||||||
fireEvent.click(await screen.findByRole("option", { name: "PHP" }));
|
fireEvent.click(await screen.findByRole("option", { name: "PHP" }));
|
||||||
fireEvent.change(screen.getByRole("spinbutton", { name: "币种金额" }), { target: { value: "48" } });
|
fireEvent.change(screen.getByRole("spinbutton", { name: "币种金额" }), { target: { value: "48" } });
|
||||||
@ -53,8 +66,8 @@ test("coin seller recharge order create dialog verifies receipt before create",
|
|||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(onCreate).toHaveBeenCalledWith({
|
expect(onCreate).toHaveBeenCalledWith({
|
||||||
appCode: "lalu",
|
appCode: "lalu",
|
||||||
coinAmount: 0,
|
|
||||||
externalOrderNo: "V5-001",
|
externalOrderNo: "V5-001",
|
||||||
|
isMakeup: false,
|
||||||
providerAmountMinor: 4800,
|
providerAmountMinor: 4800,
|
||||||
providerCode: "v5pay",
|
providerCode: "v5pay",
|
||||||
providerCurrencyCode: "PHP",
|
providerCurrencyCode: "PHP",
|
||||||
@ -65,6 +78,50 @@ test("coin seller recharge order create dialog verifies receipt before create",
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("makeup order keeps zero coins while creating a normal recharge record", async () => {
|
||||||
|
const onCreate = vi.fn(async () => ({ id: "makeup-order" }));
|
||||||
|
const onQuote = vi.fn();
|
||||||
|
const onVerifyReceipt = vi.fn(async () => ({ status: "paid", verified: true }));
|
||||||
|
render(
|
||||||
|
<FinanceCoinSellerRechargeOrderList
|
||||||
|
{...baseProps()}
|
||||||
|
onCreate={onCreate}
|
||||||
|
onQuote={onQuote}
|
||||||
|
onVerifyReceipt={onVerifyReceipt}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "创建币商充值" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "补单" }));
|
||||||
|
expect(screen.getByRole("button", { name: "补单" })).toHaveAttribute("aria-pressed", "true");
|
||||||
|
expect(screen.getByRole("spinbutton", { name: "充值金币" })).toHaveValue(0);
|
||||||
|
expect(screen.getByText("补单不发放金币,充值金额仍正常计入统计")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByRole("textbox", { name: "目标用户 ID" }), { target: { value: "123456" } });
|
||||||
|
fireEvent.change(screen.getByRole("spinbutton", { name: "USD 金额" }), { target: { value: "150" } });
|
||||||
|
fireEvent.mouseDown(screen.getByRole("combobox", { name: "币种" }));
|
||||||
|
fireEvent.click(await screen.findByRole("option", { name: "PHP" }));
|
||||||
|
fireEvent.change(screen.getByRole("spinbutton", { name: "币种金额" }), { target: { value: "7200" } });
|
||||||
|
fireEvent.change(screen.getByRole("textbox", { name: "三方订单号" }), { target: { value: "MIFA-MAKEUP-001" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "校验" }));
|
||||||
|
await screen.findByText(/校验通过/);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "创建" }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(onCreate).toHaveBeenCalledWith({
|
||||||
|
appCode: "lalu",
|
||||||
|
externalOrderNo: "MIFA-MAKEUP-001",
|
||||||
|
isMakeup: true,
|
||||||
|
providerAmountMinor: 720000,
|
||||||
|
providerCode: "mifapay",
|
||||||
|
providerCurrencyCode: "PHP",
|
||||||
|
targetUserId: "123456",
|
||||||
|
usdAmount: 150,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(onQuote).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
test("coin seller recharge order actions follow verify and grant status", () => {
|
test("coin seller recharge order actions follow verify and grant status", () => {
|
||||||
const onVerify = vi.fn();
|
const onVerify = vi.fn();
|
||||||
const onGrant = vi.fn();
|
const onGrant = vi.fn();
|
||||||
@ -135,6 +192,7 @@ function baseProps(overrides = {}) {
|
|||||||
actionLoading: "",
|
actionLoading: "",
|
||||||
apps: [{ appCode: "lalu", appName: "lalu" }],
|
apps: [{ appCode: "lalu", appName: "lalu" }],
|
||||||
canCreate: true,
|
canCreate: true,
|
||||||
|
canConfigureExchangeRate: false,
|
||||||
canGrant: true,
|
canGrant: true,
|
||||||
canVerify: true,
|
canVerify: true,
|
||||||
error: "",
|
error: "",
|
||||||
@ -149,9 +207,12 @@ function baseProps(overrides = {}) {
|
|||||||
},
|
},
|
||||||
loading: false,
|
loading: false,
|
||||||
onCreate: vi.fn(),
|
onCreate: vi.fn(),
|
||||||
|
onLoadExchangeRate: vi.fn(async () => ({ appCode: "lalu", tiers: [], whitelist: [] })),
|
||||||
onFiltersChange: vi.fn(),
|
onFiltersChange: vi.fn(),
|
||||||
onGrant: vi.fn(),
|
onGrant: vi.fn(),
|
||||||
onReload: vi.fn(),
|
onReload: vi.fn(),
|
||||||
|
onQuote: vi.fn(async () => ({ coinAmount: 800000, coinsPerUsd: 80000, rateSource: "tier" })),
|
||||||
|
onSaveExchangeRate: vi.fn(),
|
||||||
onVerify: vi.fn(),
|
onVerify: vi.fn(),
|
||||||
onVerifyReceipt: vi.fn(),
|
onVerifyReceipt: vi.fn(),
|
||||||
orders: {
|
orders: {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
export const FINANCE_PERMISSIONS = {
|
export const FINANCE_PERMISSIONS = {
|
||||||
auditApplication: "finance-application:audit",
|
auditApplication: "finance-application:audit",
|
||||||
auditWithdrawalApplications: "finance-withdrawal:audit",
|
auditWithdrawalApplications: "finance-withdrawal:audit",
|
||||||
|
configureCoinSellerRechargeExchangeRate: "coin-seller:exchange-rate",
|
||||||
createApplication: "finance-application:create",
|
createApplication: "finance-application:create",
|
||||||
createCoinSellerRechargeOrder: "finance-order:coin-seller-recharge:create",
|
createCoinSellerRechargeOrder: "finance-order:coin-seller-recharge:create",
|
||||||
grantCoinSellerRechargeOrder: "finance-order:coin-seller-recharge:grant",
|
grantCoinSellerRechargeOrder: "finance-order:coin-seller-recharge:grant",
|
||||||
@ -111,6 +112,7 @@ export const DEFAULT_COIN_SELLER_RECHARGE_ORDER_FORM = {
|
|||||||
chain: "",
|
chain: "",
|
||||||
coinAmount: "",
|
coinAmount: "",
|
||||||
externalOrderNo: "",
|
externalOrderNo: "",
|
||||||
|
isMakeup: false,
|
||||||
providerAmount: "",
|
providerAmount: "",
|
||||||
providerCode: "mifapay",
|
providerCode: "mifapay",
|
||||||
providerCurrencyCode: "",
|
providerCurrencyCode: "",
|
||||||
|
|||||||
@ -134,8 +134,8 @@ export function buildCoinSellerRechargeOrderPayload(form) {
|
|||||||
const payload = {
|
const payload = {
|
||||||
appCode: stringValue(form.appCode),
|
appCode: stringValue(form.appCode),
|
||||||
chain: providerCode === "usdt" ? stringValue(form.chain).toUpperCase() : "",
|
chain: providerCode === "usdt" ? stringValue(form.chain).toUpperCase() : "",
|
||||||
coinAmount: numberValue(form.coinAmount),
|
|
||||||
externalOrderNo: stringValue(form.externalOrderNo),
|
externalOrderNo: stringValue(form.externalOrderNo),
|
||||||
|
isMakeup: Boolean(form.isMakeup),
|
||||||
providerAmountMinor: requiresThirdPartyProviderAmount(providerCode) ? providerAmountMinor : "",
|
providerAmountMinor: requiresThirdPartyProviderAmount(providerCode) ? providerAmountMinor : "",
|
||||||
providerCode,
|
providerCode,
|
||||||
providerCurrencyCode: requiresThirdPartyProviderAmount(providerCode) ? providerCurrencyCode : "",
|
providerCurrencyCode: requiresThirdPartyProviderAmount(providerCode) ? providerCurrencyCode : "",
|
||||||
@ -174,9 +174,6 @@ export function validateCoinSellerRechargeOrderPayload(payload) {
|
|||||||
if (!Number.isFinite(payload.usdAmount) || payload.usdAmount <= 0) {
|
if (!Number.isFinite(payload.usdAmount) || payload.usdAmount <= 0) {
|
||||||
return "请填写大于 0 的 USD 金额";
|
return "请填写大于 0 的 USD 金额";
|
||||||
}
|
}
|
||||||
if (!Number.isInteger(payload.coinAmount) || payload.coinAmount < 0) {
|
|
||||||
return "请填写不小于 0 的整数金币";
|
|
||||||
}
|
|
||||||
if (!payload.providerCode) {
|
if (!payload.providerCode) {
|
||||||
return "请选择支付渠道";
|
return "请选择支付渠道";
|
||||||
}
|
}
|
||||||
@ -200,6 +197,62 @@ export function validateCoinSellerRechargeOrderPayload(payload) {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildCoinSellerRechargeQuotePayload(form) {
|
||||||
|
return {
|
||||||
|
appCode: stringValue(form.appCode),
|
||||||
|
targetUserId: stringValue(form.targetUserId),
|
||||||
|
usdAmount: numberValue(form.usdAmount),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCoinSellerRechargeExchangeRatePayload(config) {
|
||||||
|
return {
|
||||||
|
tiers: (config.tiers || []).map((item) => ({
|
||||||
|
coinsPerUsd: numberValue(item.coinsPerUsd),
|
||||||
|
maxUsdAmount: numberValue(item.maxUsdAmount),
|
||||||
|
minUsdAmount: numberValue(item.minUsdAmount),
|
||||||
|
})),
|
||||||
|
whitelist: (config.whitelist || []).map((item) => ({
|
||||||
|
coinsPerUsd: numberValue(item.coinsPerUsd),
|
||||||
|
userId: stringValue(item.userId),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateCoinSellerRechargeExchangeRatePayload(payload) {
|
||||||
|
if (!payload.tiers.length) {
|
||||||
|
return "至少添加一个 USD 金额区间";
|
||||||
|
}
|
||||||
|
const tiers = [...payload.tiers].sort((left, right) => left.minUsdAmount - right.minUsdAmount);
|
||||||
|
for (let index = 0; index < tiers.length; index += 1) {
|
||||||
|
const item = tiers[index];
|
||||||
|
if (
|
||||||
|
!Number.isFinite(item.minUsdAmount) ||
|
||||||
|
item.minUsdAmount <= 0 ||
|
||||||
|
!Number.isFinite(item.maxUsdAmount) ||
|
||||||
|
item.maxUsdAmount < item.minUsdAmount ||
|
||||||
|
!Number.isInteger(item.coinsPerUsd) ||
|
||||||
|
item.coinsPerUsd <= 0
|
||||||
|
) {
|
||||||
|
return "请填写正确的 USD 金额区间和金币汇率";
|
||||||
|
}
|
||||||
|
if (index > 0 && item.minUsdAmount <= tiers[index - 1].maxUsdAmount) {
|
||||||
|
return "USD 金额区间不能重叠";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const userIds = new Set();
|
||||||
|
for (const item of payload.whitelist) {
|
||||||
|
if (!item.userId || !Number.isInteger(item.coinsPerUsd) || item.coinsPerUsd <= 0) {
|
||||||
|
return "请填写正确的白名单用户 ID 和金币汇率";
|
||||||
|
}
|
||||||
|
if (userIds.has(item.userId)) {
|
||||||
|
return `白名单用户 ID 重复:${item.userId}`;
|
||||||
|
}
|
||||||
|
userIds.add(item.userId);
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeCoinSellerRechargeOrder(item = {}) {
|
export function normalizeCoinSellerRechargeOrder(item = {}) {
|
||||||
return {
|
return {
|
||||||
appCode: stringValue(item.appCode ?? item.app_code),
|
appCode: stringValue(item.appCode ?? item.app_code),
|
||||||
|
|||||||
@ -2,7 +2,9 @@ import { expect, test } from "vitest";
|
|||||||
import {
|
import {
|
||||||
buildApplicationPayload,
|
buildApplicationPayload,
|
||||||
buildCoinSellerRechargeReceiptPayload,
|
buildCoinSellerRechargeReceiptPayload,
|
||||||
|
buildCoinSellerRechargeExchangeRatePayload,
|
||||||
buildCoinSellerRechargeOrderPayload,
|
buildCoinSellerRechargeOrderPayload,
|
||||||
|
buildCoinSellerRechargeQuotePayload,
|
||||||
coinSellerRechargeProviderLabel,
|
coinSellerRechargeProviderLabel,
|
||||||
normalizeApplicationPage,
|
normalizeApplicationPage,
|
||||||
normalizeCoinSellerRechargeOrderPage,
|
normalizeCoinSellerRechargeOrderPage,
|
||||||
@ -13,6 +15,7 @@ import {
|
|||||||
statusLabel,
|
statusLabel,
|
||||||
validateApplicationPayload,
|
validateApplicationPayload,
|
||||||
validateCoinSellerRechargeOrderPayload,
|
validateCoinSellerRechargeOrderPayload,
|
||||||
|
validateCoinSellerRechargeExchangeRatePayload,
|
||||||
walletIdentityLabel,
|
walletIdentityLabel,
|
||||||
} from "./format.js";
|
} from "./format.js";
|
||||||
|
|
||||||
@ -180,8 +183,8 @@ test("builds validates and normalizes coin seller recharge orders", () => {
|
|||||||
|
|
||||||
expect(payload).toEqual({
|
expect(payload).toEqual({
|
||||||
appCode: "lalu",
|
appCode: "lalu",
|
||||||
coinAmount: 100000,
|
|
||||||
externalOrderNo: "MIFA-001",
|
externalOrderNo: "MIFA-001",
|
||||||
|
isMakeup: false,
|
||||||
providerAmountMinor: 125050,
|
providerAmountMinor: 125050,
|
||||||
providerCode: "mifapay",
|
providerCode: "mifapay",
|
||||||
providerCurrencyCode: "PHP",
|
providerCurrencyCode: "PHP",
|
||||||
@ -208,8 +211,6 @@ test("builds validates and normalizes coin seller recharge orders", () => {
|
|||||||
expect(validateCoinSellerRechargeOrderPayload({ ...payload, providerAmountMinor: 0 })).toBe(
|
expect(validateCoinSellerRechargeOrderPayload({ ...payload, providerAmountMinor: 0 })).toBe(
|
||||||
"请填写大于 0 的币种金额",
|
"请填写大于 0 的币种金额",
|
||||||
);
|
);
|
||||||
expect(validateCoinSellerRechargeOrderPayload({ ...payload, coinAmount: 0 })).toBe("");
|
|
||||||
expect(validateCoinSellerRechargeOrderPayload({ ...payload, coinAmount: -1 })).toBe("请填写不小于 0 的整数金币");
|
|
||||||
expect(validateCoinSellerRechargeOrderPayload({ ...payload, remark: "x".repeat(513) })).toBe("备注不能超过 512 字");
|
expect(validateCoinSellerRechargeOrderPayload({ ...payload, remark: "x".repeat(513) })).toBe("备注不能超过 512 字");
|
||||||
expect(validateCoinSellerRechargeOrderPayload({ ...payload, providerCode: "usdt", chain: "" })).toBe(
|
expect(validateCoinSellerRechargeOrderPayload({ ...payload, providerCode: "usdt", chain: "" })).toBe(
|
||||||
"请选择 USDT 链",
|
"请选择 USDT 链",
|
||||||
@ -260,8 +261,8 @@ test("builds validates and normalizes coin seller recharge orders", () => {
|
|||||||
).toEqual({
|
).toEqual({
|
||||||
appCode: "lalu",
|
appCode: "lalu",
|
||||||
chain: "TRON",
|
chain: "TRON",
|
||||||
coinAmount: 100000,
|
|
||||||
externalOrderNo: "MIFA-001",
|
externalOrderNo: "MIFA-001",
|
||||||
|
isMakeup: false,
|
||||||
providerCode: "usdt",
|
providerCode: "usdt",
|
||||||
remark: "人工补单",
|
remark: "人工补单",
|
||||||
targetUserId: "10001",
|
targetUserId: "10001",
|
||||||
@ -321,6 +322,38 @@ test("builds validates and normalizes coin seller recharge orders", () => {
|
|||||||
expect(coinSellerRechargeProviderLabel("mifapay")).toBe("MiFaPay");
|
expect(coinSellerRechargeProviderLabel("mifapay")).toBe("MiFaPay");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("builds coin seller recharge quotes and validates exchange rate config", () => {
|
||||||
|
expect(buildCoinSellerRechargeQuotePayload({ appCode: " lalu ", targetUserId: " 123456 ", usdAmount: "150" })).toEqual({
|
||||||
|
appCode: "lalu",
|
||||||
|
targetUserId: "123456",
|
||||||
|
usdAmount: 150,
|
||||||
|
});
|
||||||
|
const payload = buildCoinSellerRechargeExchangeRatePayload({
|
||||||
|
tiers: [
|
||||||
|
{ minUsdAmount: "100", maxUsdAmount: "200", coinsPerUsd: "80000" },
|
||||||
|
{ minUsdAmount: "201", maxUsdAmount: "300", coinsPerUsd: "90000" },
|
||||||
|
],
|
||||||
|
whitelist: [{ userId: " 123456 ", coinsPerUsd: "100000" }],
|
||||||
|
});
|
||||||
|
expect(validateCoinSellerRechargeExchangeRatePayload(payload)).toBe("");
|
||||||
|
expect(payload).toEqual({
|
||||||
|
tiers: [
|
||||||
|
{ minUsdAmount: 100, maxUsdAmount: 200, coinsPerUsd: 80000 },
|
||||||
|
{ minUsdAmount: 201, maxUsdAmount: 300, coinsPerUsd: 90000 },
|
||||||
|
],
|
||||||
|
whitelist: [{ userId: "123456", coinsPerUsd: 100000 }],
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
validateCoinSellerRechargeExchangeRatePayload({
|
||||||
|
...payload,
|
||||||
|
tiers: [
|
||||||
|
payload.tiers[0],
|
||||||
|
{ minUsdAmount: 200, maxUsdAmount: 300, coinsPerUsd: 90000 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).toBe("USD 金额区间不能重叠");
|
||||||
|
});
|
||||||
|
|
||||||
test("normalizes app recharge detail list rows with optional payment fields", () => {
|
test("normalizes app recharge detail list rows with optional payment fields", () => {
|
||||||
const page = normalizeRechargeBillPage({
|
const page = normalizeRechargeBillPage({
|
||||||
items: [
|
items: [
|
||||||
|
|||||||
@ -564,6 +564,67 @@ a {
|
|||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.finance-rate-config {
|
||||||
|
display: grid;
|
||||||
|
gap: 24px;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-rate-config__section {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-rate-config__heading {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-rate-config__heading h3 {
|
||||||
|
font-size: 15px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-rate-config__table {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-rate-config__row {
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-rate-config__row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-rate-config__row--3 {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr)) 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-rate-config__row--2 {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr)) 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-rate-config__row--head {
|
||||||
|
background: var(--bg-card-strong);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-rate-config__empty {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.finance-receipt-field {
|
.finance-receipt-field {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@ -181,6 +181,40 @@ test("renders cp weekly rank route with an authenticated session", async () => {
|
|||||||
expect(await screen.findByText("已发放")).toBeInTheDocument();
|
expect(await screen.findByText("已发放")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("renders the shareable app user detail route with the detail header", async () => {
|
||||||
|
setAccessToken("test-token");
|
||||||
|
vi.mocked(fetch).mockImplementation(async (input) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url.includes("/v1/auth/refresh") || url.includes("/v1/auth/me")) {
|
||||||
|
return jsonResponse({
|
||||||
|
accessToken: "test-token",
|
||||||
|
permissions: ["app-user:view"],
|
||||||
|
user: { userId: 1, username: "admin" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.includes("/v1/admin/apps")) {
|
||||||
|
return jsonResponse({ items: [{ appCode: "lalu", name: "Lalu" }], total: 1 });
|
||||||
|
}
|
||||||
|
if (url.includes("/v1/admin/navigation/menus")) {
|
||||||
|
return jsonResponse([]);
|
||||||
|
}
|
||||||
|
if (url.includes("/v1/app/users/10001")) {
|
||||||
|
return jsonResponse({
|
||||||
|
display_user_id: "163337",
|
||||||
|
status: "active",
|
||||||
|
user_id: "10001",
|
||||||
|
username: "detail-user",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return jsonResponse(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithRoute("/app/users/10001");
|
||||||
|
|
||||||
|
expect(await screen.findByRole("heading", { level: 1, name: "用户详情" })).toBeInTheDocument();
|
||||||
|
expect((await screen.findAllByText("detail-user")).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
test("admin routes declare menu code and permission", () => {
|
test("admin routes declare menu code and permission", () => {
|
||||||
expect(adminRoutes.length).toBeGreaterThan(0);
|
expect(adminRoutes.length).toBeGreaterThan(0);
|
||||||
expect(adminRoutes.every((route) => route.menuCode && route.permission)).toBe(true);
|
expect(adminRoutes.every((route) => route.menuCode && route.permission)).toBe(true);
|
||||||
|
|||||||
@ -3,31 +3,34 @@ import { AdminLayout } from "@/app/layout/AdminLayout.jsx";
|
|||||||
import { DeferredPage } from "@/app/router/DeferredPage.jsx";
|
import { DeferredPage } from "@/app/router/DeferredPage.jsx";
|
||||||
import { RequireAuth, RequirePermission } from "@/app/router/guards.jsx";
|
import { RequireAuth, RequirePermission } from "@/app/router/guards.jsx";
|
||||||
import { adminRoutes, defaultAdminPath, publicRoutes } from "@/app/router/routeConfig";
|
import { adminRoutes, defaultAdminPath, publicRoutes } from "@/app/router/routeConfig";
|
||||||
|
import { AppUserDetailProvider } from "@/features/app-users/components/AppUserDetailProvider.jsx";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<AppUserDetailProvider>
|
||||||
{publicRoutes.map((route) => (
|
<Routes>
|
||||||
<Route key={route.path} path={route.path} element={<DeferredPage route={route} />} />
|
{publicRoutes.map((route) => (
|
||||||
))}
|
<Route key={route.path} path={route.path} element={<DeferredPage route={route} />} />
|
||||||
<Route element={<RequireAuth />}>
|
))}
|
||||||
<Route element={<AdminLayout />}>
|
<Route element={<RequireAuth />}>
|
||||||
<Route index element={<Navigate to={defaultAdminPath} replace />} />
|
<Route element={<AdminLayout />}>
|
||||||
{adminRoutes.map((route) => (
|
<Route index element={<Navigate to={defaultAdminPath} replace />} />
|
||||||
<Route
|
{adminRoutes.map((route) => (
|
||||||
key={route.path}
|
<Route
|
||||||
path={route.path}
|
key={route.path}
|
||||||
element={
|
path={route.path}
|
||||||
<RequirePermission code={route.permission}>
|
element={
|
||||||
<DeferredPage route={route} />
|
<RequirePermission code={route.permission}>
|
||||||
</RequirePermission>
|
<DeferredPage route={route} />
|
||||||
}
|
</RequirePermission>
|
||||||
/>
|
}
|
||||||
))}
|
/>
|
||||||
|
))}
|
||||||
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
<Route path="*" element={<Navigate to={defaultAdminPath} replace />} />
|
||||||
<Route path="*" element={<Navigate to={defaultAdminPath} replace />} />
|
</Routes>
|
||||||
</Routes>
|
</AppUserDetailProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
import { matchPath, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||||
import { getMenus } from "@/features/menus/api";
|
import { getMenus } from "@/features/menus/api";
|
||||||
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
||||||
@ -12,6 +12,7 @@ import {
|
|||||||
mergeNavigationItems
|
mergeNavigationItems
|
||||||
} from "@/app/navigation/menu.js";
|
} from "@/app/navigation/menu.js";
|
||||||
import { recordSecondLevelMenuVisit } from "@/app/navigation/menuUsage.js";
|
import { recordSecondLevelMenuVisit } from "@/app/navigation/menuUsage.js";
|
||||||
|
import { adminRoutes } from "@/app/router/routeConfig";
|
||||||
import { Header } from "./Header.jsx";
|
import { Header } from "./Header.jsx";
|
||||||
import { Sidebar } from "./Sidebar.jsx";
|
import { Sidebar } from "./Sidebar.jsx";
|
||||||
|
|
||||||
@ -35,6 +36,10 @@ export function AdminLayout() {
|
|||||||
return mergeNavigationItems(mapBackendMenus(backendMenus), fallbackMenus, fallbackNavigation);
|
return mergeNavigationItems(mapBackendMenus(backendMenus), fallbackMenus, fallbackNavigation);
|
||||||
}, [backendMenus, can]);
|
}, [backendMenus, can]);
|
||||||
const activeNav = useMemo(() => findNavItemByPath(location.pathname, menus), [location.pathname, menus]);
|
const activeNav = useMemo(() => findNavItemByPath(location.pathname, menus), [location.pathname, menus]);
|
||||||
|
const activeRoute = useMemo(
|
||||||
|
() => adminRoutes.find((route) => matchPath({ end: true, path: route.path }, location.pathname)),
|
||||||
|
[location.pathname]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
recordSecondLevelMenuVisit({ menus, pathname: location.pathname, user });
|
recordSecondLevelMenuVisit({ menus, pathname: location.pathname, user });
|
||||||
@ -43,7 +48,7 @@ export function AdminLayout() {
|
|||||||
return (
|
return (
|
||||||
<div className={`app-shell ${sidebarCollapsed ? "app-shell--sidebar-collapsed" : ""}`}>
|
<div className={`app-shell ${sidebarCollapsed ? "app-shell--sidebar-collapsed" : ""}`}>
|
||||||
<Header
|
<Header
|
||||||
activeLabel={activeNav?.label || "总览"}
|
activeLabel={activeRoute?.label || activeNav?.label || "总览"}
|
||||||
isSidebarCollapsed={sidebarCollapsed}
|
isSidebarCollapsed={sidebarCollapsed}
|
||||||
menuItems={menus}
|
menuItems={menus}
|
||||||
onRefresh={refresh}
|
onRefresh={refresh}
|
||||||
|
|||||||
@ -225,6 +225,7 @@ export const MENU_CODES = {
|
|||||||
logsOperations: "logs-operations",
|
logsOperations: "logs-operations",
|
||||||
appUsers: "app-users",
|
appUsers: "app-users",
|
||||||
appUserList: "app-user-list",
|
appUserList: "app-user-list",
|
||||||
|
appUserDetail: "app-user-detail",
|
||||||
appUserBans: "app-user-bans",
|
appUserBans: "app-user-bans",
|
||||||
appUserLoginLogs: "app-user-login-logs",
|
appUserLoginLogs: "app-user-login-logs",
|
||||||
appUserLevelConfig: "app-user-level-config",
|
appUserLevelConfig: "app-user-level-config",
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import { ConfirmProvider } from "@/shared/ui/ConfirmProvider.jsx";
|
|||||||
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
|
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
|
||||||
import { QueryProvider } from "@/shared/query/QueryProvider.jsx";
|
import { QueryProvider } from "@/shared/query/QueryProvider.jsx";
|
||||||
import { RefreshProvider } from "@/shared/hooks/useRefreshSignal.js";
|
import { RefreshProvider } from "@/shared/hooks/useRefreshSignal.js";
|
||||||
import { AppUserDetailProvider } from "@/features/app-users/components/AppUserDetailProvider.jsx";
|
|
||||||
import { theme } from "@/theme.js";
|
import { theme } from "@/theme.js";
|
||||||
|
|
||||||
export function AppProviders({ children }) {
|
export function AppProviders({ children }) {
|
||||||
@ -19,7 +18,7 @@ export function AppProviders({ children }) {
|
|||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<AppScopeProvider>
|
<AppScopeProvider>
|
||||||
<TimeZoneProvider>
|
<TimeZoneProvider>
|
||||||
<AppUserDetailProvider>{children}</AppUserDetailProvider>
|
{children}
|
||||||
</TimeZoneProvider>
|
</TimeZoneProvider>
|
||||||
</AppScopeProvider>
|
</AppScopeProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
|||||||
@ -244,6 +244,56 @@
|
|||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.genderAgeFemale,
|
||||||
|
.genderAgeMale,
|
||||||
|
.genderAgeUnknown {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: 20px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genderAgeFemale {
|
||||||
|
background: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.genderAgeMale {
|
||||||
|
background: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.genderAgeUnknown {
|
||||||
|
background: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.userLevels {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userLevel {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 3px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userLevel strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: var(--admin-font-size);
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
.coinButton {
|
.coinButton {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@ -435,17 +485,87 @@
|
|||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detailDrawerBody {
|
||||||
|
display: block;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailView {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
container-type: inline-size;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailStickyHeader {
|
||||||
|
position: sticky;
|
||||||
|
z-index: 2;
|
||||||
|
top: 0;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding-bottom: var(--space-2);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
.detailHero {
|
.detailHero {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
padding-bottom: var(--space-4);
|
padding-bottom: var(--space-3);
|
||||||
border-bottom: 1px solid var(--border);
|
}
|
||||||
|
|
||||||
|
.detailHeroActions {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailMetrics {
|
||||||
|
display: grid;
|
||||||
|
min-width: 620px;
|
||||||
|
grid-template-columns: repeat(7, minmax(72px, 1fr));
|
||||||
|
border-top: 1px solid var(--border-soft);
|
||||||
|
border-bottom: 1px solid var(--border-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailMetric {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 2px;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-left: 1px solid var(--border-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailMetric:first-child {
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailMetric span {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 650;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailMetric strong {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: var(--admin-font-size);
|
||||||
|
font-weight: 800;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detailLoading {
|
.detailLoading {
|
||||||
|
margin-top: var(--space-2);
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
border: 1px solid var(--primary-border);
|
border: 1px solid var(--primary-border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
@ -455,13 +575,49 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detailTabs {
|
||||||
|
min-height: 40px;
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailTabs :global(.MuiTabs-indicator) {
|
||||||
|
height: 3px;
|
||||||
|
border-radius: var(--radius-pill) var(--radius-pill) 0 0;
|
||||||
|
background: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailTab.detailTab {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 0 var(--space-4);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: var(--admin-font-size);
|
||||||
|
font-weight: 750;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailTab.detailTab:hover,
|
||||||
|
.detailTab.detailTab:global(.Mui-selected) {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailTabPanel {
|
||||||
|
display: grid;
|
||||||
|
gap: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
.detailSection {
|
.detailSection {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
padding: var(--space-4);
|
padding: var(--space-5) 0;
|
||||||
border: 1px solid var(--border);
|
border-bottom: 1px solid var(--border-soft);
|
||||||
border-radius: var(--radius-card);
|
}
|
||||||
background: var(--bg-card-strong);
|
|
||||||
|
.detailSection:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detailSectionTitle {
|
.detailSectionTitle {
|
||||||
@ -474,18 +630,29 @@
|
|||||||
.detailGrid {
|
.detailGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: var(--space-3);
|
column-gap: var(--space-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.detailItem {
|
.detailItem {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
gap: 4px;
|
min-height: 32px;
|
||||||
|
grid-template-columns: minmax(76px, 0.48fr) minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px solid var(--detail-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailItemWide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detailLabel {
|
.detailLabel {
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: var(--admin-line-height);
|
||||||
}
|
}
|
||||||
|
|
||||||
.detailValue {
|
.detailValue {
|
||||||
@ -494,6 +661,175 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: var(--admin-font-size);
|
font-size: var(--admin-font-size);
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
|
line-height: var(--admin-line-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyableDetailValue {
|
||||||
|
display: inline-flex;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyableDetailValue > span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyDetailButton {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyDetailButton:hover,
|
||||||
|
.copyDetailButton:focus-visible {
|
||||||
|
background: var(--primary-surface);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyDetailButton:focus-visible {
|
||||||
|
outline: 2px solid var(--primary-border);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailPageRoot {
|
||||||
|
display: flex;
|
||||||
|
height: calc(100vh - var(--header-height) - var(--space-8) - var(--space-4));
|
||||||
|
height: calc(100dvh - var(--header-height) - var(--space-8) - var(--space-4));
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailPageToolbar {
|
||||||
|
display: flex;
|
||||||
|
min-height: 58px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-4);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: var(--space-3) var(--space-5);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailPageTitleGroup {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailPageTitleGroup h1 {
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailPageContent {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0 var(--space-6) var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailPageContent :global(.data-state) {
|
||||||
|
min-height: 100%;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailViewPage {
|
||||||
|
max-width: 1440px;
|
||||||
|
min-height: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container (min-width: 900px) {
|
||||||
|
.detailGrid {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@container (max-width: 640px) {
|
||||||
|
.detailMetrics {
|
||||||
|
min-width: 0;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailMetric:nth-child(5) {
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailGrid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailItemWide {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailHero {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailHeroActions {
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@container (max-width: 400px) {
|
||||||
|
.detailMetrics {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailMetric:nth-child(odd) {
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.copyDetailButton {
|
||||||
|
transition:
|
||||||
|
background-color var(--motion-fast) var(--ease-standard),
|
||||||
|
color var(--motion-fast) var(--ease-standard),
|
||||||
|
transform var(--motion-fast) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyDetailButton:active {
|
||||||
|
transform: scale(0.92);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.detailPageToolbar,
|
||||||
|
.detailPageTitleGroup {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailPageContent {
|
||||||
|
padding-right: var(--space-4);
|
||||||
|
padding-left: var(--space-4);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.vipBadge,
|
.vipBadge,
|
||||||
|
|||||||
@ -1,19 +1,9 @@
|
|||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { AdminPrettyDisplayID, AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
|
||||||
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
|
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
|
||||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
|
||||||
import { getAppUser } from "@/features/app-users/api";
|
import { getAppUser } from "@/features/app-users/api";
|
||||||
import { appUserStatusLabels } from "@/features/app-users/constants.js";
|
|
||||||
import { AppUserDetailContext } from "@/features/app-users/components/AppUserDetailContext.jsx";
|
import { AppUserDetailContext } from "@/features/app-users/components/AppUserDetailContext.jsx";
|
||||||
import {
|
import { AppUserDetailView } from "@/features/app-users/components/AppUserDetailView.jsx";
|
||||||
BanCountdown,
|
|
||||||
GenderValue,
|
|
||||||
LevelValue,
|
|
||||||
OperatorValue,
|
|
||||||
RolesValue,
|
|
||||||
formatUsdMinor,
|
|
||||||
} from "@/features/app-users/components/AppUserValues.jsx";
|
|
||||||
import styles from "@/features/app-users/app-users.module.css";
|
import styles from "@/features/app-users/app-users.module.css";
|
||||||
|
|
||||||
export function AppUserDetailProvider({ children }) {
|
export function AppUserDetailProvider({ children }) {
|
||||||
@ -43,6 +33,7 @@ export function AppUserDetailProvider({ children }) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const latestUser = await getAppUser(seedUser.userId);
|
const latestUser = await getAppUser(seedUser.userId);
|
||||||
|
// 只接收最后一次打开请求的结果,避免连续点击多行时较慢的旧请求覆盖当前用户。
|
||||||
if (requestSeqRef.current === requestSeq) {
|
if (requestSeqRef.current === requestSeq) {
|
||||||
setDetailUser({ ...seedUser, ...latestUser });
|
setDetailUser({ ...seedUser, ...latestUser });
|
||||||
}
|
}
|
||||||
@ -78,172 +69,25 @@ function AppUserDetailDrawer({ loading, onClose, open, user }) {
|
|||||||
if (!open || !user) {
|
if (!open || !user) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const balances = user.balances || [];
|
|
||||||
const equippedResources = user.equippedResources || [];
|
|
||||||
const resources = user.resources || [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SideDrawer open={open} title="用户详情" width="wide" onClose={onClose}>
|
<SideDrawer
|
||||||
<div className={styles.detailHero}>
|
contentClassName={styles.detailDrawerBody}
|
||||||
<AdminUserIdentity user={user} size="large" />
|
open={open}
|
||||||
<AppUserStatus status={user.status} />
|
title="用户详情"
|
||||||
</div>
|
width="detail"
|
||||||
{loading ? <div className={styles.detailLoading}>正在刷新用户详情...</div> : null}
|
onClose={onClose}
|
||||||
<section className={styles.detailSection}>
|
>
|
||||||
<h3 className={styles.detailSectionTitle}>身份信息</h3>
|
<AppUserDetailView
|
||||||
<div className={styles.detailGrid}>
|
fullDetailsHref={`/app/users/${encodeURIComponent(user.userId)}`}
|
||||||
<DetailItem label="用户 ID" value={user.userId} />
|
loading={loading}
|
||||||
<DetailItem label="短 ID" value={user.defaultDisplayUserId || user.displayUserId || user.userId} />
|
user={user}
|
||||||
<DetailItem label="靓号" value={<PrettyValue user={user} />} />
|
onOpenFullDetails={onClose}
|
||||||
<DetailItem label="靓号记录 ID" value={user.prettyId} />
|
/>
|
||||||
<DetailItem label="昵称" value={user.username} />
|
|
||||||
<DetailItem label="性别" value={<GenderValue gender={user.gender} />} />
|
|
||||||
<DetailItem label="生日" value={user.birth} />
|
|
||||||
<DetailItem label="年龄" value={formatAge(user.age)} />
|
|
||||||
<DetailItem label="注册设备" value={user.registerDevice} />
|
|
||||||
<DetailItem label="身份" value={<RolesValue roles={user.roles} />} />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section className={styles.detailSection}>
|
|
||||||
<h3 className={styles.detailSectionTitle}>等级信息</h3>
|
|
||||||
<div className={styles.detailGrid}>
|
|
||||||
<DetailItem label="富豪等级" value={<LevelValue level={user.levels?.wealth} showReal />} />
|
|
||||||
<DetailItem label="魅力等级" value={<LevelValue level={user.levels?.charm} showReal />} />
|
|
||||||
<DetailItem label="游戏等级" value={<LevelValue level={user.levels?.game} showReal />} />
|
|
||||||
<DetailItem label="VIP 等级" value={<VipDetailValue vip={user.vip} />} />
|
|
||||||
<DetailItem label="VIP 到期时间" value={formatVipExpire(user.vip)} />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section className={styles.detailSection}>
|
|
||||||
<h3 className={styles.detailSectionTitle}>账号状态</h3>
|
|
||||||
<div className={styles.detailGrid}>
|
|
||||||
<DetailItem label="状态" value={appUserStatusLabels[user.status] || user.status || "-"} />
|
|
||||||
<DetailItem label="封禁剩余" value={<BanCountdown ban={user.ban} status={user.status} />} />
|
|
||||||
<DetailItem label="封禁原因" value={user.ban?.reason} />
|
|
||||||
<DetailItem label="金币" value={formatNumber(user.coin)} />
|
|
||||||
<DetailItem label="钻石" value={formatNumber(user.diamond)} />
|
|
||||||
<DetailItem label="累计充值" value={formatUsdMinor(user.cumulativeRechargeUsdMinor)} />
|
|
||||||
<DetailItem label="国家" value={formatCountry(user)} />
|
|
||||||
<DetailItem
|
|
||||||
label="区域"
|
|
||||||
value={user.regionName || (user.regionId ? `区域 ${user.regionId}` : "-")}
|
|
||||||
/>
|
|
||||||
<DetailItem label="最新成功登录" value={formatMillis(user.lastLoginAtMs)} />
|
|
||||||
<DetailItem label="最近活跃" value={formatMillis(user.lastActiveAtMs)} />
|
|
||||||
<DetailItem
|
|
||||||
label="最新操作"
|
|
||||||
value={<OperatorValue operatedAtMs={user.lastOperatedAtMs} operator={user.lastOperator} />}
|
|
||||||
/>
|
|
||||||
<DetailItem label="创建时间" value={formatMillis(user.createdAtMs)} />
|
|
||||||
<DetailItem label="更新时间" value={formatMillis(user.updatedAtMs)} />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section className={styles.detailSection}>
|
|
||||||
<h3 className={styles.detailSectionTitle}>资产余额</h3>
|
|
||||||
<AssetBalanceList balances={balances} />
|
|
||||||
</section>
|
|
||||||
<section className={styles.detailSection}>
|
|
||||||
<h3 className={styles.detailSectionTitle}>当前佩戴</h3>
|
|
||||||
<ResourceList resources={equippedResources} />
|
|
||||||
</section>
|
|
||||||
<section className={styles.detailSection}>
|
|
||||||
<h3 className={styles.detailSectionTitle}>资源资产</h3>
|
|
||||||
<ResourceList resources={resources} />
|
|
||||||
</section>
|
|
||||||
</SideDrawer>
|
</SideDrawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DetailItem({ label, value }) {
|
|
||||||
const isElement = value && typeof value === "object";
|
|
||||||
return (
|
|
||||||
<div className={styles.detailItem}>
|
|
||||||
<span className={styles.detailLabel}>{label}</span>
|
|
||||||
<span className={styles.detailValue}>
|
|
||||||
{isElement ? value : value === undefined || value === null || value === "" ? "-" : value}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PrettyValue({ user }) {
|
|
||||||
if (!user.prettyId || !user.prettyDisplayUserId) {
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
return <AdminPrettyDisplayID prettyDisplayUserId={user.prettyDisplayUserId} prettyId={user.prettyId} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function VipDetailValue({ vip }) {
|
|
||||||
const level = Number(vip?.level || 0);
|
|
||||||
if (level <= 0 || !vip?.active) {
|
|
||||||
return "VIP 0";
|
|
||||||
}
|
|
||||||
return vip.name ? `VIP ${level} · ${vip.name}` : `VIP ${level}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function AssetBalanceList({ balances }) {
|
|
||||||
if (!balances.length) {
|
|
||||||
return <div className={styles.emptyInline}>当前无数据</div>;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className={styles.assetList}>
|
|
||||||
{balances.map((balance) => (
|
|
||||||
<div className={styles.assetRow} key={balance.assetType}>
|
|
||||||
<div>
|
|
||||||
<div className={styles.assetName}>{formatAssetType(balance.assetType)}</div>
|
|
||||||
<div className={styles.assetMeta}>冻结 {formatNumber(balance.frozenAmount)}</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.assetAmount}>{formatNumber(balance.availableAmount)}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ResourceList({ resources }) {
|
|
||||||
if (!resources.length) {
|
|
||||||
return <div className={styles.emptyInline}>当前无数据</div>;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<ul className={styles.resourceList}>
|
|
||||||
{resources.map((resource) => (
|
|
||||||
<li className={styles.resourceItem} key={resource.entitlementId || resource.resourceId}>
|
|
||||||
<ResourceThumb resource={resource} />
|
|
||||||
<div className={styles.resourceBody}>
|
|
||||||
<div className={styles.resourceTitle}>
|
|
||||||
<span>{resource.name || resource.resourceCode || `资源 ${resource.resourceId}`}</span>
|
|
||||||
{resource.equipped ? <span className={styles.resourceTag}>佩戴中</span> : null}
|
|
||||||
</div>
|
|
||||||
<div className={styles.resourceMeta}>
|
|
||||||
{formatResourceType(resource.resourceType)} · 剩余{" "}
|
|
||||||
{formatNumber(resource.remainingQuantity)} / {formatNumber(resource.quantity)}
|
|
||||||
</div>
|
|
||||||
<div className={styles.resourceMeta}>到期 {formatExpireTime(resource.expiresAtMs)}</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ResourceThumb({ resource }) {
|
|
||||||
const imageUrl = resource.previewUrl || resource.assetUrl || resource.animationUrl;
|
|
||||||
if (!imageUrl) {
|
|
||||||
return <span className={styles.resourceThumbFallback}>{resource.name?.slice(0, 1) || "资"}</span>;
|
|
||||||
}
|
|
||||||
return <img alt="" className={styles.resourceThumb} src={imageUrl} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function AppUserStatus({ status }) {
|
|
||||||
const tone = status === "active" ? "running" : "danger";
|
|
||||||
return (
|
|
||||||
<span className={`status-badge status-badge--${tone}`}>
|
|
||||||
<span className="status-point" />
|
|
||||||
{appUserStatusLabels[status] || status || "-"}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDetailSeed(source) {
|
function normalizeDetailSeed(source) {
|
||||||
if (source === undefined || source === null) {
|
if (source === undefined || source === null) {
|
||||||
return {};
|
return {};
|
||||||
@ -273,63 +117,6 @@ function normalizeDetailSeed(source) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCountry(user) {
|
|
||||||
return user.countryDisplayName || user.countryName || user.country || "-";
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatNumber(value) {
|
|
||||||
const number = Number(value || 0);
|
|
||||||
return Number.isFinite(number) ? number.toLocaleString() : "-";
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatAge(value) {
|
|
||||||
if (value === undefined || value === null || value === "") {
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
const age = Number(value);
|
|
||||||
return Number.isFinite(age) && age >= 0 ? `${Math.trunc(age)} 岁` : "-";
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatVipExpire(vip) {
|
|
||||||
if (Number(vip?.level || 0) <= 0) {
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
return formatExpireTime(vip?.expiresAtMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatAssetType(value) {
|
|
||||||
const normalized = String(value || "").toUpperCase();
|
|
||||||
const labels = {
|
|
||||||
COIN: "金币",
|
|
||||||
DIAMOND: "钻石",
|
|
||||||
};
|
|
||||||
return labels[normalized] || normalized || "-";
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatResourceType(value) {
|
|
||||||
const labels = {
|
|
||||||
avatar_frame: "头像框",
|
|
||||||
badge: "徽章",
|
|
||||||
chat_bubble: "聊天气泡",
|
|
||||||
emoji_pack: "表情包",
|
|
||||||
floating_screen: "飘屏",
|
|
||||||
gift: "礼物",
|
|
||||||
mic_seat_animation: "麦位动画",
|
|
||||||
mic_seat_icon: "麦位图标",
|
|
||||||
profile_card: "资料卡",
|
|
||||||
vehicle: "座驾",
|
|
||||||
};
|
|
||||||
return labels[value] || value || "-";
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatExpireTime(value) {
|
|
||||||
const expiresAtMs = Number(value || 0);
|
|
||||||
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= 0) {
|
|
||||||
return "永久";
|
|
||||||
}
|
|
||||||
return formatMillis(expiresAtMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
function firstNonEmpty(...values) {
|
function firstNonEmpty(...values) {
|
||||||
for (const value of values) {
|
for (const value of values) {
|
||||||
const normalized = String(value ?? "").trim();
|
const normalized = String(value ?? "").trim();
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { beforeEach, expect, test, vi } from "vitest";
|
import { beforeEach, expect, test, vi } from "vitest";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
|
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
|
||||||
import { getAppUser } from "@/features/app-users/api";
|
import { getAppUser } from "@/features/app-users/api";
|
||||||
import { useAppUserDetail } from "@/features/app-users/components/AppUserDetailContext.jsx";
|
import { useAppUserDetail } from "@/features/app-users/components/AppUserDetailContext.jsx";
|
||||||
@ -47,9 +48,11 @@ test("detail drawer keeps the identity hero and shows the expanded admin project
|
|||||||
|
|
||||||
render(
|
render(
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<AppUserDetailProvider>
|
<MemoryRouter initialEntries={["/app/users?status=active"]}>
|
||||||
<DetailTrigger />
|
<AppUserDetailProvider>
|
||||||
</AppUserDetailProvider>
|
<DetailTrigger />
|
||||||
|
</AppUserDetailProvider>
|
||||||
|
</MemoryRouter>
|
||||||
</ToastProvider>,
|
</ToastProvider>,
|
||||||
);
|
);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "打开详情" }));
|
fireEvent.click(screen.getByRole("button", { name: "打开详情" }));
|
||||||
@ -58,11 +61,20 @@ test("detail drawer keeps the identity hero and shows the expanded admin project
|
|||||||
expect(screen.getByText("注册设备")).toBeInTheDocument();
|
expect(screen.getByText("注册设备")).toBeInTheDocument();
|
||||||
expect(screen.getByText("iPhone17,2")).toBeInTheDocument();
|
expect(screen.getByText("iPhone17,2")).toBeInTheDocument();
|
||||||
expect(screen.getByText("主播、BD")).toBeInTheDocument();
|
expect(screen.getByText("主播、BD")).toBeInTheDocument();
|
||||||
expect(screen.getByText("累计充值")).toBeInTheDocument();
|
expect(screen.getByRole("link", { name: /完整详情/ })).toHaveAttribute("href", "/app/users/10001");
|
||||||
expect(screen.getByText(/123\.45/)).toBeInTheDocument();
|
expect(screen.getByRole("tab", { name: "总览" })).toHaveAttribute("aria-selected", "true");
|
||||||
expect(screen.getByText("永久")).toBeInTheDocument();
|
|
||||||
expect(screen.getAllByText(/真实 Lv8/).length).toBeGreaterThan(0);
|
expect(screen.getAllByText(/真实 Lv8/).length).toBeGreaterThan(0);
|
||||||
expect(screen.getByText("VIP 3 · 黄金会员")).toBeInTheDocument();
|
expect(screen.getByText("VIP 3 · 黄金会员")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: "资产" }));
|
||||||
|
expect(screen.getAllByText("累计充值").length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getAllByText(/123\.45/).length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: "状态记录" }));
|
||||||
|
expect(screen.getByText("永久")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("link", { name: /完整详情/ }));
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
function DetailTrigger() {
|
function DetailTrigger() {
|
||||||
|
|||||||
428
src/features/app-users/components/AppUserDetailView.jsx
Normal file
428
src/features/app-users/components/AppUserDetailView.jsx
Normal file
@ -0,0 +1,428 @@
|
|||||||
|
import ContentCopyOutlined from "@mui/icons-material/ContentCopyOutlined";
|
||||||
|
import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined";
|
||||||
|
import Tab from "@mui/material/Tab";
|
||||||
|
import Tabs from "@mui/material/Tabs";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link, useLocation } from "react-router-dom";
|
||||||
|
import { AdminPrettyDisplayID, AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
||||||
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
|
import { appUserStatusLabels } from "@/features/app-users/constants.js";
|
||||||
|
import {
|
||||||
|
BanCountdown,
|
||||||
|
GenderAgeTag,
|
||||||
|
GenderValue,
|
||||||
|
LevelValue,
|
||||||
|
OperatorValue,
|
||||||
|
RolesValue,
|
||||||
|
formatUsdMinor,
|
||||||
|
} from "@/features/app-users/components/AppUserValues.jsx";
|
||||||
|
import styles from "@/features/app-users/app-users.module.css";
|
||||||
|
|
||||||
|
const detailTabs = [
|
||||||
|
["overview", "总览"],
|
||||||
|
["assets", "资产"],
|
||||||
|
["resources", "资源"],
|
||||||
|
["status", "状态记录"],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AppUserDetailView({
|
||||||
|
fullDetailsHref = "",
|
||||||
|
loading = false,
|
||||||
|
mode = "drawer",
|
||||||
|
onOpenFullDetails,
|
||||||
|
user,
|
||||||
|
}) {
|
||||||
|
const location = useLocation();
|
||||||
|
const [activeTab, setActiveTab] = useState("overview");
|
||||||
|
const balances = user.balances || [];
|
||||||
|
const equippedResources = user.equippedResources || [];
|
||||||
|
const resources = user.resources || [];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 抽屉复用同一个组件切换用户时回到总览,避免新用户沿用上一位用户的深层 Tab 而遗漏核心信息。
|
||||||
|
setActiveTab("overview");
|
||||||
|
}, [user.userId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={[styles.detailView, mode === "page" ? styles.detailViewPage : ""].filter(Boolean).join(" ")}>
|
||||||
|
<div className={styles.detailStickyHeader}>
|
||||||
|
<div className={styles.detailHero}>
|
||||||
|
<AdminUserIdentity
|
||||||
|
namePrefix={<GenderAgeTag age={user.age} gender={user.gender} />}
|
||||||
|
size="large"
|
||||||
|
user={user}
|
||||||
|
/>
|
||||||
|
<div className={styles.detailHeroActions}>
|
||||||
|
<AppUserStatus status={user.status} />
|
||||||
|
{fullDetailsHref ? (
|
||||||
|
<Button
|
||||||
|
component={Link}
|
||||||
|
state={{ from: `${location.pathname}${location.search}${location.hash}` }}
|
||||||
|
to={fullDetailsHref}
|
||||||
|
type={undefined}
|
||||||
|
onClick={onOpenFullDetails}
|
||||||
|
>
|
||||||
|
<OpenInNewOutlined fontSize="small" />
|
||||||
|
完整详情
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.detailMetrics}>
|
||||||
|
<SummaryMetric label="财富" value={formatSummaryLevel(user.levels?.wealth)} />
|
||||||
|
<SummaryMetric label="魅力" value={formatSummaryLevel(user.levels?.charm)} />
|
||||||
|
<SummaryMetric label="游戏" value={formatSummaryLevel(user.levels?.game)} />
|
||||||
|
<SummaryMetric label="VIP" value={formatVipSummary(user.vip)} />
|
||||||
|
<SummaryMetric label="金币" value={formatNumber(user.coin)} />
|
||||||
|
<SummaryMetric label="钻石" value={formatNumber(user.diamond)} />
|
||||||
|
<SummaryMetric label="累计充值" value={formatCompactUsdMinor(user.cumulativeRechargeUsdMinor)} />
|
||||||
|
</div>
|
||||||
|
{loading ? <div className={styles.detailLoading}>正在刷新用户详情...</div> : null}
|
||||||
|
<Tabs
|
||||||
|
aria-label="用户详情分类"
|
||||||
|
className={styles.detailTabs}
|
||||||
|
value={activeTab}
|
||||||
|
variant="scrollable"
|
||||||
|
onChange={(_, value) => setActiveTab(value)}
|
||||||
|
>
|
||||||
|
{detailTabs.map(([value, label]) => (
|
||||||
|
<Tab
|
||||||
|
className={styles.detailTab}
|
||||||
|
key={value}
|
||||||
|
label={value === "resources" ? `${label} (${equippedResources.length + resources.length})` : label}
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.detailTabPanel} role="tabpanel">
|
||||||
|
{activeTab === "overview" ? <OverviewPanel user={user} /> : null}
|
||||||
|
{activeTab === "assets" ? <AssetsPanel balances={balances} user={user} /> : null}
|
||||||
|
{activeTab === "resources" ? (
|
||||||
|
<ResourcesPanel equippedResources={equippedResources} resources={resources} />
|
||||||
|
) : null}
|
||||||
|
{activeTab === "status" ? <StatusPanel user={user} /> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverviewPanel({ user }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DetailSection title="身份信息">
|
||||||
|
<div className={styles.detailGrid}>
|
||||||
|
<DetailItem label="用户 ID" value={<CopyableValue label="复制用户 ID" value={user.userId} />} />
|
||||||
|
<DetailItem
|
||||||
|
label="短 ID"
|
||||||
|
value={
|
||||||
|
<CopyableValue
|
||||||
|
label="复制短 ID"
|
||||||
|
value={user.defaultDisplayUserId || user.displayUserId || user.userId}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DetailItem label="靓号" value={<PrettyValue user={user} />} />
|
||||||
|
<DetailItem label="靓号记录 ID" value={user.prettyId} />
|
||||||
|
<DetailItem label="昵称" value={user.username} />
|
||||||
|
<DetailItem label="性别" value={<GenderValue gender={user.gender} />} />
|
||||||
|
<DetailItem label="生日" value={user.birth} />
|
||||||
|
<DetailItem label="年龄" value={formatAge(user.age)} />
|
||||||
|
<DetailItem label="注册设备" value={user.registerDevice} />
|
||||||
|
<DetailItem label="身份" value={<RolesValue roles={user.roles} />} />
|
||||||
|
</div>
|
||||||
|
</DetailSection>
|
||||||
|
<DetailSection title="等级与会员">
|
||||||
|
<div className={styles.detailGrid}>
|
||||||
|
<DetailItem label="财富等级" value={<LevelValue level={user.levels?.wealth} showReal />} />
|
||||||
|
<DetailItem label="魅力等级" value={<LevelValue level={user.levels?.charm} showReal />} />
|
||||||
|
<DetailItem label="游戏等级" value={<LevelValue level={user.levels?.game} showReal />} />
|
||||||
|
<DetailItem label="VIP 等级" value={<VipDetailValue vip={user.vip} />} />
|
||||||
|
<DetailItem label="VIP 到期" value={formatVipExpire(user.vip)} />
|
||||||
|
</div>
|
||||||
|
</DetailSection>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AssetsPanel({ balances, user }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DetailSection title="资产概览">
|
||||||
|
<div className={styles.detailGrid}>
|
||||||
|
<DetailItem label="金币" value={formatNumber(user.coin)} />
|
||||||
|
<DetailItem label="钻石" value={formatNumber(user.diamond)} />
|
||||||
|
<DetailItem label="累计充值" value={formatUsdMinor(user.cumulativeRechargeUsdMinor)} />
|
||||||
|
</div>
|
||||||
|
</DetailSection>
|
||||||
|
<DetailSection title={`资产余额 (${balances.length})`}>
|
||||||
|
<AssetBalanceList balances={balances} />
|
||||||
|
</DetailSection>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResourcesPanel({ equippedResources, resources }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DetailSection title={`当前佩戴 (${equippedResources.length})`}>
|
||||||
|
<ResourceList resources={equippedResources} />
|
||||||
|
</DetailSection>
|
||||||
|
<DetailSection title={`资源资产 (${resources.length})`}>
|
||||||
|
<ResourceList resources={resources} />
|
||||||
|
</DetailSection>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusPanel({ user }) {
|
||||||
|
return (
|
||||||
|
<DetailSection title="账号状态与记录">
|
||||||
|
<div className={styles.detailGrid}>
|
||||||
|
<DetailItem label="状态" value={appUserStatusLabels[user.status] || user.status || "-"} />
|
||||||
|
<DetailItem label="封禁剩余" value={<BanCountdown ban={user.ban} status={user.status} />} />
|
||||||
|
<DetailItem label="封禁原因" value={user.ban?.reason} wide />
|
||||||
|
<DetailItem label="国家" value={formatCountry(user)} />
|
||||||
|
<DetailItem label="区域" value={user.regionName || (user.regionId ? `区域 ${user.regionId}` : "-")} />
|
||||||
|
<DetailItem label="最新登录" value={formatMillis(user.lastLoginAtMs)} />
|
||||||
|
<DetailItem label="最近活跃" value={formatMillis(user.lastActiveAtMs)} />
|
||||||
|
<DetailItem
|
||||||
|
label="最新操作"
|
||||||
|
value={<OperatorValue operatedAtMs={user.lastOperatedAtMs} operator={user.lastOperator} />}
|
||||||
|
wide
|
||||||
|
/>
|
||||||
|
<DetailItem label="创建时间" value={formatMillis(user.createdAtMs)} />
|
||||||
|
<DetailItem label="更新时间" value={formatMillis(user.updatedAtMs)} />
|
||||||
|
</div>
|
||||||
|
</DetailSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailSection({ children, title }) {
|
||||||
|
return (
|
||||||
|
<section className={styles.detailSection}>
|
||||||
|
<h3 className={styles.detailSectionTitle}>{title}</h3>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailItem({ label, value, wide = false }) {
|
||||||
|
const isElement = value && typeof value === "object";
|
||||||
|
return (
|
||||||
|
<div className={[styles.detailItem, wide ? styles.detailItemWide : ""].filter(Boolean).join(" ")}>
|
||||||
|
<span className={styles.detailLabel}>{label}</span>
|
||||||
|
<span className={styles.detailValue}>
|
||||||
|
{isElement ? value : value === undefined || value === null || value === "" ? "-" : value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryMetric({ label, value }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.detailMetric}>
|
||||||
|
<span>{label}</span>
|
||||||
|
<strong>{value}</strong>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CopyableValue({ label, value }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const normalized = String(value ?? "").trim();
|
||||||
|
|
||||||
|
if (!normalized) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
const copy = async () => {
|
||||||
|
await copyText(normalized);
|
||||||
|
setCopied(true);
|
||||||
|
window.setTimeout(() => setCopied(false), 1200);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={styles.copyableDetailValue}>
|
||||||
|
<span>{normalized}</span>
|
||||||
|
<button aria-label={label} className={styles.copyDetailButton} title={copied ? "已复制" : label} type="button" onClick={copy}>
|
||||||
|
<ContentCopyOutlined fontSize="inherit" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PrettyValue({ user }) {
|
||||||
|
if (!user.prettyId || !user.prettyDisplayUserId) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return <AdminPrettyDisplayID prettyDisplayUserId={user.prettyDisplayUserId} prettyId={user.prettyId} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function VipDetailValue({ vip }) {
|
||||||
|
const level = Number(vip?.level || 0);
|
||||||
|
if (level <= 0 || !vip?.active) {
|
||||||
|
return "VIP 0";
|
||||||
|
}
|
||||||
|
return vip.name ? `VIP ${level} · ${vip.name}` : `VIP ${level}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AssetBalanceList({ balances }) {
|
||||||
|
if (!balances.length) {
|
||||||
|
return <div className={styles.emptyInline}>当前无数据</div>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={styles.assetList}>
|
||||||
|
{balances.map((balance) => (
|
||||||
|
<div className={styles.assetRow} key={balance.assetType}>
|
||||||
|
<div>
|
||||||
|
<div className={styles.assetName}>{formatAssetType(balance.assetType)}</div>
|
||||||
|
<div className={styles.assetMeta}>冻结 {formatNumber(balance.frozenAmount)}</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.assetAmount}>{formatNumber(balance.availableAmount)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResourceList({ resources }) {
|
||||||
|
if (!resources.length) {
|
||||||
|
return <div className={styles.emptyInline}>当前无数据</div>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ul className={styles.resourceList}>
|
||||||
|
{resources.map((resource) => (
|
||||||
|
<li className={styles.resourceItem} key={resource.entitlementId || resource.resourceId}>
|
||||||
|
<ResourceThumb resource={resource} />
|
||||||
|
<div className={styles.resourceBody}>
|
||||||
|
<div className={styles.resourceTitle}>
|
||||||
|
<span>{resource.name || resource.resourceCode || `资源 ${resource.resourceId}`}</span>
|
||||||
|
{resource.equipped ? <span className={styles.resourceTag}>佩戴中</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className={styles.resourceMeta}>
|
||||||
|
{formatResourceType(resource.resourceType)} · 剩余 {formatNumber(resource.remainingQuantity)} / {formatNumber(resource.quantity)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.resourceMeta}>到期 {formatExpireTime(resource.expiresAtMs)}</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResourceThumb({ resource }) {
|
||||||
|
const imageUrl = resource.previewUrl || resource.assetUrl || resource.animationUrl;
|
||||||
|
if (!imageUrl) {
|
||||||
|
return <span className={styles.resourceThumbFallback}>{resource.name?.slice(0, 1) || "资"}</span>;
|
||||||
|
}
|
||||||
|
return <img alt="" className={styles.resourceThumb} src={imageUrl} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppUserStatus({ status }) {
|
||||||
|
const tone = status === "active" ? "running" : "danger";
|
||||||
|
return (
|
||||||
|
<span className={`status-badge status-badge--${tone}`}>
|
||||||
|
<span className="status-point" />
|
||||||
|
{appUserStatusLabels[status] || status || "-"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSummaryLevel(level) {
|
||||||
|
const value = Number(level?.displayLevel ?? level?.realLevel);
|
||||||
|
return Number.isFinite(value) && value >= 0 ? `Lv${Math.trunc(value)}` : "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatVipSummary(vip) {
|
||||||
|
const level = Number(vip?.level || 0);
|
||||||
|
return level > 0 && vip?.active ? `VIP ${level}` : "VIP 0";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCompactUsdMinor(value) {
|
||||||
|
const amount = Number(value) / 100;
|
||||||
|
if (!Number.isFinite(amount)) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
currency: "USD",
|
||||||
|
maximumFractionDigits: Math.abs(amount) >= 1000 ? 1 : 2,
|
||||||
|
notation: Math.abs(amount) >= 1000 ? "compact" : "standard",
|
||||||
|
style: "currency",
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCountry(user) {
|
||||||
|
return user.countryDisplayName || user.countryName || user.country || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value) {
|
||||||
|
const number = Number(value || 0);
|
||||||
|
return Number.isFinite(number) ? number.toLocaleString() : "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAge(value) {
|
||||||
|
if (value === undefined || value === null || value === "") {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
const age = Number(value);
|
||||||
|
return Number.isFinite(age) && age >= 0 ? `${Math.trunc(age)} 岁` : "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatVipExpire(vip) {
|
||||||
|
if (Number(vip?.level || 0) <= 0) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return formatExpireTime(vip?.expiresAtMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAssetType(value) {
|
||||||
|
const normalized = String(value || "").toUpperCase();
|
||||||
|
const labels = { COIN: "金币", DIAMOND: "钻石" };
|
||||||
|
return labels[normalized] || normalized || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatResourceType(value) {
|
||||||
|
const labels = {
|
||||||
|
avatar_frame: "头像框",
|
||||||
|
badge: "徽章",
|
||||||
|
chat_bubble: "聊天气泡",
|
||||||
|
emoji_pack: "表情包",
|
||||||
|
floating_screen: "飘屏",
|
||||||
|
gift: "礼物",
|
||||||
|
mic_seat_animation: "麦位动画",
|
||||||
|
mic_seat_icon: "麦位图标",
|
||||||
|
profile_card: "资料卡",
|
||||||
|
vehicle: "座驾",
|
||||||
|
};
|
||||||
|
return labels[value] || value || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatExpireTime(value) {
|
||||||
|
const expiresAtMs = Number(value || 0);
|
||||||
|
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= 0) {
|
||||||
|
return "永久";
|
||||||
|
}
|
||||||
|
return formatMillis(expiresAtMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyText(value) {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// 后台可能运行在非安全上下文,Clipboard API 被拒绝时继续使用兼容复制路径。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const textarea = document.createElement("textarea");
|
||||||
|
textarea.value = value;
|
||||||
|
textarea.style.position = "fixed";
|
||||||
|
textarea.style.opacity = "0";
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
document.execCommand("copy");
|
||||||
|
textarea.remove();
|
||||||
|
}
|
||||||
@ -19,6 +19,44 @@ export function GenderValue({ gender }) {
|
|||||||
return formatGender(gender);
|
return formatGender(gender);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GenderAgeTag({ age, gender }) {
|
||||||
|
const normalizedGender = normalizeGender(gender);
|
||||||
|
const normalizedAge = Number(age);
|
||||||
|
const ageLabel = Number.isFinite(normalizedAge) && normalizedAge >= 0 ? normalizedAge : "-";
|
||||||
|
|
||||||
|
// 未设置或无法识别的性别不伪造颜色语义,仍保留年龄,避免列表信息因字段迁移缺失而消失。
|
||||||
|
if (!normalizedGender) {
|
||||||
|
return <span className={styles.genderAgeUnknown}>年龄 {ageLabel}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-label={`${normalizedGender === "female" ? "女" : "男"},${ageLabel} 岁`}
|
||||||
|
className={normalizedGender === "female" ? styles.genderAgeFemale : styles.genderAgeMale}
|
||||||
|
>
|
||||||
|
{normalizedGender === "female" ? "♀" : "♂"} {ageLabel}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserLevelsValue({ levels }) {
|
||||||
|
const entries = [
|
||||||
|
["财富", levels?.wealth],
|
||||||
|
["魅力", levels?.charm],
|
||||||
|
["游戏", levels?.game],
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div className={styles.userLevels}>
|
||||||
|
{entries.map(([label, level]) => (
|
||||||
|
<span className={styles.userLevel} key={label}>
|
||||||
|
<span>{label}</span>
|
||||||
|
<strong>{level ? `Lv${numericLevel(level.displayLevel ?? level.realLevel)}` : "-"}</strong>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function LevelValue({ level, showReal = false }) {
|
export function LevelValue({ level, showReal = false }) {
|
||||||
if (!level) {
|
if (!level) {
|
||||||
return "-";
|
return "-";
|
||||||
@ -112,6 +150,17 @@ export function formatGender(gender) {
|
|||||||
return gender || "-";
|
return gender || "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeGender(gender) {
|
||||||
|
const value = String(gender || "").toLowerCase();
|
||||||
|
if (value === "female" || value === "f" || value === "女") {
|
||||||
|
return "female";
|
||||||
|
}
|
||||||
|
if (value === "male" || value === "m" || value === "男") {
|
||||||
|
return "male";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
export function formatUsdMinor(value) {
|
export function formatUsdMinor(value) {
|
||||||
const amount = Number(value);
|
const amount = Number(value);
|
||||||
if (!Number.isFinite(amount)) {
|
if (!Number.isFinite(amount)) {
|
||||||
|
|||||||
61
src/features/app-users/pages/AppUserDetailPage.jsx
Normal file
61
src/features/app-users/pages/AppUserDetailPage.jsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import ArrowBackOutlined from "@mui/icons-material/ArrowBackOutlined";
|
||||||
|
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
|
import { PageSkeleton } from "@/shared/ui/PageSkeleton.jsx";
|
||||||
|
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
||||||
|
import { getAppUser } from "@/features/app-users/api";
|
||||||
|
import { AppUserDetailView } from "@/features/app-users/components/AppUserDetailView.jsx";
|
||||||
|
import styles from "@/features/app-users/app-users.module.css";
|
||||||
|
|
||||||
|
export function AppUserDetailPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { userId = "" } = useParams();
|
||||||
|
const normalizedUserId = String(userId).trim();
|
||||||
|
const loadUser = useCallback(() => getAppUser(normalizedUserId), [normalizedUserId]);
|
||||||
|
const { data, error, loading, reload } = useAdminQuery(loadUser, {
|
||||||
|
enabled: Boolean(normalizedUserId),
|
||||||
|
errorMessage: "加载用户详情失败",
|
||||||
|
initialData: null,
|
||||||
|
queryKey: ["app-users", "detail", normalizedUserId],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loading && !data) {
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageError = normalizedUserId ? error : "缺少用户 ID";
|
||||||
|
const returnPath = resolveReturnPath(location.state?.from);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.detailPageRoot}>
|
||||||
|
<div className={styles.detailPageToolbar}>
|
||||||
|
<div className={styles.detailPageTitleGroup}>
|
||||||
|
<Button onClick={() => navigate(returnPath)}>
|
||||||
|
<ArrowBackOutlined fontSize="small" />
|
||||||
|
返回用户列表
|
||||||
|
</Button>
|
||||||
|
<h1>用户详情</h1>
|
||||||
|
</div>
|
||||||
|
<Button disabled={loading || !normalizedUserId} onClick={reload}>
|
||||||
|
<RefreshOutlined fontSize="small" />
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.detailPageContent}>
|
||||||
|
<DataState error={pageError} loading={false} onRetry={normalizedUserId ? reload : undefined}>
|
||||||
|
{data ? <AppUserDetailView mode="page" user={data} /> : null}
|
||||||
|
</DataState>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveReturnPath(value) {
|
||||||
|
const path = String(value || "");
|
||||||
|
// 只恢复站内绝对路径,避免路由 state 被外部输入污染后形成开放跳转。
|
||||||
|
return path.startsWith("/") && !path.startsWith("//") ? path : "/app/users";
|
||||||
|
}
|
||||||
56
src/features/app-users/pages/AppUserDetailPage.test.jsx
Normal file
56
src/features/app-users/pages/AppUserDetailPage.test.jsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { expect, test, vi } from "vitest";
|
||||||
|
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||||
|
import { QueryProvider } from "@/shared/query/QueryProvider.jsx";
|
||||||
|
import { getAppUser } from "@/features/app-users/api";
|
||||||
|
import { AppUserDetailPage } from "@/features/app-users/pages/AppUserDetailPage.jsx";
|
||||||
|
|
||||||
|
vi.mock("@/features/app-users/api", () => ({ getAppUser: vi.fn() }));
|
||||||
|
|
||||||
|
test("full user detail route loads the URL user and returns to its source list", async () => {
|
||||||
|
vi.mocked(getAppUser).mockResolvedValue({
|
||||||
|
age: 24,
|
||||||
|
defaultDisplayUserId: "163337",
|
||||||
|
gender: "female",
|
||||||
|
levels: {
|
||||||
|
charm: { displayLevel: 3, realLevel: 3 },
|
||||||
|
game: { displayLevel: 6, realLevel: 6 },
|
||||||
|
wealth: { displayLevel: 4, realLevel: 4 },
|
||||||
|
},
|
||||||
|
status: "active",
|
||||||
|
userId: "10001",
|
||||||
|
username: "tester",
|
||||||
|
vip: { active: false, level: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<QueryProvider>
|
||||||
|
<MemoryRouter
|
||||||
|
initialEntries={[
|
||||||
|
{
|
||||||
|
pathname: "/app/users/10001",
|
||||||
|
state: { from: "/app/users?status=active" },
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/app/users/:userId" element={<AppUserDetailPage />} />
|
||||||
|
<Route path="/app/users" element={<LocationValue />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect((await screen.findAllByText("tester")).length).toBeGreaterThan(0);
|
||||||
|
expect(getAppUser).toHaveBeenCalledWith("10001");
|
||||||
|
expect(screen.getByRole("tab", { name: "总览" })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("link", { name: /完整详情/ })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /返回用户列表/ }));
|
||||||
|
expect(await screen.findByTestId("location-value")).toHaveTextContent("/app/users?status=active");
|
||||||
|
});
|
||||||
|
|
||||||
|
function LocationValue() {
|
||||||
|
const location = useLocation();
|
||||||
|
return <span data-testid="location-value">{`${location.pathname}${location.search}`}</span>;
|
||||||
|
}
|
||||||
@ -27,10 +27,10 @@ import { formatMillis } from "@/shared/utils/time.js";
|
|||||||
import { appUserStatusFilters, appUserStatusLabels, genderOptions } from "@/features/app-users/constants.js";
|
import { appUserStatusFilters, appUserStatusLabels, genderOptions } from "@/features/app-users/constants.js";
|
||||||
import {
|
import {
|
||||||
BanCountdown,
|
BanCountdown,
|
||||||
GenderValue,
|
|
||||||
LevelValue,
|
|
||||||
OperatorValue,
|
OperatorValue,
|
||||||
RolesValue,
|
RolesValue,
|
||||||
|
GenderAgeTag,
|
||||||
|
UserLevelsValue,
|
||||||
} from "@/features/app-users/components/AppUserValues.jsx";
|
} from "@/features/app-users/components/AppUserValues.jsx";
|
||||||
import { useAppUsersPage } from "@/features/app-users/hooks/useAppUsersPage.js";
|
import { useAppUsersPage } from "@/features/app-users/hooks/useAppUsersPage.js";
|
||||||
import { CoinLedgerTable } from "@/features/operations/components/CoinLedgerTable.jsx";
|
import { CoinLedgerTable } from "@/features/operations/components/CoinLedgerTable.jsx";
|
||||||
@ -92,42 +92,24 @@ export function AppUserListPage() {
|
|||||||
{
|
{
|
||||||
key: "identity",
|
key: "identity",
|
||||||
label: "用户",
|
label: "用户",
|
||||||
width: "minmax(240px, 1.5fr)",
|
width: "minmax(260px, 1.6fr)",
|
||||||
filter: createUserIdentityColumnFilter({
|
filter: createUserIdentityColumnFilter({
|
||||||
value: page.userFilter,
|
value: page.userFilter,
|
||||||
onChange: page.changeUserFilter,
|
onChange: page.changeUserFilter,
|
||||||
}),
|
}),
|
||||||
render: (user) => <AdminUserIdentity openInAppUserDetail user={user} />,
|
render: (user) => (
|
||||||
|
<AdminUserIdentity
|
||||||
|
namePrefix={<GenderAgeTag age={user.age} gender={user.gender} />}
|
||||||
|
openInAppUserDetail
|
||||||
|
user={user}
|
||||||
|
/>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "gender",
|
key: "levels",
|
||||||
label: "性别",
|
label: "财富/魅力/游戏等级",
|
||||||
width: "minmax(72px, 0.4fr)",
|
width: "minmax(230px, 1.15fr)",
|
||||||
render: (user) => <GenderValue gender={user.gender} />,
|
render: (user) => <UserLevelsValue levels={user.levels} />,
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "age",
|
|
||||||
label: "年龄",
|
|
||||||
width: "minmax(72px, 0.4fr)",
|
|
||||||
render: (user) => (user.age === undefined || user.age === null ? "-" : `${user.age} 岁`),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "wealthLevel",
|
|
||||||
label: "富豪等级",
|
|
||||||
width: "minmax(118px, 0.7fr)",
|
|
||||||
render: (user) => <LevelValue level={user.levels?.wealth} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "charmLevel",
|
|
||||||
label: "魅力等级",
|
|
||||||
width: "minmax(118px, 0.7fr)",
|
|
||||||
render: (user) => <LevelValue level={user.levels?.charm} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "gameLevel",
|
|
||||||
label: "游戏等级",
|
|
||||||
width: "minmax(96px, 0.55fr)",
|
|
||||||
render: (user) => <LevelValue level={user.levels?.game} />,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "roles",
|
key: "roles",
|
||||||
|
|||||||
@ -36,7 +36,9 @@ test("clicking an app user's coin opens the coin ledger drawer", () => {
|
|||||||
expect(screen.getByText("VIP 2")).toBeInTheDocument();
|
expect(screen.getByText("VIP 2")).toBeInTheDocument();
|
||||||
expect(screen.getByText("VIP888")).toBeInTheDocument();
|
expect(screen.getByText("VIP888")).toBeInTheDocument();
|
||||||
expect(screen.getByText("公会长、BD")).toBeInTheDocument();
|
expect(screen.getByText("公会长、BD")).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: "调整富豪等级列宽" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "调整财富/魅力/游戏等级列宽" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("女,24 岁")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("财富")).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: "调整最新成功登录列宽" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "调整最新成功登录列宽" })).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "查看 d 的金币流水" }));
|
fireEvent.click(screen.getByRole("button", { name: "查看 d 的金币流水" }));
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,14 @@ export const appUserRoutes = [
|
|||||||
path: "/app/users",
|
path: "/app/users",
|
||||||
permission: PERMISSIONS.appUserView,
|
permission: PERMISSIONS.appUserView,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "用户详情",
|
||||||
|
loader: () => import("./pages/AppUserDetailPage.jsx").then((module) => module.AppUserDetailPage),
|
||||||
|
menuCode: MENU_CODES.appUserDetail,
|
||||||
|
pageKey: "app-user-detail",
|
||||||
|
path: "/app/users/:userId",
|
||||||
|
permission: PERMISSIONS.appUserView,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "登录日志",
|
label: "登录日志",
|
||||||
loader: () => import("./pages/AppUserLoginLogsPage.jsx").then((module) => module.AppUserLoginLogsPage),
|
loader: () => import("./pages/AppUserLoginLogsPage.jsx").then((module) => module.AppUserLoginLogsPage),
|
||||||
|
|||||||
44
src/features/dashboard/api.test.ts
Normal file
44
src/features/dashboard/api.test.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
|
import { setSelectedAppCode } from "@/shared/api/request";
|
||||||
|
import { getDashboardUserProfileOverview } from "./api";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setSelectedAppCode("lalu");
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("requests the app-scoped user profile overview with the current day range and time zone", async () => {
|
||||||
|
setSelectedAppCode("huwaa");
|
||||||
|
const payload = {
|
||||||
|
activeUsers: 48,
|
||||||
|
appCode: "huwaa",
|
||||||
|
appVersionDistribution: [{ count: 30, key: "2.1.0", label: "2.1.0" }],
|
||||||
|
genderDistribution: [{ count: 32, key: "female", label: "女" }],
|
||||||
|
newUsers: 7,
|
||||||
|
totalUsers: 120,
|
||||||
|
updatedAtMs: 1783728000000
|
||||||
|
};
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () => new Response(JSON.stringify({ code: 0, data: payload })))
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await getDashboardUserProfileOverview({
|
||||||
|
appCode: "huwaa",
|
||||||
|
endMs: 1783728000000,
|
||||||
|
startMs: 1783699200000,
|
||||||
|
statTz: "Asia/Shanghai"
|
||||||
|
});
|
||||||
|
const [input, init] = vi.mocked(fetch).mock.calls[0];
|
||||||
|
const url = new URL(String(input));
|
||||||
|
|
||||||
|
expect(url.pathname).toBe("/api/v1/dashboard/user-profile-overview");
|
||||||
|
expect(Object.fromEntries(url.searchParams)).toEqual({
|
||||||
|
app_code: "huwaa",
|
||||||
|
end_ms: "1783728000000",
|
||||||
|
start_ms: "1783699200000",
|
||||||
|
stat_tz: "Asia/Shanghai"
|
||||||
|
});
|
||||||
|
expect(init?.headers).toMatchObject({ "X-App-Code": "huwaa" });
|
||||||
|
expect(result).toEqual(payload);
|
||||||
|
});
|
||||||
@ -1,7 +1,25 @@
|
|||||||
import { apiRequest } from "@/shared/api/request";
|
import { apiRequest } from "@/shared/api/request";
|
||||||
import { API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
import { API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||||
import type { DashboardOverviewDto } from "@/shared/api/types";
|
import type {
|
||||||
|
DashboardOverviewDto,
|
||||||
|
DashboardUserProfileOverviewDto,
|
||||||
|
DashboardUserProfileOverviewQuery
|
||||||
|
} from "@/shared/api/types";
|
||||||
|
|
||||||
export function getDashboardOverview(): Promise<DashboardOverviewDto> {
|
export function getDashboardOverview(): Promise<DashboardOverviewDto> {
|
||||||
return apiRequest<DashboardOverviewDto>(apiEndpointPath(API_OPERATIONS.dashboardOverview));
|
return apiRequest<DashboardOverviewDto>(apiEndpointPath(API_OPERATIONS.dashboardOverview));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getDashboardUserProfileOverview(
|
||||||
|
query: DashboardUserProfileOverviewQuery
|
||||||
|
): Promise<DashboardUserProfileOverviewDto> {
|
||||||
|
// app_code 明确参与统计聚合;请求层仍会附带 X-App-Code,供后端执行应用数据权限校验。
|
||||||
|
return apiRequest<DashboardUserProfileOverviewDto>(apiEndpointPath(API_OPERATIONS.dashboardUserProfileOverview), {
|
||||||
|
query: {
|
||||||
|
app_code: query.appCode,
|
||||||
|
end_ms: query.endMs,
|
||||||
|
start_ms: query.startMs,
|
||||||
|
stat_tz: query.statTz
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -1,10 +1,40 @@
|
|||||||
import { ChartCard } from "@/shared/charts/ChartCard.jsx";
|
import { Card } from "@/shared/ui/Card.jsx";
|
||||||
|
import { DistributionPieChart } from "@/shared/charts/DistributionPieChart.jsx";
|
||||||
|
|
||||||
|
const GENDER_COLOR_TOKENS = {
|
||||||
|
female: "--chart-red",
|
||||||
|
male: "--chart-blue",
|
||||||
|
unknown: "--stopped"
|
||||||
|
};
|
||||||
|
|
||||||
export function DashboardCharts({ overview }) {
|
export function DashboardCharts({ overview }) {
|
||||||
|
const genderDistribution = withGenderColors(overview?.genderDistribution);
|
||||||
|
const appVersionDistribution = overview?.appVersionDistribution || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="overview-charts" aria-label="资源图表">
|
<section className="overview-charts" aria-label="用户画像分布">
|
||||||
<ChartCard title="后台用户" colorToken="--chart-blue" series={overview?.series?.users || []} />
|
<Card className="chart-card dashboard-distribution-card" component="article">
|
||||||
<ChartCard title="操作日志" colorToken="--chart-green" series={overview?.series?.operations || []} />
|
<div className="card-head dashboard-distribution-card__head">
|
||||||
|
<h3 className="card-title">性别人数分布</h3>
|
||||||
|
<span>自然用户 · 已完成资料 · 排除机器人/快捷账号</span>
|
||||||
|
</div>
|
||||||
|
<DistributionPieChart ariaLabel="自然用户性别人数分布饼图" data={genderDistribution} />
|
||||||
|
</Card>
|
||||||
|
<Card className="chart-card dashboard-distribution-card" component="article">
|
||||||
|
<div className="card-head dashboard-distribution-card__head">
|
||||||
|
<h3 className="card-title">App 版本号人数分布</h3>
|
||||||
|
<span>自然用户 · 最近一次成功登录版本</span>
|
||||||
|
</div>
|
||||||
|
<DistributionPieChart ariaLabel="自然用户最近一次成功登录 App 版本号人数分布饼图" data={appVersionDistribution} />
|
||||||
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withGenderColors(distribution) {
|
||||||
|
return (Array.isArray(distribution) ? distribution : []).map((item) => ({
|
||||||
|
...item,
|
||||||
|
// 性别颜色按稳定 key 固定,避免后端排序变化导致同一性别在刷新后换色。
|
||||||
|
colorToken: GENDER_COLOR_TOKENS[String(item?.key || "").toLowerCase()]
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|||||||
@ -1,14 +1,41 @@
|
|||||||
import DeveloperBoardOutlined from "@mui/icons-material/DeveloperBoardOutlined";
|
import OnlinePredictionOutlined from "@mui/icons-material/OnlinePredictionOutlined";
|
||||||
import DnsOutlined from "@mui/icons-material/DnsOutlined";
|
import PeopleAltOutlined from "@mui/icons-material/PeopleAltOutlined";
|
||||||
import StorageOutlined from "@mui/icons-material/StorageOutlined";
|
import PersonAddAltOutlined from "@mui/icons-material/PersonAddAltOutlined";
|
||||||
import { KpiCard } from "@/shared/ui/KpiCard.jsx";
|
import { KpiCard } from "@/shared/ui/KpiCard.jsx";
|
||||||
|
|
||||||
export function DashboardStats({ overview, stats }) {
|
const countFormatter = new Intl.NumberFormat("zh-CN");
|
||||||
|
|
||||||
|
export function DashboardStats({ overview }) {
|
||||||
return (
|
return (
|
||||||
<section className="kpi-grid" aria-label="系统指标">
|
<section className="kpi-grid dashboard-profile-kpis" aria-label="用户核心指标">
|
||||||
<KpiCard icon={DnsOutlined} label="后台用户" value={overview?.usersTotal || 0} unit="个" sub={<span className="status-ok">启用 {stats.activeUsers}</span>} />
|
<KpiCard
|
||||||
<KpiCard icon={DeveloperBoardOutlined} label="角色数量" value={stats.rolesTotal} unit="个" sub={`菜单 ${stats.menuTotal}`} />
|
icon={PeopleAltOutlined}
|
||||||
<KpiCard icon={StorageOutlined} label="今日操作" value={stats.logsToday} unit="条" tone="info" sub="操作日志" />
|
label="总人数"
|
||||||
|
value={formatCount(overview?.totalUsers)}
|
||||||
|
unit="人"
|
||||||
|
sub="已完成资料,排除机器人/快捷账号"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={PersonAddAltOutlined}
|
||||||
|
label="当天新增"
|
||||||
|
value={formatCount(overview?.newUsers)}
|
||||||
|
unit="人"
|
||||||
|
tone="success"
|
||||||
|
sub="按当前显示时区统计"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={OnlinePredictionOutlined}
|
||||||
|
label="当前日活"
|
||||||
|
value={formatCount(overview?.activeUsers)}
|
||||||
|
unit="人"
|
||||||
|
tone="info"
|
||||||
|
sub="当天去重活跃用户"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatCount(value) {
|
||||||
|
const count = Number(value);
|
||||||
|
return countFormatter.format(Number.isFinite(count) && count > 0 ? count : 0);
|
||||||
|
}
|
||||||
|
|||||||
73
src/features/dashboard/components/DashboardUserProfile.jsx
Normal file
73
src/features/dashboard/components/DashboardUserProfile.jsx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import Refresh from "@mui/icons-material/Refresh";
|
||||||
|
import Skeleton from "@mui/material/Skeleton";
|
||||||
|
import { useTimeZone } from "@/app/timezone/TimeZoneProvider.jsx";
|
||||||
|
import { DashboardCharts } from "@/features/dashboard/components/DashboardCharts.jsx";
|
||||||
|
import { DashboardStats } from "@/features/dashboard/components/DashboardStats.jsx";
|
||||||
|
import { useDashboardPage } from "@/features/dashboard/hooks/useDashboardPage.js";
|
||||||
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
|
import { usePageLoadingStatus } from "@/shared/ui/PageLoadBoundary.jsx";
|
||||||
|
|
||||||
|
export function DashboardUserProfile() {
|
||||||
|
const page = useDashboardPage();
|
||||||
|
const { formatMillis } = useTimeZone();
|
||||||
|
// 首次请求接入整页加载边界;边界释放后的 App/时区切换则展示本区域自己的骨架,避免页面空白。
|
||||||
|
usePageLoadingStatus(page.loading);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="dashboard-profile-section" aria-labelledby="dashboard-profile-title">
|
||||||
|
<div className="dashboard-profile-head">
|
||||||
|
<h2 id="dashboard-profile-title">用户画像</h2>
|
||||||
|
{page.overview?.updatedAtMs ? <span>数据更新时间 {formatMillis(page.overview.updatedAtMs)}</span> : null}
|
||||||
|
</div>
|
||||||
|
{renderContent(page)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderContent(page) {
|
||||||
|
if (page.loading) {
|
||||||
|
return <DashboardUserProfileSkeleton />;
|
||||||
|
}
|
||||||
|
if (page.error) {
|
||||||
|
return (
|
||||||
|
<div className="dashboard-profile-state" role="alert">
|
||||||
|
<span>{page.error}</span>
|
||||||
|
<Button onClick={page.reload}>
|
||||||
|
<Refresh fontSize="small" />
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!page.overview) {
|
||||||
|
return (
|
||||||
|
<div className="dashboard-profile-state" role="status">
|
||||||
|
当前无数据
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DashboardStats overview={page.overview} />
|
||||||
|
<DashboardCharts overview={page.overview} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardUserProfileSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="dashboard-profile-skeleton" aria-hidden="true">
|
||||||
|
<div className="dashboard-profile-skeleton__kpis">
|
||||||
|
{Array.from({ length: 3 }, (_, index) => (
|
||||||
|
<Skeleton animation="wave" className="dashboard-profile-skeleton__kpi" key={index} variant="rounded" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="dashboard-profile-skeleton__charts">
|
||||||
|
{Array.from({ length: 2 }, (_, index) => (
|
||||||
|
<Skeleton animation="wave" className="dashboard-profile-skeleton__chart" key={index} variant="rounded" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,94 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { beforeEach, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
page: null,
|
||||||
|
reload: vi.fn(),
|
||||||
|
reportLoading: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/app/timezone/TimeZoneProvider.jsx", () => ({
|
||||||
|
useTimeZone: () => ({ formatMillis: () => "2026-07-11 10:30:00" })
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/dashboard/hooks/useDashboardPage.js", () => ({
|
||||||
|
useDashboardPage: () => mocks.page
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/shared/ui/PageLoadBoundary.jsx", () => ({
|
||||||
|
usePageLoadingStatus: mocks.reportLoading
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/shared/charts/DistributionPieChart.jsx", () => ({
|
||||||
|
DistributionPieChart: ({ ariaLabel, data }) => (
|
||||||
|
<div aria-label={ariaLabel}>{data.map((item) => `${item.label}:${item.count}`).join("|")}</div>
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { DashboardUserProfile } from "./DashboardUserProfile.jsx";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.reload.mockReset();
|
||||||
|
mocks.reportLoading.mockReset();
|
||||||
|
mocks.page = {
|
||||||
|
error: "",
|
||||||
|
loading: false,
|
||||||
|
overview: {
|
||||||
|
activeUsers: 456,
|
||||||
|
appCode: "lalu",
|
||||||
|
appVersionDistribution: [
|
||||||
|
{ count: 300, key: "2.1.0", label: "2.1.0" },
|
||||||
|
{ count: 156, key: "2.0.9", label: "2.0.9" }
|
||||||
|
],
|
||||||
|
genderDistribution: [
|
||||||
|
{ count: 700, key: "female", label: "女" },
|
||||||
|
{ count: 534, key: "male", label: "男" }
|
||||||
|
],
|
||||||
|
newUsers: 32,
|
||||||
|
totalUsers: 1234,
|
||||||
|
updatedAtMs: 1783737000000
|
||||||
|
},
|
||||||
|
reload: mocks.reload
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders the three user KPIs and both distribution charts with explicit metric scope", () => {
|
||||||
|
render(<DashboardUserProfile />);
|
||||||
|
|
||||||
|
expect(screen.getByText("总人数")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("1,234")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("当天新增")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("32")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("当前日活")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("456")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("性别人数分布")).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("自然用户性别人数分布饼图")).toHaveTextContent("女:700|男:534");
|
||||||
|
expect(screen.getByText("App 版本号人数分布")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("自然用户 · 最近一次成功登录版本")).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("自然用户最近一次成功登录 App 版本号人数分布饼图")).toHaveTextContent("2.1.0:300|2.0.9:156");
|
||||||
|
expect(screen.getByText("数据更新时间 2026-07-11 10:30:00")).toBeInTheDocument();
|
||||||
|
expect(mocks.reportLoading).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows a retryable error without removing the user profile section", () => {
|
||||||
|
mocks.page = { error: "画像接口暂不可用", loading: false, overview: null, reload: mocks.reload };
|
||||||
|
|
||||||
|
render(<DashboardUserProfile />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "重试" }));
|
||||||
|
|
||||||
|
expect(screen.getByRole("alert")).toHaveTextContent("画像接口暂不可用");
|
||||||
|
expect(mocks.reload).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders animated placeholders while loading and a stable empty state for an empty response", () => {
|
||||||
|
mocks.page = { error: "", loading: true, overview: null, reload: mocks.reload };
|
||||||
|
const { container, rerender } = render(<DashboardUserProfile />);
|
||||||
|
|
||||||
|
expect(container.querySelectorAll(".dashboard-profile-skeleton__kpi")).toHaveLength(3);
|
||||||
|
expect(container.querySelectorAll(".dashboard-profile-skeleton__chart")).toHaveLength(2);
|
||||||
|
expect(mocks.reportLoading).toHaveBeenLastCalledWith(true);
|
||||||
|
|
||||||
|
mocks.page = { error: "", loading: false, overview: null, reload: mocks.reload };
|
||||||
|
rerender(<DashboardUserProfile />);
|
||||||
|
expect(screen.getByRole("status")).toHaveTextContent("当前无数据");
|
||||||
|
});
|
||||||
@ -1,10 +1,156 @@
|
|||||||
.overview-charts {
|
.overview-charts {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(260px, 1fr));
|
grid-template-columns: repeat(2, minmax(280px, 1fr));
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
margin-top: var(--space-4);
|
margin-top: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-profile-section {
|
||||||
|
margin-top: var(--space-8);
|
||||||
|
padding-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-profile-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-4);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-profile-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: calc(var(--admin-font-size) + 2px);
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-profile-head > span,
|
||||||
|
.dashboard-distribution-card__head > span {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: var(--admin-font-size);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-profile-kpis {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-profile-kpis .kpi-value {
|
||||||
|
font-size: calc(var(--admin-font-size) * 1.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-profile-state {
|
||||||
|
display: flex;
|
||||||
|
min-height: 220px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-profile-skeleton {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-profile-skeleton__kpis,
|
||||||
|
.dashboard-profile-skeleton__charts {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-profile-skeleton__kpis {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-profile-skeleton__charts {
|
||||||
|
grid-template-columns: repeat(2, minmax(280px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-profile-skeleton__kpi {
|
||||||
|
height: 132px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-profile-skeleton__chart {
|
||||||
|
height: 304px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-distribution-card__head {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-distribution-card__head .card-title {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 180px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-distribution-card__head > span {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-pie {
|
||||||
|
display: grid;
|
||||||
|
min-height: 216px;
|
||||||
|
grid-template-columns: minmax(160px, 0.82fr) minmax(180px, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-pie__chart {
|
||||||
|
width: 100%;
|
||||||
|
height: 216px;
|
||||||
|
opacity: 0;
|
||||||
|
animation: panel-enter 420ms var(--ease-emphasized) 120ms both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-pie__legend {
|
||||||
|
display: grid;
|
||||||
|
max-height: 216px;
|
||||||
|
overflow-y: auto;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding-right: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-pie__legend-row {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
grid-template-columns: 8px minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--admin-font-size);
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-pie__legend-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-pie__legend-label {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-pie__legend-row strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-pie-empty {
|
||||||
|
display: grid;
|
||||||
|
min-height: 216px;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
.workspace-entry-section {
|
.workspace-entry-section {
|
||||||
margin-top: var(--space-4);
|
margin-top: var(--space-4);
|
||||||
}
|
}
|
||||||
@ -450,3 +596,36 @@
|
|||||||
margin-top: var(--space-4);
|
margin-top: var(--space-4);
|
||||||
padding-bottom: var(--space-2);
|
padding-bottom: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1080px) {
|
||||||
|
.dashboard-profile-skeleton__kpis {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1120px) {
|
||||||
|
.distribution-pie {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-pie__chart {
|
||||||
|
height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-pie__legend {
|
||||||
|
max-height: 120px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.dashboard-profile-head {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-profile-skeleton__kpis,
|
||||||
|
.dashboard-profile-skeleton__charts {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,48 +1,48 @@
|
|||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback } from "react";
|
||||||
|
import { useAppScope } from "@/app/app-scope/AppScopeProvider.jsx";
|
||||||
|
import { useTimeZone } from "@/app/timezone/TimeZoneProvider.jsx";
|
||||||
|
import { getDashboardUserProfileOverview } from "@/features/dashboard/api";
|
||||||
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
||||||
import { getDashboardOverview } from "@/features/dashboard/api";
|
import { nextDayStartMillis, startOfDayMillis } from "@/shared/utils/time.js";
|
||||||
|
|
||||||
const emptyData = { overview: null };
|
const DAY_ROLLOVER_DELAY_MS = 1000;
|
||||||
|
|
||||||
export function useDashboardPage() {
|
export function useDashboardPage() {
|
||||||
const [range, setRange] = useState("近 1 小时");
|
const { appCode } = useAppScope();
|
||||||
|
const { timeZone } = useTimeZone();
|
||||||
|
|
||||||
const queryFn = useCallback(async () => {
|
const queryFn = useCallback(() => {
|
||||||
const overview = await getDashboardOverview();
|
// 每次实际请求时重新取 now,确保跨过零点后的手动刷新不会继续沿用前一天的统计区间。
|
||||||
return { overview };
|
const endMs = Date.now();
|
||||||
}, []);
|
const startMs = startOfDayMillis(endMs, timeZone);
|
||||||
|
if (startMs === null) {
|
||||||
|
// 无法计算边界时直接失败,避免用错误日期请求并把跨天数据伪装成“当天”。
|
||||||
|
throw new Error("无法计算当天统计时间范围");
|
||||||
|
}
|
||||||
|
return getDashboardUserProfileOverview({ appCode, endMs, startMs, statTz: timeZone });
|
||||||
|
}, [appCode, timeZone]);
|
||||||
|
|
||||||
const { data, error, loading, reload } = useAdminQuery(queryFn, {
|
const { data, error, loading, reload } = useAdminQuery(queryFn, {
|
||||||
errorMessage: "加载总览失败",
|
errorMessage: "加载用户画像失败",
|
||||||
initialData: emptyData,
|
initialData: null,
|
||||||
keepPreviousData: true,
|
// App 与时区都会改变统计口径,必须隔离缓存,避免切换时短暂展示上一口径的数据。
|
||||||
queryKey: ["dashboard", "overview"]
|
queryKey: ["dashboard", "user-profile-overview", appCode, timeZone],
|
||||||
|
// 页面可能整夜保持打开;在所选显示时区跨过零点后主动重取,确保“当天新增/当前日活”不会停留在前一天。
|
||||||
|
refetchInterval: () => dashboardDayRolloverInterval(timeZone)
|
||||||
});
|
});
|
||||||
|
|
||||||
const overview = data?.overview;
|
|
||||||
|
|
||||||
const stats = useMemo(() => {
|
|
||||||
if (!overview) {
|
|
||||||
return { activeUsers: 0, disabledUsers: 0, lockedUsers: 0, logsToday: 0, menuTotal: 0, rolesTotal: 0, usersTotal: 0 };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
activeUsers: overview.activeUsers,
|
|
||||||
disabledUsers: overview.disabledUsers,
|
|
||||||
lockedUsers: overview.lockedUsers,
|
|
||||||
logsToday: overview.logsToday,
|
|
||||||
menuTotal: overview.menuTotal,
|
|
||||||
rolesTotal: overview.rolesTotal,
|
|
||||||
usersTotal: overview.usersTotal
|
|
||||||
};
|
|
||||||
}, [overview]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
error,
|
error,
|
||||||
loading,
|
loading,
|
||||||
overview,
|
overview: data,
|
||||||
range,
|
reload
|
||||||
reload,
|
|
||||||
setRange,
|
|
||||||
stats
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function dashboardDayRolloverInterval(timeZone, now = Date.now()) {
|
||||||
|
const nextStartMs = nextDayStartMillis(now, timeZone);
|
||||||
|
if (nextStartMs === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Math.max(DAY_ROLLOVER_DELAY_MS, nextStartMs - now + DAY_ROLLOVER_DELAY_MS);
|
||||||
|
}
|
||||||
|
|||||||
74
src/features/dashboard/hooks/useDashboardPage.test.jsx
Normal file
74
src/features/dashboard/hooks/useDashboardPage.test.jsx
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { renderHook } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
appCode: "lalu",
|
||||||
|
getDashboardUserProfileOverview: vi.fn(),
|
||||||
|
queryFn: null,
|
||||||
|
queryOptions: null,
|
||||||
|
reload: vi.fn(),
|
||||||
|
timeZone: "Asia/Shanghai"
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/app/app-scope/AppScopeProvider.jsx", () => ({
|
||||||
|
useAppScope: () => ({ appCode: mocks.appCode })
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/app/timezone/TimeZoneProvider.jsx", () => ({
|
||||||
|
useTimeZone: () => ({ timeZone: mocks.timeZone })
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/dashboard/api", () => ({
|
||||||
|
getDashboardUserProfileOverview: mocks.getDashboardUserProfileOverview
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/shared/hooks/useAdminQuery.js", () => ({
|
||||||
|
useAdminQuery: (queryFn, options) => {
|
||||||
|
mocks.queryFn = queryFn;
|
||||||
|
mocks.queryOptions = options;
|
||||||
|
return { data: null, error: "", loading: false, reload: mocks.reload };
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { useDashboardPage } from "./useDashboardPage.js";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.appCode = "lalu";
|
||||||
|
mocks.timeZone = "Asia/Shanghai";
|
||||||
|
mocks.getDashboardUserProfileOverview.mockResolvedValue(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isolates the query by App and time zone and requests the current local day", async () => {
|
||||||
|
const now = Date.UTC(2026, 6, 11, 2, 30, 0);
|
||||||
|
vi.spyOn(Date, "now").mockReturnValue(now);
|
||||||
|
const { rerender } = renderHook(() => useDashboardPage());
|
||||||
|
|
||||||
|
expect(mocks.queryOptions.queryKey).toEqual(["dashboard", "user-profile-overview", "lalu", "Asia/Shanghai"]);
|
||||||
|
expect(mocks.queryOptions.refetchInterval()).toBe(Date.UTC(2026, 6, 11, 16, 0, 1) - now);
|
||||||
|
await mocks.queryFn();
|
||||||
|
expect(mocks.getDashboardUserProfileOverview).toHaveBeenLastCalledWith({
|
||||||
|
appCode: "lalu",
|
||||||
|
endMs: now,
|
||||||
|
startMs: Date.UTC(2026, 6, 10, 16, 0, 0),
|
||||||
|
statTz: "Asia/Shanghai"
|
||||||
|
});
|
||||||
|
|
||||||
|
mocks.appCode = "huwaa";
|
||||||
|
mocks.timeZone = "UTC";
|
||||||
|
rerender();
|
||||||
|
|
||||||
|
expect(mocks.queryOptions.queryKey).toEqual(["dashboard", "user-profile-overview", "huwaa", "UTC"]);
|
||||||
|
expect(mocks.queryOptions.refetchInterval()).toBe(Date.UTC(2026, 6, 12, 0, 0, 1) - now);
|
||||||
|
await mocks.queryFn();
|
||||||
|
expect(mocks.getDashboardUserProfileOverview).toHaveBeenLastCalledWith({
|
||||||
|
appCode: "huwaa",
|
||||||
|
endMs: now,
|
||||||
|
startMs: Date.UTC(2026, 6, 11, 0, 0, 0),
|
||||||
|
statTz: "UTC"
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,5 +1,11 @@
|
|||||||
import { DashboardWorkspaces } from "@/features/dashboard/components/DashboardWorkspaces.jsx";
|
import { DashboardWorkspaces } from "@/features/dashboard/components/DashboardWorkspaces.jsx";
|
||||||
|
import { DashboardUserProfile } from "@/features/dashboard/components/DashboardUserProfile.jsx";
|
||||||
|
|
||||||
export function OverviewPage() {
|
export function OverviewPage() {
|
||||||
return <DashboardWorkspaces />;
|
return (
|
||||||
|
<>
|
||||||
|
<DashboardWorkspaces />
|
||||||
|
<DashboardUserProfile />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -78,6 +78,7 @@ export const API_OPERATIONS = {
|
|||||||
createWeeklyStarCycle: "createWeeklyStarCycle",
|
createWeeklyStarCycle: "createWeeklyStarCycle",
|
||||||
creditCoinSellerStock: "creditCoinSellerStock",
|
creditCoinSellerStock: "creditCoinSellerStock",
|
||||||
dashboardOverview: "dashboardOverview",
|
dashboardOverview: "dashboardOverview",
|
||||||
|
dashboardUserProfileOverview: "dashboardUserProfileOverview",
|
||||||
debitCoinSellerStock: "debitCoinSellerStock",
|
debitCoinSellerStock: "debitCoinSellerStock",
|
||||||
deleteAchievementDefinition: "deleteAchievementDefinition",
|
deleteAchievementDefinition: "deleteAchievementDefinition",
|
||||||
deleteAgency: "deleteAgency",
|
deleteAgency: "deleteAgency",
|
||||||
@ -126,6 +127,7 @@ export const API_OPERATIONS = {
|
|||||||
getCPConfig: "getCPConfig",
|
getCPConfig: "getCPConfig",
|
||||||
getCPWeeklyRankConfig: "getCPWeeklyRankConfig",
|
getCPWeeklyRankConfig: "getCPWeeklyRankConfig",
|
||||||
getCumulativeRechargeRewardConfig: "getCumulativeRechargeRewardConfig",
|
getCumulativeRechargeRewardConfig: "getCumulativeRechargeRewardConfig",
|
||||||
|
getFinanceCoinSellerRechargeExchangeRate: "getFinanceCoinSellerRechargeExchangeRate",
|
||||||
getFinanceScope: "getFinanceScope",
|
getFinanceScope: "getFinanceScope",
|
||||||
getFirstRechargeRewardConfig: "getFirstRechargeRewardConfig",
|
getFirstRechargeRewardConfig: "getFirstRechargeRewardConfig",
|
||||||
getHumanRoomRobotConfig: "getHumanRoomRobotConfig",
|
getHumanRoomRobotConfig: "getHumanRoomRobotConfig",
|
||||||
@ -267,6 +269,7 @@ export const API_OPERATIONS = {
|
|||||||
navigationMenus: "navigationMenus",
|
navigationMenus: "navigationMenus",
|
||||||
publishHostAgencyPolicy: "publishHostAgencyPolicy",
|
publishHostAgencyPolicy: "publishHostAgencyPolicy",
|
||||||
publishPolicyInstance: "publishPolicyInstance",
|
publishPolicyInstance: "publishPolicyInstance",
|
||||||
|
quoteFinanceCoinSellerRechargeOrder: "quoteFinanceCoinSellerRechargeOrder",
|
||||||
recyclePrettyId: "recyclePrettyId",
|
recyclePrettyId: "recyclePrettyId",
|
||||||
refresh: "refresh",
|
refresh: "refresh",
|
||||||
refreshGoogleRechargePaid: "refreshGoogleRechargePaid",
|
refreshGoogleRechargePaid: "refreshGoogleRechargePaid",
|
||||||
@ -275,6 +278,7 @@ export const API_OPERATIONS = {
|
|||||||
rejectFinanceWithdrawalApplication: "rejectFinanceWithdrawalApplication",
|
rejectFinanceWithdrawalApplication: "rejectFinanceWithdrawalApplication",
|
||||||
renameCountryCode: "renameCountryCode",
|
renameCountryCode: "renameCountryCode",
|
||||||
replaceCoinSellerSalaryRates: "replaceCoinSellerSalaryRates",
|
replaceCoinSellerSalaryRates: "replaceCoinSellerSalaryRates",
|
||||||
|
replaceFinanceCoinSellerRechargeExchangeRate: "replaceFinanceCoinSellerRechargeExchangeRate",
|
||||||
replaceRegionBlocks: "replaceRegionBlocks",
|
replaceRegionBlocks: "replaceRegionBlocks",
|
||||||
replaceRegionCountries: "replaceRegionCountries",
|
replaceRegionCountries: "replaceRegionCountries",
|
||||||
replaceRoleDataScopes: "replaceRoleDataScopes",
|
replaceRoleDataScopes: "replaceRoleDataScopes",
|
||||||
@ -828,6 +832,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "overview:view",
|
permission: "overview:view",
|
||||||
permissions: ["overview:view"]
|
permissions: ["overview:view"]
|
||||||
},
|
},
|
||||||
|
dashboardUserProfileOverview: {
|
||||||
|
method: "GET",
|
||||||
|
operationId: API_OPERATIONS.dashboardUserProfileOverview,
|
||||||
|
path: "/v1/dashboard/user-profile-overview",
|
||||||
|
permission: "overview:view",
|
||||||
|
permissions: ["overview:view"]
|
||||||
|
},
|
||||||
debitCoinSellerStock: {
|
debitCoinSellerStock: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
operationId: API_OPERATIONS.debitCoinSellerStock,
|
operationId: API_OPERATIONS.debitCoinSellerStock,
|
||||||
@ -1164,6 +1175,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "cumulative-recharge-reward:view",
|
permission: "cumulative-recharge-reward:view",
|
||||||
permissions: ["cumulative-recharge-reward:view"]
|
permissions: ["cumulative-recharge-reward:view"]
|
||||||
},
|
},
|
||||||
|
getFinanceCoinSellerRechargeExchangeRate: {
|
||||||
|
method: "GET",
|
||||||
|
operationId: API_OPERATIONS.getFinanceCoinSellerRechargeExchangeRate,
|
||||||
|
path: "/v1/admin/finance/coin-seller-recharge-exchange-rates/{app_code}",
|
||||||
|
permission: "coin-seller:exchange-rate",
|
||||||
|
permissions: ["coin-seller:exchange-rate"]
|
||||||
|
},
|
||||||
getFinanceScope: {
|
getFinanceScope: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.getFinanceScope,
|
operationId: API_OPERATIONS.getFinanceScope,
|
||||||
@ -2141,6 +2159,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "policy-instance:publish",
|
permission: "policy-instance:publish",
|
||||||
permissions: ["policy-instance:publish"]
|
permissions: ["policy-instance:publish"]
|
||||||
},
|
},
|
||||||
|
quoteFinanceCoinSellerRechargeOrder: {
|
||||||
|
method: "POST",
|
||||||
|
operationId: API_OPERATIONS.quoteFinanceCoinSellerRechargeOrder,
|
||||||
|
path: "/v1/admin/finance/orders/coin-seller-recharges/quotes",
|
||||||
|
permission: "finance-order:coin-seller-recharge:create",
|
||||||
|
permissions: ["finance-order:coin-seller-recharge:create"]
|
||||||
|
},
|
||||||
recyclePrettyId: {
|
recyclePrettyId: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
operationId: API_OPERATIONS.recyclePrettyId,
|
operationId: API_OPERATIONS.recyclePrettyId,
|
||||||
@ -2195,6 +2220,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "coin-seller:exchange-rate",
|
permission: "coin-seller:exchange-rate",
|
||||||
permissions: ["coin-seller:exchange-rate"]
|
permissions: ["coin-seller:exchange-rate"]
|
||||||
},
|
},
|
||||||
|
replaceFinanceCoinSellerRechargeExchangeRate: {
|
||||||
|
method: "PUT",
|
||||||
|
operationId: API_OPERATIONS.replaceFinanceCoinSellerRechargeExchangeRate,
|
||||||
|
path: "/v1/admin/finance/coin-seller-recharge-exchange-rates/{app_code}",
|
||||||
|
permission: "coin-seller:exchange-rate",
|
||||||
|
permissions: ["coin-seller:exchange-rate"]
|
||||||
|
},
|
||||||
replaceRegionBlocks: {
|
replaceRegionBlocks: {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
operationId: API_OPERATIONS.replaceRegionBlocks,
|
operationId: API_OPERATIONS.replaceRegionBlocks,
|
||||||
|
|||||||
218
src/shared/api/generated/schema.d.ts
vendored
218
src/shared/api/generated/schema.d.ts
vendored
@ -2488,6 +2488,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/admin/finance/orders/coin-seller-recharges/quotes": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["quoteFinanceCoinSellerRechargeOrder"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/admin/finance/orders/coin-seller-recharges/receipt-verifications": {
|
"/admin/finance/orders/coin-seller-recharges/receipt-verifications": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -2536,6 +2552,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/admin/finance/coin-seller-recharge-exchange-rates/{app_code}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getFinanceCoinSellerRechargeExchangeRate"];
|
||||||
|
put: operations["replaceFinanceCoinSellerRechargeExchangeRate"];
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/admin/payment/temporary-links": {
|
"/admin/payment/temporary-links": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -3576,6 +3608,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/dashboard/user-profile-overview": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["dashboardUserProfileOverview"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/statistics/overview": {
|
"/statistics/overview": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -4886,6 +4934,9 @@ export interface components {
|
|||||||
ApiResponseDashboardOverview: components["schemas"]["Envelope"] & {
|
ApiResponseDashboardOverview: components["schemas"]["Envelope"] & {
|
||||||
data?: components["schemas"]["DashboardOverview"];
|
data?: components["schemas"]["DashboardOverview"];
|
||||||
};
|
};
|
||||||
|
ApiResponseDashboardUserProfileOverview: components["schemas"]["Envelope"] & {
|
||||||
|
data?: components["schemas"]["DashboardUserProfileOverview"];
|
||||||
|
};
|
||||||
ApiResponseEmpty: components["schemas"]["Envelope"];
|
ApiResponseEmpty: components["schemas"]["Envelope"];
|
||||||
ApiResponseObject: components["schemas"]["Envelope"] & {
|
ApiResponseObject: components["schemas"]["Envelope"] & {
|
||||||
data?: {
|
data?: {
|
||||||
@ -4965,6 +5016,12 @@ export interface components {
|
|||||||
ApiResponseFinanceCoinSellerRechargeOrder: components["schemas"]["Envelope"] & {
|
ApiResponseFinanceCoinSellerRechargeOrder: components["schemas"]["Envelope"] & {
|
||||||
data?: components["schemas"]["FinanceCoinSellerRechargeOrder"];
|
data?: components["schemas"]["FinanceCoinSellerRechargeOrder"];
|
||||||
};
|
};
|
||||||
|
ApiResponseFinanceCoinSellerRechargeQuote: components["schemas"]["Envelope"] & {
|
||||||
|
data?: components["schemas"]["FinanceCoinSellerRechargeQuote"];
|
||||||
|
};
|
||||||
|
ApiResponseFinanceCoinSellerRechargeExchangeRate: components["schemas"]["Envelope"] & {
|
||||||
|
data?: components["schemas"]["FinanceCoinSellerRechargeExchangeRate"];
|
||||||
|
};
|
||||||
ApiResponseFinanceCoinSellerRechargeReceiptVerification: components["schemas"]["Envelope"] & {
|
ApiResponseFinanceCoinSellerRechargeReceiptVerification: components["schemas"]["Envelope"] & {
|
||||||
data?: components["schemas"]["FinanceCoinSellerRechargeReceiptVerification"];
|
data?: components["schemas"]["FinanceCoinSellerRechargeReceiptVerification"];
|
||||||
};
|
};
|
||||||
@ -5311,6 +5368,24 @@ export interface components {
|
|||||||
DashboardOverview: {
|
DashboardOverview: {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
DashboardUserDistributionItem: {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
DashboardUserProfileOverview: {
|
||||||
|
appCode: string;
|
||||||
|
/** @description 已完成资料且排除机器人、快捷账号的自然用户总数 */
|
||||||
|
totalUsers: number;
|
||||||
|
newUsers: number;
|
||||||
|
activeUsers: number;
|
||||||
|
/** @description 已完成资料且排除机器人、快捷账号的自然用户性别分布 */
|
||||||
|
genderDistribution: components["schemas"]["DashboardUserDistributionItem"][];
|
||||||
|
/** @description 自然用户最近一次成功登录时上报的 App 版本号分布 */
|
||||||
|
appVersionDistribution: components["schemas"]["DashboardUserDistributionItem"][];
|
||||||
|
/** Format: int64 */
|
||||||
|
updatedAtMs: number;
|
||||||
|
};
|
||||||
Envelope: {
|
Envelope: {
|
||||||
code?: number;
|
code?: number;
|
||||||
data?: unknown;
|
data?: unknown;
|
||||||
@ -5644,7 +5719,8 @@ export interface components {
|
|||||||
appCode: string;
|
appCode: string;
|
||||||
targetUserId: string;
|
targetUserId: string;
|
||||||
usdAmount: number;
|
usdAmount: number;
|
||||||
coinAmount: number;
|
/** @description 补单时为 true;订单正常计入充值,但服务端强制金币数为 0 */
|
||||||
|
isMakeup?: boolean;
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
providerCode: "mifapay" | "v5pay" | "usdt";
|
providerCode: "mifapay" | "v5pay" | "usdt";
|
||||||
externalOrderNo: string;
|
externalOrderNo: string;
|
||||||
@ -5654,6 +5730,50 @@ export interface components {
|
|||||||
chain?: "TRON" | "BSC";
|
chain?: "TRON" | "BSC";
|
||||||
remark?: string;
|
remark?: string;
|
||||||
};
|
};
|
||||||
|
FinanceCoinSellerRechargeQuoteInput: {
|
||||||
|
appCode: string;
|
||||||
|
targetUserId: string;
|
||||||
|
usdAmount: number;
|
||||||
|
};
|
||||||
|
FinanceCoinSellerRechargeQuote: {
|
||||||
|
appCode: string;
|
||||||
|
targetUserId: string;
|
||||||
|
targetDisplayUserId?: string;
|
||||||
|
usdAmount: string;
|
||||||
|
coinAmount: number;
|
||||||
|
coinsPerUsd: number;
|
||||||
|
/** @enum {string} */
|
||||||
|
rateSource: "tier" | "whitelist";
|
||||||
|
matchedUserId?: string;
|
||||||
|
minUsdAmount?: string;
|
||||||
|
maxUsdAmount?: string;
|
||||||
|
};
|
||||||
|
FinanceCoinSellerRechargeExchangeRateTier: {
|
||||||
|
minUsdAmount: string;
|
||||||
|
maxUsdAmount: string;
|
||||||
|
coinsPerUsd: number;
|
||||||
|
};
|
||||||
|
FinanceCoinSellerRechargeExchangeRateWhitelist: {
|
||||||
|
userId: string;
|
||||||
|
coinsPerUsd: number;
|
||||||
|
};
|
||||||
|
FinanceCoinSellerRechargeExchangeRate: {
|
||||||
|
appCode: string;
|
||||||
|
tiers: components["schemas"]["FinanceCoinSellerRechargeExchangeRateTier"][];
|
||||||
|
whitelist: components["schemas"]["FinanceCoinSellerRechargeExchangeRateWhitelist"][];
|
||||||
|
updatedAtMs: number;
|
||||||
|
};
|
||||||
|
FinanceCoinSellerRechargeExchangeRateInput: {
|
||||||
|
tiers: {
|
||||||
|
minUsdAmount: number;
|
||||||
|
maxUsdAmount: number;
|
||||||
|
coinsPerUsd: number;
|
||||||
|
}[];
|
||||||
|
whitelist: {
|
||||||
|
userId: string;
|
||||||
|
coinsPerUsd: number;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
FinanceCoinSellerRechargeReceiptVerification: {
|
FinanceCoinSellerRechargeReceiptVerification: {
|
||||||
verified: boolean;
|
verified: boolean;
|
||||||
providerCode?: string;
|
providerCode?: string;
|
||||||
@ -5913,6 +6033,15 @@ export interface components {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description OK */
|
/** @description OK */
|
||||||
|
DashboardUserProfileOverviewResponse: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiResponseDashboardUserProfileOverview"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description OK */
|
||||||
VipProgramResponse: {
|
VipProgramResponse: {
|
||||||
headers: {
|
headers: {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
@ -5985,6 +6114,24 @@ export interface components {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description OK */
|
/** @description OK */
|
||||||
|
FinanceCoinSellerRechargeQuoteResponse: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiResponseFinanceCoinSellerRechargeQuote"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description OK */
|
||||||
|
FinanceCoinSellerRechargeExchangeRateResponse: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiResponseFinanceCoinSellerRechargeExchangeRate"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description OK */
|
||||||
FinanceCoinSellerRechargeReceiptVerificationResponse: {
|
FinanceCoinSellerRechargeReceiptVerificationResponse: {
|
||||||
headers: {
|
headers: {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
@ -6281,6 +6428,16 @@ export interface components {
|
|||||||
"application/json": components["schemas"]["FinanceCoinSellerRechargeOrderInput"];
|
"application/json": components["schemas"]["FinanceCoinSellerRechargeOrderInput"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
FinanceCoinSellerRechargeQuoteRequest: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["FinanceCoinSellerRechargeQuoteInput"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
FinanceCoinSellerRechargeExchangeRateRequest: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["FinanceCoinSellerRechargeExchangeRateInput"];
|
||||||
|
};
|
||||||
|
};
|
||||||
FinanceCoinSellerRechargeReceiptVerificationRequest: {
|
FinanceCoinSellerRechargeReceiptVerificationRequest: {
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["FinanceCoinSellerRechargeReceiptVerificationInput"];
|
"application/json": components["schemas"]["FinanceCoinSellerRechargeReceiptVerificationInput"];
|
||||||
@ -9247,6 +9404,8 @@ export interface operations {
|
|||||||
end_ms?: number;
|
end_ms?: number;
|
||||||
app_codes?: string;
|
app_codes?: string;
|
||||||
region_id?: number;
|
region_id?: number;
|
||||||
|
/** @description 逗号分隔的大区 ID;显式区域筛选时用于按 Finance 口径补逐日渠道。 */
|
||||||
|
region_ids?: string;
|
||||||
};
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
@ -9485,6 +9644,18 @@ export interface operations {
|
|||||||
201: components["responses"]["FinanceCoinSellerRechargeOrderResponse"];
|
201: components["responses"]["FinanceCoinSellerRechargeOrderResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
quoteFinanceCoinSellerRechargeOrder: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: components["requestBodies"]["FinanceCoinSellerRechargeQuoteRequest"];
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["FinanceCoinSellerRechargeQuoteResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
verifyFinanceCoinSellerRechargeReceipt: {
|
verifyFinanceCoinSellerRechargeReceipt: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -9525,6 +9696,34 @@ export interface operations {
|
|||||||
200: components["responses"]["FinanceCoinSellerRechargeOrderResponse"];
|
200: components["responses"]["FinanceCoinSellerRechargeOrderResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
getFinanceCoinSellerRechargeExchangeRate: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
app_code: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["FinanceCoinSellerRechargeExchangeRateResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
replaceFinanceCoinSellerRechargeExchangeRate: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
app_code: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: components["requestBodies"]["FinanceCoinSellerRechargeExchangeRateRequest"];
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["FinanceCoinSellerRechargeExchangeRateResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
listTemporaryPaymentLinks: {
|
listTemporaryPaymentLinks: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -10703,6 +10902,23 @@ export interface operations {
|
|||||||
200: components["responses"]["DashboardOverviewResponse"];
|
200: components["responses"]["DashboardOverviewResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
dashboardUserProfileOverview: {
|
||||||
|
parameters: {
|
||||||
|
query: {
|
||||||
|
app_code: string;
|
||||||
|
stat_tz: string;
|
||||||
|
start_ms: number;
|
||||||
|
end_ms: number;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["DashboardUserProfileOverviewResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
statisticsOverview: {
|
statisticsOverview: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
|
|||||||
@ -197,6 +197,29 @@ export interface DashboardOverviewDto {
|
|||||||
usersTotal: number;
|
usersTotal: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DashboardUserDistributionItemDto {
|
||||||
|
count: number;
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardUserProfileOverviewDto {
|
||||||
|
activeUsers: number;
|
||||||
|
appCode: string;
|
||||||
|
appVersionDistribution: DashboardUserDistributionItemDto[];
|
||||||
|
genderDistribution: DashboardUserDistributionItemDto[];
|
||||||
|
newUsers: number;
|
||||||
|
totalUsers: number;
|
||||||
|
updatedAtMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardUserProfileOverviewQuery {
|
||||||
|
appCode: string;
|
||||||
|
endMs: number;
|
||||||
|
startMs: number;
|
||||||
|
statTz: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LogDto {
|
export interface LogDto {
|
||||||
action?: string;
|
action?: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
|
|||||||
106
src/shared/charts/DistributionPieChart.jsx
Normal file
106
src/shared/charts/DistributionPieChart.jsx
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { readCssToken } from "@/shared/utils/cssTokens.js";
|
||||||
|
import { EChart } from "./EChart.jsx";
|
||||||
|
|
||||||
|
const DEFAULT_COLOR_TOKENS = ["--chart-blue", "--chart-green", "--chart-orange", "--chart-red", "--info", "--stopped"];
|
||||||
|
const countFormatter = new Intl.NumberFormat("zh-CN");
|
||||||
|
|
||||||
|
export function DistributionPieChart({ ariaLabel, data = [], emptyText = "当前无数据" }) {
|
||||||
|
const reducedMotion = usePrefersReducedMotion();
|
||||||
|
const items = useMemo(() => normalizeDistributionItems(data), [data]);
|
||||||
|
const option = useMemo(() => createDistributionPieOption(items, reducedMotion), [items, reducedMotion]);
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
return (
|
||||||
|
<div aria-label={ariaLabel} className="distribution-pie-empty" role="status">
|
||||||
|
{emptyText}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div aria-label={ariaLabel} className="distribution-pie" role="group">
|
||||||
|
<EChart className="distribution-pie__chart" option={option} />
|
||||||
|
<div className="distribution-pie__legend" role="list">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div className="distribution-pie__legend-row" key={item.key} role="listitem" title={item.label}>
|
||||||
|
<span className="distribution-pie__legend-dot" style={{ backgroundColor: item.color }} />
|
||||||
|
<span className="distribution-pie__legend-label">{item.label}</span>
|
||||||
|
<strong>{countFormatter.format(item.count)}</strong>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeDistributionItems(data) {
|
||||||
|
// 过滤非法值和零值,避免 ECharts 生成不可见扇区却仍在 tooltip/图例中展示伪数据。
|
||||||
|
return (Array.isArray(data) ? data : [])
|
||||||
|
.map((item, index) => {
|
||||||
|
const count = Number(item?.count);
|
||||||
|
const colorToken = item?.colorToken || DEFAULT_COLOR_TOKENS[index % DEFAULT_COLOR_TOKENS.length];
|
||||||
|
return {
|
||||||
|
color: readCssToken(colorToken, "#64748b"),
|
||||||
|
count,
|
||||||
|
key: String(item?.key || `distribution-${index}`),
|
||||||
|
label: String(item?.label || item?.key || "未知")
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item) => Number.isFinite(item.count) && item.count > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDistributionPieOption(items, reducedMotion = false) {
|
||||||
|
return {
|
||||||
|
animation: !reducedMotion,
|
||||||
|
animationDuration: reducedMotion ? 0 : 450,
|
||||||
|
animationDurationUpdate: reducedMotion ? 0 : 300,
|
||||||
|
color: items.map((item) => item.color),
|
||||||
|
tooltip: {
|
||||||
|
trigger: "item",
|
||||||
|
backgroundColor: readCssToken("--chart-tooltip-bg", "#ffffff"),
|
||||||
|
borderColor: readCssToken("--chart-tooltip-border", "#d8e0ec"),
|
||||||
|
textStyle: { color: readCssToken("--chart-tooltip-text", "#0f172a") },
|
||||||
|
valueFormatter: (value) => `${countFormatter.format(Number(value) || 0)} 人`
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: "pie",
|
||||||
|
radius: ["44%", "72%"],
|
||||||
|
center: ["50%", "50%"],
|
||||||
|
avoidLabelOverlap: true,
|
||||||
|
label: { show: false },
|
||||||
|
labelLine: { show: false },
|
||||||
|
itemStyle: {
|
||||||
|
borderColor: readCssToken("--bg-card", "#ffffff"),
|
||||||
|
borderWidth: 2
|
||||||
|
},
|
||||||
|
emphasis: { scaleSize: 6 },
|
||||||
|
data: items.map((item) => ({ name: item.label, value: item.count }))
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function usePrefersReducedMotion() {
|
||||||
|
const [reducedMotion, setReducedMotion] = useState(() => readReducedMotionPreference());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window.matchMedia !== "function") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||||
|
const update = (event) => setReducedMotion(event.matches);
|
||||||
|
setReducedMotion(media.matches);
|
||||||
|
media.addEventListener?.("change", update);
|
||||||
|
return () => media.removeEventListener?.("change", update);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return reducedMotion;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readReducedMotionPreference() {
|
||||||
|
return typeof window !== "undefined" && typeof window.matchMedia === "function"
|
||||||
|
? window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||||
|
: false;
|
||||||
|
}
|
||||||
67
src/shared/charts/DistributionPieChart.test.jsx
Normal file
67
src/shared/charts/DistributionPieChart.test.jsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("./EChart.jsx", () => ({
|
||||||
|
EChart: ({ className }) => <div className={className} data-testid="distribution-echart" />
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
createDistributionPieOption,
|
||||||
|
DistributionPieChart,
|
||||||
|
normalizeDistributionItems
|
||||||
|
} from "./DistributionPieChart.jsx";
|
||||||
|
|
||||||
|
test("shows the standard empty state when every distribution count is zero or invalid", () => {
|
||||||
|
render(
|
||||||
|
<DistributionPieChart
|
||||||
|
ariaLabel="空分布"
|
||||||
|
data={[
|
||||||
|
{ count: 0, key: "zero", label: "零" },
|
||||||
|
{ count: "invalid", key: "invalid", label: "无效" }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("status", { name: "空分布" })).toHaveTextContent("当前无数据");
|
||||||
|
expect(screen.queryByTestId("distribution-echart")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps a long distribution in a bounded legend instead of expanding the chart card", () => {
|
||||||
|
const data = Array.from({ length: 12 }, (_, index) => ({
|
||||||
|
count: 12 - index,
|
||||||
|
key: `version-${index}`,
|
||||||
|
label: `2.0.${index}`
|
||||||
|
}));
|
||||||
|
const { container } = render(<DistributionPieChart ariaLabel="版本分布" data={data} />);
|
||||||
|
|
||||||
|
expect(screen.getAllByRole("listitem")).toHaveLength(12);
|
||||||
|
expect(container.querySelector(".distribution-pie__legend")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("distribution-echart")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("disables ECharts canvas animation when reduced motion is requested", () => {
|
||||||
|
const items = normalizeDistributionItems([{ count: 8, key: "female", label: "女" }]);
|
||||||
|
|
||||||
|
expect(createDistributionPieOption(items, true)).toMatchObject({
|
||||||
|
animation: false,
|
||||||
|
animationDuration: 0,
|
||||||
|
animationDurationUpdate: 0
|
||||||
|
});
|
||||||
|
expect(createDistributionPieOption(items, false)).toMatchObject({
|
||||||
|
animation: true,
|
||||||
|
animationDuration: 450,
|
||||||
|
animationDurationUpdate: 300
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maps normalized distribution rows into ECharts pie series", () => {
|
||||||
|
const items = normalizeDistributionItems([
|
||||||
|
{ count: 8, key: "female", label: "女" },
|
||||||
|
{ count: 5, key: "male", label: "男" }
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(createDistributionPieOption(items).series[0].data).toEqual([
|
||||||
|
{ name: "女", value: 8 },
|
||||||
|
{ name: "男", value: 5 }
|
||||||
|
]);
|
||||||
|
});
|
||||||
@ -8,6 +8,7 @@ export function AdminUserIdentity({
|
|||||||
defaultDisplayUserId,
|
defaultDisplayUserId,
|
||||||
displayUserId,
|
displayUserId,
|
||||||
name,
|
name,
|
||||||
|
namePrefix,
|
||||||
onClick,
|
onClick,
|
||||||
openInAppUserDetail = false,
|
openInAppUserDetail = false,
|
||||||
prettyDisplayUserId,
|
prettyDisplayUserId,
|
||||||
@ -67,6 +68,7 @@ export function AdminUserIdentity({
|
|||||||
</span>
|
</span>
|
||||||
<div className={styles.text}>
|
<div className={styles.text}>
|
||||||
<div className={styles.nameLine}>
|
<div className={styles.nameLine}>
|
||||||
|
{namePrefix ? <span className={styles.namePrefix}>{namePrefix}</span> : null}
|
||||||
<span className={styles.name}>{normalized.displayName}</span>
|
<span className={styles.name}>{normalized.displayName}</span>
|
||||||
</div>
|
</div>
|
||||||
{visibleRows.length ? (
|
{visibleRows.length ? (
|
||||||
|
|||||||
@ -80,6 +80,12 @@
|
|||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.namePrefix {
|
||||||
|
display: inline-flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.name {
|
.name {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, expect, test, vi } from "vitest";
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { AppUserDetailProvider } from "@/features/app-users/components/AppUserDetailProvider.jsx";
|
import { AppUserDetailProvider } from "@/features/app-users/components/AppUserDetailProvider.jsx";
|
||||||
import { AppUserDetailContext } from "@/features/app-users/components/AppUserDetailContext.jsx";
|
import { AppUserDetailContext } from "@/features/app-users/components/AppUserDetailContext.jsx";
|
||||||
import { setAccessToken } from "@/shared/api/request";
|
import { setAccessToken } from "@/shared/api/request";
|
||||||
@ -136,9 +137,11 @@ test("app user detail provider fetches detail and renders current pretty id", as
|
|||||||
|
|
||||||
render(
|
render(
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<AppUserDetailProvider>
|
<MemoryRouter>
|
||||||
<AdminUserIdentity openInAppUserDetail user={{ displayUserId: "123456", userId: "10001" }} />
|
<AppUserDetailProvider>
|
||||||
</AppUserDetailProvider>
|
<AdminUserIdentity openInAppUserDetail user={{ displayUserId: "123456", userId: "10001" }} />
|
||||||
|
</AppUserDetailProvider>
|
||||||
|
</MemoryRouter>
|
||||||
</ToastProvider>,
|
</ToastProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -15,12 +15,29 @@ export function SideDrawer({
|
|||||||
width = "default",
|
width = "default",
|
||||||
...props
|
...props
|
||||||
}) {
|
}) {
|
||||||
const drawerClassName = ["side-drawer", width === "wide" ? "side-drawer--wide" : "", className]
|
const widthClassName =
|
||||||
|
width === "wide" ? "side-drawer--wide" : width === "detail" ? "side-drawer--detail" : "";
|
||||||
|
const drawerClassName = ["side-drawer", widthClassName, className]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ");
|
.join(" ");
|
||||||
|
const backdropSlotProps = drawerProps.slotProps?.backdrop || {};
|
||||||
|
const resolvedDrawerProps = {
|
||||||
|
...drawerProps,
|
||||||
|
slotProps: {
|
||||||
|
...drawerProps.slotProps,
|
||||||
|
backdrop: {
|
||||||
|
...backdropSlotProps,
|
||||||
|
// 抽屉需要保留父页面上下文,使用全局半透明遮罩 token,避免 MUI 默认遮罩过暗。
|
||||||
|
sx: [
|
||||||
|
{ backgroundColor: "var(--overlay-scrim)" },
|
||||||
|
...(Array.isArray(backdropSlotProps.sx) ? backdropSlotProps.sx : [backdropSlotProps.sx].filter(Boolean)),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer anchor="right" open={open} onClose={onClose} {...drawerProps}>
|
<Drawer anchor="right" open={open} onClose={onClose} {...resolvedDrawerProps}>
|
||||||
<Component className={drawerClassName} {...props}>
|
<Component className={drawerClassName} {...props}>
|
||||||
<div className="side-drawer__header">
|
<div className="side-drawer__header">
|
||||||
{title ? <h2 className="side-drawer__title">{title}</h2> : <span />}
|
{title ? <h2 className="side-drawer__title">{title}</h2> : <span />}
|
||||||
|
|||||||
@ -85,6 +85,66 @@ export function formatMillis(value, timeZone = activeTimeZone) {
|
|||||||
return formatDateTime(value, timeZone);
|
return formatDateTime(value, timeZone);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function startOfDayMillis(value = Date.now(), timeZone = activeTimeZone) {
|
||||||
|
const normalizedTimeZone = normalizeTimeZone(timeZone);
|
||||||
|
const targetParts = dateTimeParts(value, normalizedTimeZone);
|
||||||
|
if (!targetParts) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return localDateStartMillis(targetParts, normalizedTimeZone);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextDayStartMillis(value = Date.now(), timeZone = activeTimeZone) {
|
||||||
|
const normalizedTimeZone = normalizeTimeZone(timeZone);
|
||||||
|
const targetParts = dateTimeParts(value, normalizedTimeZone);
|
||||||
|
if (!targetParts) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先按日历推进一天,再把该日期的本地零点换算成 epoch;不能直接加 24 小时,
|
||||||
|
// 否则未来开放夏令时时区后会在切换日提前或延后一小时刷新 Dashboard。
|
||||||
|
const nextCalendarDate = new Date(
|
||||||
|
Date.UTC(Number(targetParts.year), Number(targetParts.month) - 1, Number(targetParts.day) + 1)
|
||||||
|
);
|
||||||
|
return localDateStartMillis(
|
||||||
|
{
|
||||||
|
day: String(nextCalendarDate.getUTCDate()).padStart(2, "0"),
|
||||||
|
month: String(nextCalendarDate.getUTCMonth() + 1).padStart(2, "0"),
|
||||||
|
year: String(nextCalendarDate.getUTCFullYear())
|
||||||
|
},
|
||||||
|
normalizedTimeZone
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function localDateStartMillis(targetParts, timeZone) {
|
||||||
|
const targetUtc = Date.UTC(Number(targetParts.year), Number(targetParts.month) - 1, Number(targetParts.day));
|
||||||
|
let candidate = targetUtc;
|
||||||
|
|
||||||
|
// IANA 时区的 UTC 偏移可能随日期变化;迭代校正能在不引入时区库的前提下得到目标日期的本地零点。
|
||||||
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||||
|
const candidateParts = dateTimeParts(candidate, timeZone);
|
||||||
|
if (!candidateParts) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const representedUtc = Date.UTC(
|
||||||
|
Number(candidateParts.year),
|
||||||
|
Number(candidateParts.month) - 1,
|
||||||
|
Number(candidateParts.day),
|
||||||
|
Number(candidateParts.hour),
|
||||||
|
Number(candidateParts.minute),
|
||||||
|
Number(candidateParts.second)
|
||||||
|
);
|
||||||
|
const corrected = targetUtc - (representedUtc - candidate);
|
||||||
|
if (corrected === candidate) {
|
||||||
|
return corrected;
|
||||||
|
}
|
||||||
|
candidate = corrected;
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
function finitePositiveMillis(value) {
|
function finitePositiveMillis(value) {
|
||||||
return Number.isFinite(value) && value > 0 ? value : null;
|
return Number.isFinite(value) && value > 0 ? value : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { formatMillis, normalizeTimeZone, toEpochMillis } from "./time.js";
|
import { formatMillis, nextDayStartMillis, normalizeTimeZone, startOfDayMillis, toEpochMillis } from "./time.js";
|
||||||
|
|
||||||
describe("time formatting", () => {
|
describe("time formatting", () => {
|
||||||
test("formats the same epoch milliseconds in UTC and China time", () => {
|
test("formats the same epoch milliseconds in UTC and China time", () => {
|
||||||
@ -18,4 +18,18 @@ describe("time formatting", () => {
|
|||||||
expect(normalizeTimeZone("UTC")).toBe("UTC");
|
expect(normalizeTimeZone("UTC")).toBe("UTC");
|
||||||
expect(normalizeTimeZone("America/Los_Angeles")).toBe("Asia/Shanghai");
|
expect(normalizeTimeZone("America/Los_Angeles")).toBe("Asia/Shanghai");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("calculates the current local day boundary for dashboard statistics", () => {
|
||||||
|
const epochMillis = Date.UTC(2026, 4, 9, 19, 21, 0);
|
||||||
|
|
||||||
|
expect(startOfDayMillis(epochMillis, "UTC")).toBe(Date.UTC(2026, 4, 9, 0, 0, 0));
|
||||||
|
expect(startOfDayMillis(epochMillis, "Asia/Shanghai")).toBe(Date.UTC(2026, 4, 9, 16, 0, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("calculates the next local day boundary used for dashboard rollover", () => {
|
||||||
|
const epochMillis = Date.UTC(2026, 4, 9, 19, 21, 0);
|
||||||
|
|
||||||
|
expect(nextDayStartMillis(epochMillis, "UTC")).toBe(Date.UTC(2026, 4, 10, 0, 0, 0));
|
||||||
|
expect(nextDayStartMillis(epochMillis, "Asia/Shanghai")).toBe(Date.UTC(2026, 4, 10, 16, 0, 0));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1222,6 +1222,10 @@
|
|||||||
width: min(560px, 100vw);
|
width: min(560px, 100vw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.side-drawer--detail {
|
||||||
|
width: min(720px, 100vw);
|
||||||
|
}
|
||||||
|
|
||||||
.side-drawer__header {
|
.side-drawer__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user