Compare commits
No commits in common. "24e9bfc439a212d257efb8a851a9ea4a018a6082" and "c836d0f702c9d0588ee9f795b8427eae6d42fb34" have entirely different histories.
24e9bfc439
...
c836d0f702
@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "0.0.1",
|
|
||||||
"configurations": [
|
|
||||||
{
|
|
||||||
"name": "admin-platform",
|
|
||||||
"runtimeExecutable": "npm",
|
|
||||||
"runtimeArgs": ["run", "dev"],
|
|
||||||
"port": 7001
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@ -15,17 +15,7 @@
|
|||||||
"operationId": "listAchievementDefinitions",
|
"operationId": "listAchievementDefinitions",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "批量创建的礼物",
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "object"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"x-permission": "achievement:view",
|
"x-permission": "achievement:view",
|
||||||
@ -35,17 +25,7 @@
|
|||||||
"operationId": "createAchievementDefinition",
|
"operationId": "createAchievementDefinition",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "批量删除的资源",
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "object"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"x-permission": "achievement:create",
|
"x-permission": "achievement:create",
|
||||||
@ -837,38 +817,6 @@
|
|||||||
"x-permissions": ["bd:create"]
|
"x-permissions": ["bd:create"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/admin/managers/{user_id}": {
|
|
||||||
"put": {
|
|
||||||
"operationId": "updateManager",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"in": "path",
|
|
||||||
"name": "user_id",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"type": "integer"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"requestBody": {
|
|
||||||
"required": true,
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/UpdateManagerPayload"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"$ref": "#/components/responses/ManagerResponse"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"x-permission": "bd:update",
|
|
||||||
"x-permissions": ["bd:update"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/admin/agencies": {
|
"/admin/agencies": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listAgencies",
|
"operationId": "listAgencies",
|
||||||
@ -2373,21 +2321,6 @@
|
|||||||
"x-permissions": ["gift:create"]
|
"x-permissions": ["gift:create"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/admin/gifts/batch": {
|
|
||||||
"post": {
|
|
||||||
"operationId": "batchCreateGifts",
|
|
||||||
"requestBody": {
|
|
||||||
"$ref": "#/components/requestBodies/BatchCreateGiftsRequest"
|
|
||||||
},
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"$ref": "#/components/responses/EmptyResponse"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"x-permission": "gift:create",
|
|
||||||
"x-permissions": ["gift:create"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/admin/gifts/{gift_id}": {
|
"/admin/gifts/{gift_id}": {
|
||||||
"put": {
|
"put": {
|
||||||
"operationId": "updateGift",
|
"operationId": "updateGift",
|
||||||
@ -2681,39 +2614,6 @@
|
|||||||
},
|
},
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "createCoinAdjustment",
|
"operationId": "createCoinAdjustment",
|
||||||
"requestBody": {
|
|
||||||
"required": true,
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"type": "object",
|
|
||||||
"required": ["amount", "targetUserId", "reason", "remark"],
|
|
||||||
"properties": {
|
|
||||||
"amount": {
|
|
||||||
"type": "integer",
|
|
||||||
"format": "int64"
|
|
||||||
},
|
|
||||||
"commandId": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"reason": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1
|
|
||||||
},
|
|
||||||
"remark": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1,
|
|
||||||
"maxLength": 512
|
|
||||||
},
|
|
||||||
"targetUserId": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"$ref": "#/components/responses/EmptyResponse"
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
@ -2828,25 +2728,13 @@
|
|||||||
"summary": { "type": "string" },
|
"summary": { "type": "string" },
|
||||||
"target_scope": {
|
"target_scope": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": ["all_active_users", "single_user", "user_ids", "region", "country"]
|
||||||
"all_registered_users",
|
|
||||||
"all_active_users",
|
|
||||||
"single_user",
|
|
||||||
"user_ids",
|
|
||||||
"region",
|
|
||||||
"country"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"target_user_id": { "type": "integer", "format": "int64" },
|
"target_user_id": { "type": "integer", "format": "int64" },
|
||||||
"title": { "type": "string" },
|
"title": { "type": "string" },
|
||||||
"user_ids": {
|
"user_ids": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": { "type": "integer", "format": "int64" }
|
||||||
"oneOf": [
|
|
||||||
{ "type": "integer", "format": "int64" },
|
|
||||||
{ "type": "string", "pattern": "^[1-9][0-9]*$" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2937,234 +2825,6 @@
|
|||||||
"x-permissions": ["payment-bill:view"]
|
"x-permissions": ["payment-bill:view"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/admin/payment/recharge-bills/overview": {
|
|
||||||
"get": {
|
|
||||||
"operationId": "getRechargeBillOverview",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"in": "query",
|
|
||||||
"name": "keyword",
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"in": "query",
|
|
||||||
"name": "recharge_type",
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"in": "query",
|
|
||||||
"name": "status",
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"in": "query",
|
|
||||||
"name": "region_id",
|
|
||||||
"schema": {
|
|
||||||
"type": "integer"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"in": "query",
|
|
||||||
"name": "start_at_ms",
|
|
||||||
"schema": {
|
|
||||||
"type": "integer"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"in": "query",
|
|
||||||
"name": "end_at_ms",
|
|
||||||
"schema": {
|
|
||||||
"type": "integer"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"in": "query",
|
|
||||||
"name": "tz_offset_minutes",
|
|
||||||
"schema": {
|
|
||||||
"type": "integer"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"$ref": "#/components/responses/EmptyResponse"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"x-permission": "payment-bill:view",
|
|
||||||
"x-permissions": ["payment-bill:view"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/admin/payment/recharge-bills/summary": {
|
|
||||||
"get": {
|
|
||||||
"operationId": "getRechargeBillSummary",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"in": "query",
|
|
||||||
"name": "keyword",
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"in": "query",
|
|
||||||
"name": "recharge_type",
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"in": "query",
|
|
||||||
"name": "status",
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"in": "query",
|
|
||||||
"name": "region_id",
|
|
||||||
"schema": {
|
|
||||||
"type": "integer"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"in": "query",
|
|
||||||
"name": "start_at_ms",
|
|
||||||
"schema": {
|
|
||||||
"type": "integer"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"in": "query",
|
|
||||||
"name": "end_at_ms",
|
|
||||||
"schema": {
|
|
||||||
"type": "integer"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"$ref": "#/components/responses/EmptyResponse"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"x-permission": "payment-bill:view",
|
|
||||||
"x-permissions": ["payment-bill:view"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/admin/payment/recharge-bills/export": {
|
|
||||||
"get": {
|
|
||||||
"operationId": "exportRechargeBills",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"in": "query",
|
|
||||||
"name": "keyword",
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"in": "query",
|
|
||||||
"name": "recharge_type",
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"in": "query",
|
|
||||||
"name": "status",
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"in": "query",
|
|
||||||
"name": "region_id",
|
|
||||||
"schema": {
|
|
||||||
"type": "integer"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"in": "query",
|
|
||||||
"name": "start_at_ms",
|
|
||||||
"schema": {
|
|
||||||
"type": "integer"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"in": "query",
|
|
||||||
"name": "end_at_ms",
|
|
||||||
"schema": {
|
|
||||||
"type": "integer"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"$ref": "#/components/responses/EmptyResponse"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"x-permission": "payment-bill:view",
|
|
||||||
"x-permissions": ["payment-bill:view"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/admin/payment/recharge-bills/google-paid/refresh": {
|
|
||||||
"post": {
|
|
||||||
"operationId": "refreshGoogleRechargePaid",
|
|
||||||
"requestBody": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"transactionIds": {
|
|
||||||
"type": "array",
|
|
||||||
"items": { "type": "string" }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["transactionIds"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"$ref": "#/components/responses/EmptyResponse"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"x-permission": "payment-bill:view",
|
|
||||||
"x-permissions": ["payment-bill:view"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/admin/payment/recharge-regions": {
|
|
||||||
"get": {
|
|
||||||
"operationId": "listRechargeRegions",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"$ref": "#/components/responses/EmptyResponse"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"x-permission": "payment-bill:view",
|
|
||||||
"x-permissions": ["payment-bill:view"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/admin/payment/recharge-apps": {
|
|
||||||
"get": {
|
|
||||||
"operationId": "listRechargeApps",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"$ref": "#/components/responses/EmptyResponse"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"x-permission": "payment-bill:view",
|
|
||||||
"x-permissions": ["payment-bill:view"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/admin/payment/recharge-products": {
|
"/admin/payment/recharge-products": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listRechargeProducts",
|
"operationId": "listRechargeProducts",
|
||||||
@ -3229,76 +2889,6 @@
|
|||||||
"x-permissions": ["payment-product:delete"]
|
"x-permissions": ["payment-product:delete"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/admin/databi/social/master": {
|
|
||||||
"get": {
|
|
||||||
"operationId": "getSocialBiMaster",
|
|
||||||
"x-permission": "overview:view",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"$ref": "#/components/responses/StatisticsObjectResponse"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"x-permissions": ["overview:view"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/admin/databi/social/overview": {
|
|
||||||
"get": {
|
|
||||||
"operationId": "getSocialBiOverview",
|
|
||||||
"x-permission": "overview:view",
|
|
||||||
"parameters": [
|
|
||||||
{ "in": "query", "name": "stat_tz", "schema": { "type": "string" } },
|
|
||||||
{ "in": "query", "name": "start_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": "region_id", "schema": { "type": "integer", "format": "int64" } }
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"$ref": "#/components/responses/StatisticsObjectResponse"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"x-permissions": ["overview:view"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/admin/databi/social/funnel": {
|
|
||||||
"get": {
|
|
||||||
"operationId": "getSocialBiFunnel",
|
|
||||||
"x-permission": "overview:view",
|
|
||||||
"parameters": [
|
|
||||||
{ "in": "query", "name": "stat_tz", "schema": { "type": "string" } },
|
|
||||||
{ "in": "query", "name": "start_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": "region_id", "schema": { "type": "integer", "format": "int64" } }
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"$ref": "#/components/responses/StatisticsObjectResponse"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"x-permissions": ["overview:view"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/admin/databi/social/kpi": {
|
|
||||||
"get": {
|
|
||||||
"operationId": "getSocialBiKpi",
|
|
||||||
"x-permission": "overview:view",
|
|
||||||
"parameters": [
|
|
||||||
{ "in": "query", "name": "stat_tz", "schema": { "type": "string" } },
|
|
||||||
{ "in": "query", "name": "start_ms", "schema": { "type": "integer", "format": "int64" } },
|
|
||||||
{ "in": "query", "name": "end_ms", "schema": { "type": "integer", "format": "int64" } },
|
|
||||||
{ "in": "query", "name": "period_month", "schema": { "type": "string" } },
|
|
||||||
{ "in": "query", "name": "app_codes", "schema": { "type": "string" } },
|
|
||||||
{ "in": "query", "name": "operator_user_id", "schema": { "type": "integer", "format": "int64" } }
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"$ref": "#/components/responses/StatisticsObjectResponse"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"x-permissions": ["overview:view"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/admin/finance/scope": {
|
"/admin/finance/scope": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getFinanceScope",
|
"operationId": "getFinanceScope",
|
||||||
@ -4188,21 +3778,6 @@
|
|||||||
"x-permissions": ["resource:create"]
|
"x-permissions": ["resource:create"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/admin/resources/batch-delete": {
|
|
||||||
"post": {
|
|
||||||
"operationId": "batchDeleteResources",
|
|
||||||
"requestBody": {
|
|
||||||
"$ref": "#/components/requestBodies/BatchDeleteResourcesRequest"
|
|
||||||
},
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"$ref": "#/components/responses/EmptyResponse"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"x-permission": "resource:delete",
|
|
||||||
"x-permissions": ["resource:delete"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/admin/resources/{resource_id}": {
|
"/admin/resources/{resource_id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getResource",
|
"operationId": "getResource",
|
||||||
@ -4243,26 +3818,6 @@
|
|||||||
],
|
],
|
||||||
"x-permission": "resource:update",
|
"x-permission": "resource:update",
|
||||||
"x-permissions": ["resource:update"]
|
"x-permissions": ["resource:update"]
|
||||||
},
|
|
||||||
"delete": {
|
|
||||||
"operationId": "deleteResource",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"$ref": "#/components/responses/EmptyResponse"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"in": "path",
|
|
||||||
"name": "resource_id",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"type": "integer"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"x-permission": "resource:delete",
|
|
||||||
"x-permissions": ["resource:delete"]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/admin/resources/{resource_id}/disable": {
|
"/admin/resources/{resource_id}/disable": {
|
||||||
@ -5953,42 +5508,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"requestBodies": {
|
"requestBodies": {
|
||||||
"BatchCreateGiftsRequest": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"type": "object",
|
|
||||||
"required": ["items"],
|
|
||||||
"properties": {
|
|
||||||
"items": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "object"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"BatchDeleteResourcesRequest": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"type": "object",
|
|
||||||
"required": ["resourceIds"],
|
|
||||||
"properties": {
|
|
||||||
"resourceIds": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "integer"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"BatchStatusRequest": {
|
"BatchStatusRequest": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
@ -7308,36 +6827,6 @@
|
|||||||
"bdLeaderCount": {
|
"bdLeaderCount": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"canAddAdmin": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"canAddBdLeader": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"canAddSuperadmin": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"canBlockUser": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"canGrantAvatarFrame": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"canGrantBadge": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"canGrantVehicle": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"canGrantVip": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"canTransferUserCountry": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"canUpdateUserLevel": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"createdAtMs": {
|
"createdAtMs": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
@ -7375,36 +6864,6 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["targetUserId"],
|
"required": ["targetUserId"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"canAddAdmin": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"canAddBdLeader": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"canAddSuperadmin": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"canBlockUser": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"canGrantAvatarFrame": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"canGrantBadge": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"canGrantVehicle": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"canGrantVip": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"canTransferUserCountry": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"canUpdateUserLevel": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"contact": {
|
"contact": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"maxLength": 128
|
"maxLength": 128
|
||||||
@ -7414,49 +6873,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"UpdateManagerPayload": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"canAddAdmin": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"canAddBdLeader": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"canAddSuperadmin": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"canBlockUser": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"canGrantAvatarFrame": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"canGrantBadge": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"canGrantVehicle": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"canGrantVip": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"canTransferUserCountry": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"canUpdateUserLevel": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"contact": {
|
|
||||||
"type": "string",
|
|
||||||
"maxLength": 128
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["active", "disabled"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"BDProfile": {
|
"BDProfile": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["userId"],
|
"required": ["userId"],
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>运营数据总览</title>
|
<title>HYApp Social BI</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="databi-root"></div>
|
<div id="databi-root"></div>
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import { ReportOverview } from "./components/ReportOverview.jsx";
|
|||||||
import { EmptyReportSection, ReportSidebar } from "./components/ReportSidebar.jsx";
|
import { EmptyReportSection, ReportSidebar } from "./components/ReportSidebar.jsx";
|
||||||
import { RevenueTrendPanel } from "./components/RevenueTrendPanel.jsx";
|
import { RevenueTrendPanel } from "./components/RevenueTrendPanel.jsx";
|
||||||
import { SelfGameScreen } from "./components/SelfGameScreen.jsx";
|
import { SelfGameScreen } from "./components/SelfGameScreen.jsx";
|
||||||
import { SocialBiApp } from "./social/SocialBiApp.jsx";
|
import { SocialBiDashboard } from "./components/SocialBiDashboard.jsx";
|
||||||
import { createDashboardModel } from "./data/createDashboardModel.js";
|
import { createDashboardModel } from "./data/createDashboardModel.js";
|
||||||
import { lastDaysRange, rangeEndMs, rangeStartMs, readStoredTimeZone, sameRange, thisMonthRange, TIME_ZONE_OPTIONS, todayRange, writeStoredTimeZone } from "./utils/time.js";
|
import { lastDaysRange, rangeEndMs, rangeStartMs, readStoredTimeZone, sameRange, thisMonthRange, TIME_ZONE_OPTIONS, todayRange, writeStoredTimeZone } from "./utils/time.js";
|
||||||
|
|
||||||
@ -23,7 +23,7 @@ const initialFilterOptions = {
|
|||||||
|
|
||||||
export function DatabiApp() {
|
export function DatabiApp() {
|
||||||
if (isSocialBiPath()) {
|
if (isSocialBiPath()) {
|
||||||
return <SocialBiApp />;
|
return <SocialBiDashboard />;
|
||||||
}
|
}
|
||||||
return <DatabiOverviewApp />;
|
return <DatabiOverviewApp />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,23 +1,11 @@
|
|||||||
import { act, render, screen, within } from "@testing-library/react";
|
import { act, render, screen, within } from "@testing-library/react";
|
||||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||||
import { DatabiApp } from "./DatabiApp.jsx";
|
import { DatabiApp } from "./DatabiApp.jsx";
|
||||||
import {
|
import { fetchFilterOptions, fetchSelfGameStatisticsOverview, fetchSocialBiFilterOptions, fetchStatisticsOverview } from "./api.js";
|
||||||
fetchFilterOptions,
|
|
||||||
fetchSelfGameStatisticsOverview,
|
|
||||||
fetchSocialBiFunnel,
|
|
||||||
fetchSocialBiKpi,
|
|
||||||
fetchSocialBiMaster,
|
|
||||||
fetchSocialBiOverview,
|
|
||||||
fetchStatisticsOverview
|
|
||||||
} from "./api.js";
|
|
||||||
|
|
||||||
vi.mock("./api.js", () => ({
|
vi.mock("./api.js", () => ({
|
||||||
fetchFilterOptions: vi.fn(),
|
fetchFilterOptions: vi.fn(),
|
||||||
fetchSocialBiFilterOptions: vi.fn(),
|
fetchSocialBiFilterOptions: vi.fn(),
|
||||||
fetchSocialBiFunnel: vi.fn(),
|
|
||||||
fetchSocialBiKpi: vi.fn(),
|
|
||||||
fetchSocialBiMaster: vi.fn(),
|
|
||||||
fetchSocialBiOverview: vi.fn(),
|
|
||||||
fetchSelfGameStatisticsOverview: vi.fn(),
|
fetchSelfGameStatisticsOverview: vi.fn(),
|
||||||
fetchStatisticsOverview: vi.fn(),
|
fetchStatisticsOverview: vi.fn(),
|
||||||
getCurrentAppCode: vi.fn(() => "lalu"),
|
getCurrentAppCode: vi.fn(() => "lalu"),
|
||||||
@ -48,16 +36,15 @@ beforeEach(() => {
|
|||||||
regionOptions: [{ countryCodes: [], id: "all", label: "全部区域", value: "all" }]
|
regionOptions: [{ countryCodes: [], id: "all", label: "全部区域", value: "all" }]
|
||||||
});
|
});
|
||||||
fetchSelfGameStatisticsOverview.mockResolvedValue({});
|
fetchSelfGameStatisticsOverview.mockResolvedValue({});
|
||||||
fetchSocialBiMaster.mockResolvedValue({
|
fetchSocialBiFilterOptions.mockResolvedValue({
|
||||||
access: { all: true, scopes: [] },
|
appOptions: [
|
||||||
apps: [],
|
{ label: "全部 App", value: "all" },
|
||||||
operators: [],
|
{ label: "Lalu", value: "lalu" },
|
||||||
permissions: {},
|
{ label: "Aslan", value: "aslan" },
|
||||||
regions: []
|
{ label: "Yumi", value: "yumi" }
|
||||||
|
],
|
||||||
|
operatorOptions: [{ label: "全部人员", searchText: "all 全部人员", value: "all" }]
|
||||||
});
|
});
|
||||||
fetchSocialBiOverview.mockResolvedValue({ access: { all: true, scopes: [] }, apps: [] });
|
|
||||||
fetchSocialBiFunnel.mockResolvedValue({ access: { all: true, scopes: [] }, apps: [] });
|
|
||||||
fetchSocialBiKpi.mockResolvedValue({ items: [], period_month: "2026-06", summary: {} });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@ -225,222 +212,65 @@ test("renders real-room robot gift cards in a separate row", async () => {
|
|||||||
|
|
||||||
test("routes databi social page to Social BI and loads real overview rows", async () => {
|
test("routes databi social page to Social BI and loads real overview rows", async () => {
|
||||||
window.history.pushState(null, "", "/databi/social/");
|
window.history.pushState(null, "", "/databi/social/");
|
||||||
const laluTotal = {
|
fetchStatisticsOverview.mockImplementation(async ({ appCode }) => ({
|
||||||
active_users: 20,
|
active_users: appCode === "lalu" ? 20 : 10,
|
||||||
|
app_name: appCode === "lalu" ? "Lalu" : appCode === "aslan" ? "Aslan" : "Yumi",
|
||||||
arpu_usd_minor: 410,
|
arpu_usd_minor: 410,
|
||||||
arppu_usd_minor: 615,
|
arppu_usd_minor: appCode === "aslan" ? null : 615,
|
||||||
avg_mic_online_ms: 120_000,
|
avg_mic_online_ms: 120_000,
|
||||||
|
coin_seller_recharge_usd_minor: 3400,
|
||||||
|
coin_seller_stock_coin: 400_000,
|
||||||
|
coin_seller_transfer_coin: 160_000,
|
||||||
|
coin_total: appCode === "aslan" ? 5000 : null,
|
||||||
consumed_coin: 900_000,
|
consumed_coin: 900_000,
|
||||||
consume_output_delta: 450_000,
|
consume_output_delta: 450_000,
|
||||||
consume_output_ratio: 0.5,
|
consume_output_ratio: 0.5,
|
||||||
d1_retention_rate: 0.25,
|
country_breakdown: [],
|
||||||
game_profit_rate: 0.17,
|
game_profit_rate: 0.17,
|
||||||
game_turnover: 300_000,
|
game_turnover: 300_000,
|
||||||
gift_coin_spent: 123_456,
|
gift_coin_spent: 123_456,
|
||||||
google_recharge_usd_minor: 700,
|
google_recharge_usd_minor: 700,
|
||||||
|
lucky_gift_profit_rate: 0.25,
|
||||||
|
lucky_gift_turnover: 200_000,
|
||||||
|
manual_grant_coin: 222,
|
||||||
|
mic_online_ms: 3_600_000,
|
||||||
mifapay_recharge_usd_minor: 800,
|
mifapay_recharge_usd_minor: 800,
|
||||||
new_users: 3,
|
new_user_recharge_usd_minor: 1200,
|
||||||
|
new_users: appCode === "lalu" ? 3 : 1,
|
||||||
output_coin: 450_000,
|
output_coin: 450_000,
|
||||||
paid_conversion_rate: 0.2,
|
paid_conversion_rate: 0.2,
|
||||||
paid_users: 4,
|
paid_users: 4,
|
||||||
recharge_usd_minor: 12_300,
|
platform_grant_coin: 111,
|
||||||
recharge_users: 4
|
recharge_usd_minor: appCode === "lalu" ? 12300 : 5000,
|
||||||
};
|
recharge_conversion_rate: 0.2,
|
||||||
const aslanRegionRow = {
|
recharge_users: 4,
|
||||||
active_users: 10,
|
retention: { day1_rate: 0.25, day7_rate: 0.1, day30_rate: 0.05 },
|
||||||
new_users: 1,
|
salary_transfer_coin: 88_000,
|
||||||
paid_users: 2,
|
salary_usd_minor: appCode === "aslan" ? 2500 : null,
|
||||||
recharge_usd_minor: 5000,
|
super_lucky_gift_profit_rate: 0.08,
|
||||||
recharge_users: 2,
|
super_lucky_gift_turnover: 50_000
|
||||||
region_code: "MENA",
|
}));
|
||||||
region_id: 11,
|
|
||||||
region_name: "中东大区"
|
|
||||||
};
|
|
||||||
fetchSocialBiMaster.mockResolvedValue({
|
|
||||||
access: { all: true, scopes: [] },
|
|
||||||
apps: [
|
|
||||||
{ app_code: "lalu", app_name: "Lalu", kind: "hyapp" },
|
|
||||||
{ app_code: "aslan", app_name: "Aslan", kind: "legacy" }
|
|
||||||
],
|
|
||||||
operators: [
|
|
||||||
{ account: "omar", name: "Omar", scopes: [{ app_code: "aslan", region_id: 11 }], team: "中东运营部", team_id: 2, user_id: 9 }
|
|
||||||
],
|
|
||||||
permissions: {},
|
|
||||||
regions: [{ app_code: "aslan", countries: ["SA"], region_code: "MENA", region_id: 11, region_name: "中东大区" }]
|
|
||||||
});
|
|
||||||
fetchSocialBiOverview.mockResolvedValue({
|
|
||||||
access: { all: true, scopes: [] },
|
|
||||||
apps: [
|
|
||||||
{
|
|
||||||
app_code: "lalu",
|
|
||||||
app_name: "Lalu",
|
|
||||||
country_breakdown: [],
|
|
||||||
daily_country_breakdown: [],
|
|
||||||
daily_region_breakdown: [],
|
|
||||||
daily_series: [],
|
|
||||||
kind: "hyapp",
|
|
||||||
region_breakdown: [],
|
|
||||||
total: laluTotal
|
|
||||||
},
|
|
||||||
{
|
|
||||||
app_code: "aslan",
|
|
||||||
app_name: "Aslan",
|
|
||||||
country_breakdown: [],
|
|
||||||
daily_country_breakdown: [],
|
|
||||||
daily_region_breakdown: [],
|
|
||||||
daily_series: [],
|
|
||||||
kind: "legacy",
|
|
||||||
region_breakdown: [aslanRegionRow],
|
|
||||||
total: { active_users: 10, new_users: 1, paid_users: 2, recharge_usd_minor: 5000, recharge_users: 2 }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
fetchSocialBiKpi.mockResolvedValue({
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
app_code: "aslan",
|
|
||||||
app_name: "Aslan",
|
|
||||||
month_recharge_usd_minor: 90_000,
|
|
||||||
operator_account: "omar",
|
|
||||||
operator_name: "Omar",
|
|
||||||
operator_user_id: 9,
|
|
||||||
range_new_users: 1,
|
|
||||||
range_recharge_usd_minor: 5000,
|
|
||||||
region_id: 11,
|
|
||||||
region_name: "中东大区",
|
|
||||||
team: "中东运营部",
|
|
||||||
team_id: 2
|
|
||||||
}
|
|
||||||
],
|
|
||||||
period_month: "2026-06",
|
|
||||||
summary: { month_recharge_usd_minor: 90_000, operator_count: 1, range_recharge_usd_minor: 5000 }
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<DatabiApp />);
|
render(<DatabiApp />);
|
||||||
|
|
||||||
await flushEffects();
|
await flushEffects();
|
||||||
|
|
||||||
// v2 Shell:主视图容器 + 侧栏视图 tab,默认选中“经营概览”。
|
|
||||||
expect(screen.getByLabelText("Social BI 数据中心")).toBeTruthy();
|
expect(screen.getByLabelText("Social BI 数据中心")).toBeTruthy();
|
||||||
const viewTabs = screen.getByRole("tablist", { name: "数据视图" });
|
expect(screen.queryByText("App 数据明细")).toBeNull();
|
||||||
["经营概览", "地区洞察", "运营中心", "留存质量", "数据明细", "埋点漏斗"].forEach((name) => {
|
expect(fetchSocialBiFilterOptions).toHaveBeenCalledTimes(1);
|
||||||
expect(within(viewTabs).getByRole("tab", { name })).toBeTruthy();
|
expect(fetchStatisticsOverview).toHaveBeenCalledWith(expect.objectContaining({ appCode: "lalu", statTz: "Asia/Shanghai" }));
|
||||||
});
|
expect(fetchStatisticsOverview).toHaveBeenCalledWith(expect.objectContaining({ appCode: "aslan", statTz: "Asia/Shanghai" }));
|
||||||
expect(within(viewTabs).getByRole("tab", { name: "经营概览" })).toHaveAttribute("aria-selected", "true");
|
expect(fetchStatisticsOverview).toHaveBeenCalledWith(expect.objectContaining({ appCode: "yumi", statTz: "Asia/Shanghai" }));
|
||||||
|
|
||||||
expect(fetchSocialBiMaster).toHaveBeenCalledTimes(1);
|
|
||||||
expect(fetchSocialBiOverview).toHaveBeenCalledWith(expect.objectContaining({ statTz: "Asia/Shanghai" }));
|
|
||||||
expect(fetchSocialBiFunnel).toHaveBeenCalledWith(expect.objectContaining({ statTz: "Asia/Shanghai" }));
|
|
||||||
expect(fetchSocialBiKpi).toHaveBeenCalledWith(expect.objectContaining({ statTz: "Asia/Shanghai" }));
|
|
||||||
|
|
||||||
// 经营概览:App 名、Hero 汇总充值(12,300 + 5,000 USD 分 = $173)、
|
|
||||||
// App 对比里 Lalu 的单 App 充值($123)、区域 Top5 里的中东大区。
|
|
||||||
expect(screen.getAllByText("Lalu").length).toBeGreaterThan(0);
|
expect(screen.getAllByText("Lalu").length).toBeGreaterThan(0);
|
||||||
expect(screen.getAllByText("充值 ($)").length).toBeGreaterThan(0);
|
|
||||||
expect(screen.getByText("付费用户")).toBeTruthy();
|
expect(screen.getByText("付费用户")).toBeTruthy();
|
||||||
expect(screen.getAllByText("$173").length).toBeGreaterThan(0);
|
expect(screen.getByText("游戏流水/利润率")).toBeTruthy();
|
||||||
|
expect(screen.getByText("消耗产出比")).toBeTruthy();
|
||||||
|
expect(screen.getByText("ARPU ($)")).toBeTruthy();
|
||||||
expect(screen.getAllByText("$123").length).toBeGreaterThan(0);
|
expect(screen.getAllByText("$123").length).toBeGreaterThan(0);
|
||||||
expect(screen.getAllByText("中东大区").length).toBeGreaterThan(0);
|
expect(screen.getAllByText("300,000").length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getAllByText("17.0%").length).toBeGreaterThan(0);
|
||||||
// 侧栏切换:地区洞察 —— 区域下钻表出现,Aslan 中东大区行充值 $50。
|
expect(screen.getAllByText("2分").length).toBeGreaterThan(0);
|
||||||
await act(async () => {
|
expect(screen.queryByText("$284,300")).toBeNull();
|
||||||
within(viewTabs).getByRole("tab", { name: "地区洞察" }).click();
|
|
||||||
});
|
|
||||||
expect(within(viewTabs).getByRole("tab", { name: "地区洞察" })).toHaveAttribute("aria-selected", "true");
|
|
||||||
expect(screen.getByText("区域下钻")).toBeTruthy();
|
|
||||||
expect(screen.getAllByText("中东大区").length).toBeGreaterThan(0);
|
|
||||||
expect(screen.getAllByText("$50").length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
// 侧栏切换:运营中心 —— 运营人员 Omar 上榜,只展示负责范围内的充值数据。
|
|
||||||
await act(async () => {
|
|
||||||
within(viewTabs).getByRole("tab", { name: "运营中心" }).click();
|
|
||||||
});
|
|
||||||
expect(within(viewTabs).getByRole("tab", { name: "运营中心" })).toHaveAttribute("aria-selected", "true");
|
|
||||||
expect(screen.getAllByText("Omar").length).toBeGreaterThan(0);
|
|
||||||
expect(screen.getAllByText("$900").length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders social BI funnel view and limits funnel apps to supported apps", async () => {
|
|
||||||
window.history.pushState(null, "", "/databi/social/?view=funnel");
|
|
||||||
fetchSocialBiMaster.mockResolvedValue({
|
|
||||||
access: { all: true, scopes: [] },
|
|
||||||
apps: [
|
|
||||||
{ app_code: "lalu", app_name: "Lalu", kind: "hyapp" },
|
|
||||||
{ app_code: "huwaa", app_name: "Huwaa", kind: "hyapp" },
|
|
||||||
{ app_code: "fami", app_name: "Fami", kind: "hyapp" },
|
|
||||||
{ app_code: "aslan", app_name: "Aslan", kind: "legacy" },
|
|
||||||
{ app_code: "yumi", app_name: "Yumi", kind: "legacy" }
|
|
||||||
],
|
|
||||||
operators: [],
|
|
||||||
permissions: {},
|
|
||||||
regions: []
|
|
||||||
});
|
|
||||||
fetchSocialBiFunnel.mockResolvedValue({
|
|
||||||
access: { all: true, scopes: [] },
|
|
||||||
apps: [
|
|
||||||
{
|
|
||||||
app_code: "lalu",
|
|
||||||
app_name: "Lalu",
|
|
||||||
cohorts: [
|
|
||||||
{
|
|
||||||
base_users: 1,
|
|
||||||
d1_retention_rate: 1,
|
|
||||||
d1_retention_users: 1,
|
|
||||||
dimension: "country",
|
|
||||||
label: "US",
|
|
||||||
steps: [
|
|
||||||
{ event_name: "login_success", user_count: 1 },
|
|
||||||
{ event_name: "profile_complete", user_count: 1 },
|
|
||||||
{ event_name: "room_join_success", user_count: 1 },
|
|
||||||
{ event_name: "stay_3m", user_count: 1 },
|
|
||||||
{ event_name: "send_message", user_count: 1 }
|
|
||||||
],
|
|
||||||
value: "US"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
kind: "hyapp",
|
|
||||||
steps: [
|
|
||||||
{ event_name: "login_start", event_count: 2, label: "登录开始", user_count: 2 },
|
|
||||||
{ event_name: "login_success", event_count: 1, label: "登录成功", user_count: 1 },
|
|
||||||
{ event_name: "room_join_success", event_count: 1, label: "进房成功", user_count: 1 },
|
|
||||||
{ event_name: "room_join_fail", event_count: 1, is_failure: true, label: "进房失败", user_count: 1 },
|
|
||||||
{ event_name: "stay_3m", event_count: 1, label: "停留 3 分钟", user_count: 1 },
|
|
||||||
{ event_name: "send_message", event_count: 1, label: "发消息", user_count: 1 }
|
|
||||||
],
|
|
||||||
totals: {
|
|
||||||
d1_retention_base_users: 1,
|
|
||||||
d1_retention_rate: 1,
|
|
||||||
d1_retention_users: 1,
|
|
||||||
login_start_users: 2,
|
|
||||||
login_success_users: 1,
|
|
||||||
room_join_fail_users: 1,
|
|
||||||
room_join_success_users: 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<DatabiApp />);
|
|
||||||
|
|
||||||
await flushEffects();
|
|
||||||
|
|
||||||
expect(screen.getByRole("tab", { name: "埋点漏斗" })).toHaveAttribute("aria-selected", "true");
|
|
||||||
expect(screen.getByRole("button", { name: "全部漏斗 App" })).toBeTruthy();
|
|
||||||
expect(screen.getByRole("button", { name: /Lalu/ })).toBeTruthy();
|
|
||||||
expect(screen.getByRole("button", { name: /Huwaa/ })).toBeTruthy();
|
|
||||||
expect(screen.getByRole("button", { name: /Fami/ })).toBeTruthy();
|
|
||||||
expect(screen.queryByRole("button", { name: /Aslan/ })).toBeNull();
|
|
||||||
expect(screen.queryByRole("button", { name: /Yumi/ })).toBeNull();
|
|
||||||
expect(fetchSocialBiFunnel).toHaveBeenCalledWith(expect.objectContaining({ appCodes: ["lalu", "huwaa", "fami"] }));
|
|
||||||
|
|
||||||
expect(screen.getByText("主路径转化")).toBeTruthy();
|
|
||||||
expect(screen.getByText("事件明细")).toBeTruthy();
|
|
||||||
expect(screen.getByText("D1 Cohort")).toBeTruthy();
|
|
||||||
expect(screen.getByText("login_start")).toBeTruthy();
|
|
||||||
expect(screen.getByText("room_join_fail")).toBeTruthy();
|
|
||||||
expect(screen.getByText("US")).toBeTruthy();
|
|
||||||
expect(screen.getAllByText("100.0%").length).toBeGreaterThan(0);
|
|
||||||
expect(screen.getByTestId("databi-chart")).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
async function flushEffects() {
|
async function flushEffects() {
|
||||||
|
|||||||
@ -155,86 +155,11 @@ export async function fetchSocialBiFilterOptions({ appCode } = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 社交 BI 主数据:按当前登录用户的数据范围返回可见 App、区域目录、运营人员及权限开关。
|
async function fetchDatabiData(path, { appCode, query } = {}) {
|
||||||
export async function fetchSocialBiMaster() {
|
const response = await fetch(buildURL(path, query), {
|
||||||
return fetchDatabiData(apiEndpointPath(API_OPERATIONS.getSocialBiMaster));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchSocialBiOverview({ appCodes, endMs, regionId, startMs, statTz }) {
|
|
||||||
const query = {};
|
|
||||||
if (statTz) {
|
|
||||||
query.stat_tz = statTz;
|
|
||||||
}
|
|
||||||
if (startMs) {
|
|
||||||
query.start_ms = String(startMs);
|
|
||||||
}
|
|
||||||
if (endMs) {
|
|
||||||
query.end_ms = String(endMs);
|
|
||||||
}
|
|
||||||
if (appCodes?.length) {
|
|
||||||
query.app_codes = appCodes.join(",");
|
|
||||||
}
|
|
||||||
if (regionId) {
|
|
||||||
query.region_id = String(regionId);
|
|
||||||
}
|
|
||||||
return fetchDatabiData(apiEndpointPath(API_OPERATIONS.getSocialBiOverview), { query });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchSocialBiFunnel({ appCodes, endMs, regionId, startMs, statTz }) {
|
|
||||||
const query = {};
|
|
||||||
if (statTz) {
|
|
||||||
query.stat_tz = statTz;
|
|
||||||
}
|
|
||||||
if (startMs) {
|
|
||||||
query.start_ms = String(startMs);
|
|
||||||
}
|
|
||||||
if (endMs) {
|
|
||||||
query.end_ms = String(endMs);
|
|
||||||
}
|
|
||||||
if (appCodes?.length) {
|
|
||||||
query.app_codes = appCodes.join(",");
|
|
||||||
}
|
|
||||||
if (regionId) {
|
|
||||||
query.region_id = String(regionId);
|
|
||||||
}
|
|
||||||
return fetchDatabiData(apiEndpointPath(API_OPERATIONS.getSocialBiFunnel), { query });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchSocialBiKpi({ appCodes, endMs, operatorUserId, periodMonth, startMs, statTz }) {
|
|
||||||
const query = {};
|
|
||||||
if (statTz) {
|
|
||||||
query.stat_tz = statTz;
|
|
||||||
}
|
|
||||||
if (startMs) {
|
|
||||||
query.start_ms = String(startMs);
|
|
||||||
}
|
|
||||||
if (endMs) {
|
|
||||||
query.end_ms = String(endMs);
|
|
||||||
}
|
|
||||||
if (periodMonth) {
|
|
||||||
query.period_month = periodMonth;
|
|
||||||
}
|
|
||||||
if (appCodes?.length) {
|
|
||||||
query.app_codes = appCodes.join(",");
|
|
||||||
}
|
|
||||||
if (operatorUserId) {
|
|
||||||
query.operator_user_id = String(operatorUserId);
|
|
||||||
}
|
|
||||||
return fetchDatabiData(apiEndpointPath(API_OPERATIONS.getSocialBiKpi), { query });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchDatabiData(path, { appCode, body, method, query } = {}) {
|
|
||||||
const headers = requestHeaders(appCode);
|
|
||||||
const init = {
|
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers,
|
headers: requestHeaders(appCode)
|
||||||
method: method || "GET"
|
});
|
||||||
};
|
|
||||||
if (body !== undefined) {
|
|
||||||
headers["Content-Type"] = "application/json";
|
|
||||||
init.body = JSON.stringify(body);
|
|
||||||
}
|
|
||||||
const response = await fetch(buildURL(path, query), init);
|
|
||||||
const payload = await readJSON(response);
|
const payload = await readJSON(response);
|
||||||
if (isAuthExpired(response, payload)) {
|
if (isAuthExpired(response, payload)) {
|
||||||
redirectToLogin(window);
|
redirectToLogin(window);
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { BarChart, EffectScatterChart, FunnelChart, LineChart, LinesChart, MapChart, PieChart, ScatterChart } from "echarts/charts";
|
import { BarChart, EffectScatterChart, FunnelChart, LineChart, LinesChart, MapChart, PieChart, ScatterChart } from "echarts/charts";
|
||||||
import { GeoComponent, GridComponent, LegendComponent, TooltipComponent, VisualMapComponent } from "echarts/components";
|
import { GeoComponent, GridComponent, LegendComponent, TooltipComponent } from "echarts/components";
|
||||||
import * as echarts from "echarts/core";
|
import * as echarts from "echarts/core";
|
||||||
import { CanvasRenderer } from "echarts/renderers";
|
import { CanvasRenderer } from "echarts/renderers";
|
||||||
import worldMap from "../data/world.json";
|
import worldMap from "../data/world.json";
|
||||||
|
|
||||||
echarts.use([BarChart, EffectScatterChart, FunnelChart, GeoComponent, GridComponent, LegendComponent, LineChart, LinesChart, MapChart, PieChart, ScatterChart, TooltipComponent, VisualMapComponent, CanvasRenderer]);
|
echarts.use([BarChart, EffectScatterChart, FunnelChart, GeoComponent, GridComponent, LegendComponent, LineChart, LinesChart, MapChart, PieChart, ScatterChart, TooltipComponent, CanvasRenderer]);
|
||||||
echarts.registerMap("databi-world", worldMap);
|
echarts.registerMap("databi-world", worldMap);
|
||||||
|
|
||||||
export function EChart({ className = "", option }) {
|
export function EChart({ className = "", option }) {
|
||||||
|
|||||||
1230
databi/src/components/SocialBiDashboard.jsx
Normal file
1230
databi/src/components/SocialBiDashboard.jsx
Normal file
File diff suppressed because it is too large
Load Diff
505
databi/src/data/socialBiDashboard.js
Normal file
505
databi/src/data/socialBiDashboard.js
Normal file
@ -0,0 +1,505 @@
|
|||||||
|
export const ALL_VALUE = "all";
|
||||||
|
|
||||||
|
export const socialBiAppOptions = [
|
||||||
|
{ label: "全部 App", value: ALL_VALUE },
|
||||||
|
{ label: "Lalu", value: "lalu" },
|
||||||
|
{ label: "Aslan", value: "aslan" },
|
||||||
|
{ label: "Yumi", value: "yumi" }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const socialBiOperatorOptions = [
|
||||||
|
{ label: "全部人员", searchText: "all", value: ALL_VALUE },
|
||||||
|
{ label: "Ava Chen", searchText: "ava chen 陈", value: "ava" },
|
||||||
|
{ label: "Nora Li", searchText: "nora li 李", value: "nora" },
|
||||||
|
{ label: "Omar Saleh", searchText: "omar saleh", value: "omar" },
|
||||||
|
{ label: "Lina Gomez", searchText: "lina gomez", value: "lina" }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const socialBiDepartmentOptions = [
|
||||||
|
{ label: "全部部门", value: ALL_VALUE },
|
||||||
|
{ label: "拉美运营一部", value: "latam-ops-1" },
|
||||||
|
{ label: "中东运营部", value: "mena-ops" },
|
||||||
|
{ label: "泛娱乐增长组", value: "growth" }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const socialBiRegionOptions = [
|
||||||
|
{
|
||||||
|
children: [
|
||||||
|
{ label: "巴西", value: "br" },
|
||||||
|
{ label: "墨西哥", value: "mx" },
|
||||||
|
{ label: "哥伦比亚", value: "co" }
|
||||||
|
],
|
||||||
|
label: "拉美大区",
|
||||||
|
value: "latam"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
children: [
|
||||||
|
{ label: "沙特", value: "sa" },
|
||||||
|
{ label: "阿联酋", value: "ae" },
|
||||||
|
{ label: "埃及", value: "eg" }
|
||||||
|
],
|
||||||
|
label: "中东大区",
|
||||||
|
value: "mena"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
children: [
|
||||||
|
{ label: "印度尼西亚", value: "id" },
|
||||||
|
{ label: "菲律宾", value: "ph" }
|
||||||
|
],
|
||||||
|
label: "东南亚大区",
|
||||||
|
value: "sea"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const socialBiFilterDefinitions = {
|
||||||
|
apps: { label: "App", options: socialBiAppOptions, type: "multi" },
|
||||||
|
dateRange: { label: "日期区间", type: "dateRange" },
|
||||||
|
departments: { label: "运营部门", options: socialBiDepartmentOptions, type: "multi" },
|
||||||
|
operators: { label: "运营人员", options: socialBiOperatorOptions, placeholder: "搜索员工姓名", type: "searchMulti" },
|
||||||
|
regions: { label: "地区", options: socialBiRegionOptions, type: "cascade" }
|
||||||
|
};
|
||||||
|
|
||||||
|
export const socialBiSections = [
|
||||||
|
{
|
||||||
|
cards: [
|
||||||
|
{ delta: 8.4, key: "recharge", label: "总充值 ($)", type: "money" },
|
||||||
|
{ delta: -2.6, key: "newUsers", label: "总新增人数", type: "number" },
|
||||||
|
{ delta: 3.8, key: "dau", label: "总日活 (DAU)", type: "number" },
|
||||||
|
{ delta: 1.7, key: "arppu", label: "综合 ARPPU ($)", type: "money" }
|
||||||
|
],
|
||||||
|
columns: [
|
||||||
|
textColumn("date", "日期"),
|
||||||
|
textColumn("appName", "App名称"),
|
||||||
|
numberColumn("newUsers", "新增"),
|
||||||
|
numberColumn("dau", "日活"),
|
||||||
|
numberColumn("paidUsers", "付费用户"),
|
||||||
|
numberColumn("rechargeUsers", "充值用户"),
|
||||||
|
moneyColumn("recharge", "充值 ($)"),
|
||||||
|
moneyColumn("newUserRecharge", "新用户充值"),
|
||||||
|
moneyColumn("coinSellerRecharge", "币商充值"),
|
||||||
|
numberColumn("coinSellerStockCoin", "币商充值金币"),
|
||||||
|
numberColumn("coinSellerTransferCoin", "币商出货金币"),
|
||||||
|
moneyColumn("googleRecharge", "谷歌充值"),
|
||||||
|
moneyColumn("thirdPartyRecharge", "三方充值"),
|
||||||
|
flowRateColumn("gameFlowProfit", "游戏流水/利润率"),
|
||||||
|
flowRateColumn("luckyGiftFlowProfit", "幸运礼物流水/利润率"),
|
||||||
|
flowRateColumn("superLuckyGiftFlowProfit", "超级幸运礼物流水/利润率"),
|
||||||
|
numberColumn("giftCoinSpent", "礼物流水"),
|
||||||
|
numberColumn("coins", "金币数量"),
|
||||||
|
numberColumn("consumedCoin", "消耗金币"),
|
||||||
|
numberColumn("outputCoin", "产出金币"),
|
||||||
|
consumeOutputColumn("consumeOutputRatio", "消耗产出比"),
|
||||||
|
numberColumn("platformGrantCoin", "平台发放金币"),
|
||||||
|
numberColumn("manualGrantCoin", "人工发放金币"),
|
||||||
|
moneyColumn("salary", "存量工资 ($)"),
|
||||||
|
numberColumn("salaryTransferCoin", "工资兑换金币"),
|
||||||
|
durationColumn("avgMicOnlineMs", "平均麦上时间", "average"),
|
||||||
|
durationColumn("micOnlineMs", "麦上总时长"),
|
||||||
|
moneyColumn("arpu", "ARPU ($)", "average"),
|
||||||
|
moneyColumn("arppu", "ARPPU ($)", "average"),
|
||||||
|
percentColumn("paidConversionRate", "付费转化率"),
|
||||||
|
percentColumn("rechargeConversionRate", "充值转化率"),
|
||||||
|
moneyColumn("turnover", "App流水 ($)"),
|
||||||
|
percentColumn("retentionD1", "次日留存"),
|
||||||
|
percentColumn("retentionD7", "7日留存"),
|
||||||
|
percentColumn("retentionD30", "30日留存")
|
||||||
|
],
|
||||||
|
filters: ["dateRange", "apps", "operators"],
|
||||||
|
key: "appData",
|
||||||
|
label: "App 数据",
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
appName: "Lalu",
|
||||||
|
arppu: 42.7,
|
||||||
|
coins: 49120000,
|
||||||
|
date: "2026-06-30",
|
||||||
|
dau: 18520,
|
||||||
|
id: "app-lalu",
|
||||||
|
newUsers: 843,
|
||||||
|
recharge: 284300,
|
||||||
|
retentionD1: 31.6,
|
||||||
|
retentionD7: 18.4,
|
||||||
|
retentionD30: 9.8,
|
||||||
|
salary: 62310,
|
||||||
|
turnover: 493820
|
||||||
|
},
|
||||||
|
{
|
||||||
|
appName: "Aslan",
|
||||||
|
arppu: 39.4,
|
||||||
|
coins: 32760000,
|
||||||
|
date: "2026-06-30",
|
||||||
|
dau: 12740,
|
||||||
|
id: "app-aslan",
|
||||||
|
newUsers: 612,
|
||||||
|
recharge: 196740,
|
||||||
|
retentionD1: 29.2,
|
||||||
|
retentionD7: 16.1,
|
||||||
|
retentionD30: 8.7,
|
||||||
|
salary: 42180,
|
||||||
|
turnover: 338560
|
||||||
|
},
|
||||||
|
{
|
||||||
|
appName: "Yumi",
|
||||||
|
arppu: 36.2,
|
||||||
|
coins: 24890000,
|
||||||
|
date: "2026-06-30",
|
||||||
|
dau: 9240,
|
||||||
|
id: "app-yumi",
|
||||||
|
newUsers: 438,
|
||||||
|
recharge: 142860,
|
||||||
|
retentionD1: 27.9,
|
||||||
|
retentionD7: 15.5,
|
||||||
|
retentionD30: 7.9,
|
||||||
|
salary: 31860,
|
||||||
|
turnover: 251420
|
||||||
|
}
|
||||||
|
],
|
||||||
|
title: "App 数据"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cards: [
|
||||||
|
{ delta: 7.3, key: "recharge", label: "部门总充值 ($)", type: "money" },
|
||||||
|
{ delta: 4.8, key: "turnover", label: "部门总流水 ($)", type: "money" },
|
||||||
|
{ delta: -1.8, key: "newUsers", label: "部门总新增", type: "number" },
|
||||||
|
{ delta: 2.9, key: "dau", label: "部门总日活 (DAU)", type: "number" }
|
||||||
|
],
|
||||||
|
columns: [
|
||||||
|
textColumn("date", "日期"),
|
||||||
|
textColumn("department", "部门"),
|
||||||
|
textColumn("appName", "App名称"),
|
||||||
|
textColumn("region", "负责地区"),
|
||||||
|
moneyColumn("recharge", "充值 ($)"),
|
||||||
|
numberColumn("newUsers", "新增"),
|
||||||
|
progressColumn("dayProgress", "当日目标完成进度"),
|
||||||
|
progressColumn("monthProgress", "当月目标完成进度")
|
||||||
|
],
|
||||||
|
filters: ["dateRange", "apps", "departments"],
|
||||||
|
key: "department",
|
||||||
|
label: "部门看板",
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
appName: "Lalu",
|
||||||
|
date: "2026-06-30",
|
||||||
|
dau: 14220,
|
||||||
|
dayProgress: progress(126800, 140000),
|
||||||
|
department: "拉美运营一部",
|
||||||
|
id: "dept-latam",
|
||||||
|
monthProgress: progress(3020000, 3600000),
|
||||||
|
newUsers: 516,
|
||||||
|
recharge: 126800,
|
||||||
|
region: "拉美大区",
|
||||||
|
turnover: 225400
|
||||||
|
},
|
||||||
|
{
|
||||||
|
appName: "Aslan",
|
||||||
|
date: "2026-06-30",
|
||||||
|
dau: 10540,
|
||||||
|
dayProgress: progress(96800, 90000),
|
||||||
|
department: "中东运营部",
|
||||||
|
id: "dept-mena",
|
||||||
|
monthProgress: progress(2416000, 2700000),
|
||||||
|
newUsers: 452,
|
||||||
|
recharge: 96800,
|
||||||
|
region: "中东大区",
|
||||||
|
turnover: 164300
|
||||||
|
},
|
||||||
|
{
|
||||||
|
appName: "Yumi",
|
||||||
|
date: "2026-06-30",
|
||||||
|
dau: 8160,
|
||||||
|
dayProgress: noGoal(),
|
||||||
|
department: "泛娱乐增长组",
|
||||||
|
id: "dept-growth",
|
||||||
|
monthProgress: progress(1682000, 2100000),
|
||||||
|
newUsers: 372,
|
||||||
|
recharge: 74200,
|
||||||
|
region: "东南亚大区",
|
||||||
|
turnover: 118900
|
||||||
|
}
|
||||||
|
],
|
||||||
|
title: "部门看板"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cards: [
|
||||||
|
{ delta: 6.5, key: "recharge", label: "人员充值合计 ($)", type: "money" },
|
||||||
|
{ delta: 4.4, key: "turnover", label: "人员流水合计 ($)", type: "money" },
|
||||||
|
{ delta: 2.1, key: "newUsers", label: "新增人数", type: "number" },
|
||||||
|
{ delta: -3.2, key: "monthKpiRate", label: "当月充值 KPI 达成率 (%)", type: "percent" }
|
||||||
|
],
|
||||||
|
columns: [
|
||||||
|
textColumn("date", "日期"),
|
||||||
|
textColumn("operator", "运营人员"),
|
||||||
|
textColumn("appName", "App名称"),
|
||||||
|
textColumn("region", "所管地区"),
|
||||||
|
moneyColumn("recharge", "充值 ($)"),
|
||||||
|
numberColumn("newUsers", "新增"),
|
||||||
|
progressColumn("dayProgress", "当日目标完成进度"),
|
||||||
|
progressColumn("monthProgress", "当月目标完成进度")
|
||||||
|
],
|
||||||
|
filters: ["dateRange", "apps", "operators"],
|
||||||
|
key: "personnel",
|
||||||
|
label: "人员绩效",
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
appName: "Lalu",
|
||||||
|
date: "2026-06-30",
|
||||||
|
dayProgress: progress(52800, 50000),
|
||||||
|
id: "person-ava",
|
||||||
|
monthKpiRate: 88.4,
|
||||||
|
monthProgress: progress(1182000, 1350000),
|
||||||
|
newUsers: 211,
|
||||||
|
operator: "Ava Chen",
|
||||||
|
recharge: 52800,
|
||||||
|
region: "巴西 / 墨西哥",
|
||||||
|
turnover: 94100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
appName: "Aslan",
|
||||||
|
date: "2026-06-30",
|
||||||
|
dayProgress: progress(41600, 45000),
|
||||||
|
id: "person-omar",
|
||||||
|
monthKpiRate: 82.1,
|
||||||
|
monthProgress: progress(906000, 1100000),
|
||||||
|
newUsers: 186,
|
||||||
|
operator: "Omar Saleh",
|
||||||
|
recharge: 41600,
|
||||||
|
region: "沙特 / 阿联酋",
|
||||||
|
turnover: 70400
|
||||||
|
},
|
||||||
|
{
|
||||||
|
appName: "Yumi",
|
||||||
|
date: "2026-06-30",
|
||||||
|
dayProgress: noGoal(),
|
||||||
|
id: "person-lina",
|
||||||
|
monthKpiRate: 75.6,
|
||||||
|
monthProgress: progress(642000, 850000),
|
||||||
|
newUsers: 154,
|
||||||
|
operator: "Lina Gomez",
|
||||||
|
recharge: 32600,
|
||||||
|
region: "菲律宾",
|
||||||
|
turnover: 52900
|
||||||
|
}
|
||||||
|
],
|
||||||
|
title: "人员绩效"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cards: [
|
||||||
|
{ delta: 9.2, key: "recharge", label: "区域总充值 ($)", type: "money" },
|
||||||
|
{ delta: -1.1, key: "newUsers", label: "区域总新增", type: "number" },
|
||||||
|
{ delta: 2.4, key: "arppu", label: "区域综合 ARPPU ($)", type: "money" }
|
||||||
|
],
|
||||||
|
columns: [
|
||||||
|
textColumn("date", "日期"),
|
||||||
|
textColumn("region", "地区"),
|
||||||
|
textColumn("appName", "App名称"),
|
||||||
|
moneyColumn("recharge", "充值 ($)"),
|
||||||
|
moneyColumn("turnover", "流水 ($)"),
|
||||||
|
moneyColumn("arppu", "ARPPU ($)", "average"),
|
||||||
|
numberColumn("newUsers", "新增"),
|
||||||
|
numberColumn("dau", "日活"),
|
||||||
|
percentColumn("retentionD1", "次留"),
|
||||||
|
percentColumn("retentionD7", "7日留"),
|
||||||
|
percentColumn("retentionD30", "30日留"),
|
||||||
|
moneyColumn("salary", "存量工资 ($)"),
|
||||||
|
numberColumn("coins", "金币数量"),
|
||||||
|
ratioColumn("agencyCount", "Agency数量"),
|
||||||
|
ratioColumn("hostCount", "Host数量"),
|
||||||
|
ratioColumn("bdCount", "BD数量"),
|
||||||
|
ratioColumn("coinSellerCount", "币商数量"),
|
||||||
|
numberColumn("coinSellerCoins", "币商金币数")
|
||||||
|
],
|
||||||
|
filters: ["dateRange", "apps", "regions"],
|
||||||
|
key: "regional",
|
||||||
|
label: "地区分析",
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
agencyCount: ratioCount(84, 112),
|
||||||
|
appName: "Lalu",
|
||||||
|
arppu: 44.1,
|
||||||
|
bdCount: ratioCount(26, 34),
|
||||||
|
coinSellerCoins: 9840000,
|
||||||
|
coinSellerCount: ratioCount(18, 24),
|
||||||
|
coins: 36120000,
|
||||||
|
date: "2026-06-30",
|
||||||
|
dau: 13280,
|
||||||
|
hostCount: ratioCount(412, 520),
|
||||||
|
id: "region-br",
|
||||||
|
newUsers: 506,
|
||||||
|
recharge: 206400,
|
||||||
|
region: "巴西",
|
||||||
|
retentionD1: 32.8,
|
||||||
|
retentionD7: 19.4,
|
||||||
|
retentionD30: 10.2,
|
||||||
|
salary: 52200,
|
||||||
|
turnover: 354200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
agencyCount: ratioCount(66, 95),
|
||||||
|
appName: "Aslan",
|
||||||
|
arppu: 41.5,
|
||||||
|
bdCount: ratioCount(19, 28),
|
||||||
|
coinSellerCoins: 7420000,
|
||||||
|
coinSellerCount: ratioCount(15, 21),
|
||||||
|
coins: 28450000,
|
||||||
|
date: "2026-06-30",
|
||||||
|
dau: 11140,
|
||||||
|
hostCount: ratioCount(356, 480),
|
||||||
|
id: "region-sa",
|
||||||
|
newUsers: 442,
|
||||||
|
recharge: 173200,
|
||||||
|
region: "沙特",
|
||||||
|
retentionD1: 30.4,
|
||||||
|
retentionD7: 17.7,
|
||||||
|
retentionD30: 9.1,
|
||||||
|
salary: 44100,
|
||||||
|
turnover: 292600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
agencyCount: ratioCount(41, 68),
|
||||||
|
appName: "Yumi",
|
||||||
|
arppu: 34.8,
|
||||||
|
bdCount: ratioCount(14, 22),
|
||||||
|
coinSellerCoins: 4180000,
|
||||||
|
coinSellerCount: ratioCount(9, 17),
|
||||||
|
coins: 17260000,
|
||||||
|
date: "2026-06-30",
|
||||||
|
dau: 6840,
|
||||||
|
hostCount: ratioCount(228, 346),
|
||||||
|
id: "region-ph",
|
||||||
|
newUsers: 318,
|
||||||
|
recharge: 94800,
|
||||||
|
region: "菲律宾",
|
||||||
|
retentionD1: 27.5,
|
||||||
|
retentionD7: 14.9,
|
||||||
|
retentionD30: 7.3,
|
||||||
|
salary: 22600,
|
||||||
|
turnover: 164900
|
||||||
|
}
|
||||||
|
],
|
||||||
|
title: "地区分析"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cards: [
|
||||||
|
{ delta: -1.7, key: "newUsers", label: "总新增", type: "number" },
|
||||||
|
{ delta: 3.5, key: "dau", label: "总日活", type: "number" },
|
||||||
|
{ delta: 1.2, key: "retentionD1", label: "次日留存均值 (%)", type: "percent" },
|
||||||
|
{ delta: 4.1, key: "ecosystemActive", label: "生态角色总活跃数", type: "number" }
|
||||||
|
],
|
||||||
|
columns: [
|
||||||
|
textColumn("date", "日期"),
|
||||||
|
textColumn("region", "地区"),
|
||||||
|
textColumn("appName", "App名称"),
|
||||||
|
numberColumn("newUsers", "新增"),
|
||||||
|
numberColumn("dau", "日活 (DAU)"),
|
||||||
|
percentColumn("retentionD1", "次日留存"),
|
||||||
|
percentColumn("retentionD7", "7日留存"),
|
||||||
|
percentColumn("retentionD30", "30日留存"),
|
||||||
|
ratioColumn("agencyActive", "Agency活跃数量"),
|
||||||
|
ratioColumn("hostActive", "Host活跃数量"),
|
||||||
|
ratioColumn("bdActive", "BD活跃数量"),
|
||||||
|
ratioColumn("coinSellerActive", "币商活跃数量")
|
||||||
|
],
|
||||||
|
filters: ["dateRange", "apps", "regions"],
|
||||||
|
key: "retention",
|
||||||
|
label: "留存分析",
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
agencyActive: ratioCount(84, 112),
|
||||||
|
appName: "Lalu",
|
||||||
|
bdActive: ratioCount(26, 34),
|
||||||
|
coinSellerActive: ratioCount(18, 24),
|
||||||
|
date: "2026-06-30",
|
||||||
|
dau: 13280,
|
||||||
|
ecosystemActive: 540,
|
||||||
|
hostActive: ratioCount(412, 520),
|
||||||
|
id: "retain-br",
|
||||||
|
newUsers: 506,
|
||||||
|
region: "巴西",
|
||||||
|
retentionD1: 32.8,
|
||||||
|
retentionD7: 19.4,
|
||||||
|
retentionD30: 10.2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
agencyActive: ratioCount(66, 95),
|
||||||
|
appName: "Aslan",
|
||||||
|
bdActive: ratioCount(19, 28),
|
||||||
|
coinSellerActive: ratioCount(15, 21),
|
||||||
|
date: "2026-06-30",
|
||||||
|
dau: 11140,
|
||||||
|
ecosystemActive: 456,
|
||||||
|
hostActive: ratioCount(356, 480),
|
||||||
|
id: "retain-sa",
|
||||||
|
newUsers: 442,
|
||||||
|
region: "沙特",
|
||||||
|
retentionD1: 30.4,
|
||||||
|
retentionD7: 17.7,
|
||||||
|
retentionD30: 9.1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
agencyActive: ratioCount(41, 68),
|
||||||
|
appName: "Yumi",
|
||||||
|
bdActive: ratioCount(14, 22),
|
||||||
|
coinSellerActive: ratioCount(9, 17),
|
||||||
|
date: "2026-06-30",
|
||||||
|
dau: 6840,
|
||||||
|
ecosystemActive: 292,
|
||||||
|
hostActive: ratioCount(228, 346),
|
||||||
|
id: "retain-ph",
|
||||||
|
newUsers: 318,
|
||||||
|
region: "菲律宾",
|
||||||
|
retentionD1: 27.5,
|
||||||
|
retentionD7: 14.9,
|
||||||
|
retentionD30: 7.3
|
||||||
|
}
|
||||||
|
],
|
||||||
|
title: "留存分析"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
function textColumn(key, label) {
|
||||||
|
return { aggregate: "text", align: "left", key, label, type: "text" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function moneyColumn(key, label, aggregate = "sum") {
|
||||||
|
return { aggregate, align: "right", key, label, type: "money" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberColumn(key, label) {
|
||||||
|
return { aggregate: "sum", align: "right", key, label, type: "number" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function percentColumn(key, label) {
|
||||||
|
return { aggregate: "average", align: "right", key, label, type: "percent" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function progressColumn(key, label) {
|
||||||
|
return { aggregate: "progress", align: "left", key, label, type: "progress" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function ratioColumn(key, label) {
|
||||||
|
return { aggregate: "ratio", align: "right", key, label, type: "ratio" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function flowRateColumn(key, label) {
|
||||||
|
return { aggregate: "flowRate", align: "right", key, label, type: "flowRate" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function consumeOutputColumn(key, label) {
|
||||||
|
return { aggregate: "consumeOutput", align: "right", key, label, type: "consumeOutput" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function durationColumn(key, label, aggregate = "sum") {
|
||||||
|
return { aggregate: aggregate === "average" ? "durationAverage" : "duration", align: "right", key, label, type: "duration" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function progress(actual, target) {
|
||||||
|
return { actual, target };
|
||||||
|
}
|
||||||
|
|
||||||
|
function noGoal() {
|
||||||
|
return { actual: 0, noGoal: true, target: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function ratioCount(active, total) {
|
||||||
|
return { active, total };
|
||||||
|
}
|
||||||
@ -1,8 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { DatabiApp } from "./DatabiApp.jsx";
|
import { DatabiApp } from "./DatabiApp.jsx";
|
||||||
// 全局设计 token(@/styles/tokens.css)供共享组件(如 TimeRangeFilter)取色,先于本地样式引入。
|
|
||||||
import "@/styles/tokens.css";
|
|
||||||
import "./styles/index.css";
|
import "./styles/index.css";
|
||||||
|
|
||||||
createRoot(document.getElementById("databi-root")).render(
|
createRoot(document.getElementById("databi-root")).render(
|
||||||
|
|||||||
@ -1,338 +0,0 @@
|
|||||||
// 社交 BI v2 Shell:深色侧栏(视图导航)+ 顶栏全局筛选(日期/App/区域/粒度),
|
|
||||||
// 视图组件通过 useSocialBi() 消费统一的筛选状态与数据派生结果。
|
|
||||||
|
|
||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import AccountTreeOutlined from "@mui/icons-material/AccountTreeOutlined";
|
|
||||||
import GroupsOutlined from "@mui/icons-material/GroupsOutlined";
|
|
||||||
import InsightsOutlined from "@mui/icons-material/InsightsOutlined";
|
|
||||||
import PublicOutlined from "@mui/icons-material/PublicOutlined";
|
|
||||||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
|
||||||
import RepeatOutlined from "@mui/icons-material/RepeatOutlined";
|
|
||||||
import TableChartOutlined from "@mui/icons-material/TableChartOutlined";
|
|
||||||
import {
|
|
||||||
ALL,
|
|
||||||
DATE_PRESETS,
|
|
||||||
GRANULARITIES,
|
|
||||||
createDefaultFilters,
|
|
||||||
rangeLabel,
|
|
||||||
readStateFromURL,
|
|
||||||
toggleMultiValue,
|
|
||||||
writeStateToURL
|
|
||||||
} from "./state.js";
|
|
||||||
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
|
||||||
import { appColor } from "./metrics.js";
|
|
||||||
import { SOCIAL_BI_FUNNEL_APP_CODES, useSocialBiData } from "./useSocialBiData.js";
|
|
||||||
import { OverviewView } from "./views/OverviewView.jsx";
|
|
||||||
import { RegionsView } from "./views/RegionsView.jsx";
|
|
||||||
import { TeamKpiView } from "./views/TeamKpiView.jsx";
|
|
||||||
import { RetentionView } from "./views/RetentionView.jsx";
|
|
||||||
import { DataTableView } from "./views/DataTableView.jsx";
|
|
||||||
import { FunnelView } from "./views/FunnelView.jsx";
|
|
||||||
import "../styles/social-v2.css";
|
|
||||||
|
|
||||||
const VIEWS = [
|
|
||||||
{ component: OverviewView, icon: InsightsOutlined, key: "overview", label: "经营概览" },
|
|
||||||
{ component: RegionsView, icon: PublicOutlined, key: "regions", label: "地区洞察" },
|
|
||||||
{ component: TeamKpiView, icon: GroupsOutlined, key: "team", label: "运营中心" },
|
|
||||||
{ component: RetentionView, icon: RepeatOutlined, key: "retention", label: "留存质量" },
|
|
||||||
{ component: DataTableView, icon: TableChartOutlined, key: "table", label: "数据明细" },
|
|
||||||
{ component: FunnelView, icon: AccountTreeOutlined, key: "funnel", label: "埋点漏斗" }
|
|
||||||
];
|
|
||||||
|
|
||||||
const SocialBiContext = createContext(null);
|
|
||||||
|
|
||||||
export function useSocialBi() {
|
|
||||||
const context = useContext(SocialBiContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error("useSocialBi must be used inside SocialBiApp");
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SocialBiApp() {
|
|
||||||
const initial = useMemo(() => readStateFromURL(), []);
|
|
||||||
const [filters, setFilters] = useState(initial.filters);
|
|
||||||
const [viewKey, setViewKey] = useState(() => (VIEWS.some((view) => view.key === initial.view) ? initial.view : "overview"));
|
|
||||||
const data = useSocialBiData(filters);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
writeStateToURL(filters, viewKey);
|
|
||||||
}, [filters, viewKey]);
|
|
||||||
|
|
||||||
const updateFilter = useCallback((key, value) => {
|
|
||||||
setFilters((current) => ({ ...current, [key]: value }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const resetFilters = useCallback(() => {
|
|
||||||
setFilters(createDefaultFilters());
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const contextValue = useMemo(
|
|
||||||
() => ({ ...data, filters, resetFilters, setViewKey, updateFilter, viewKey }),
|
|
||||||
[data, filters, resetFilters, updateFilter, viewKey]
|
|
||||||
);
|
|
||||||
|
|
||||||
const ActiveView = (VIEWS.find((view) => view.key === viewKey) || VIEWS[0]).component;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SocialBiContext.Provider value={contextValue}>
|
|
||||||
<div className="sbi-shell">
|
|
||||||
<Sidebar data={data} onViewChange={setViewKey} viewKey={viewKey} />
|
|
||||||
<div className="sbi-main">
|
|
||||||
<TopBar data={data} filters={filters} updateFilter={updateFilter} viewKey={viewKey} />
|
|
||||||
{data.errors.length ? (
|
|
||||||
<div className="sbi-error-banner" role="alert">
|
|
||||||
{data.errors.map((message, index) => (
|
|
||||||
<span key={`${index}-${message}`}>{message}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<main className="sbi-view" aria-label="Social BI 数据中心">
|
|
||||||
<ActiveView />
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SocialBiContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Sidebar({ data, onViewChange, viewKey }) {
|
|
||||||
const access = data.master?.access;
|
|
||||||
const scopeLabel = !data.master ? "" : access?.all ? "全量数据范围" : `负责 ${access?.scopes?.length || 0} 个 App/区域`;
|
|
||||||
return (
|
|
||||||
<aside className="sbi-sidebar" aria-label="Social BI 导航">
|
|
||||||
<div className="sbi-brand">
|
|
||||||
<span className="sbi-brand-mark">HY</span>
|
|
||||||
<div className="sbi-brand-copy">
|
|
||||||
<strong>运营数据总览</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<nav className="sbi-nav" role="tablist" aria-label="数据视图">
|
|
||||||
{VIEWS.map((view) => {
|
|
||||||
const Icon = view.icon;
|
|
||||||
const isActive = viewKey === view.key;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
aria-selected={isActive}
|
|
||||||
className={isActive ? "sbi-nav-item is-active" : "sbi-nav-item"}
|
|
||||||
key={view.key}
|
|
||||||
onClick={() => onViewChange(view.key)}
|
|
||||||
role="tab"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon fontSize="small" />
|
|
||||||
<span>{view.label}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
{scopeLabel ? (
|
|
||||||
<div className="sbi-scope-badge" title="数据按你的负责范围裁剪,可在用户管理里调整">
|
|
||||||
<span className="sbi-scope-dot" />
|
|
||||||
{scopeLabel}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TopBar({ data, filters, updateFilter, viewKey }) {
|
|
||||||
return (
|
|
||||||
<header className="sbi-topbar">
|
|
||||||
<div className="sbi-topbar-row">
|
|
||||||
<DatePresetControl filters={filters} updateFilter={updateFilter} />
|
|
||||||
<GranularityControl filters={filters} updateFilter={updateFilter} />
|
|
||||||
<button className="sbi-refresh" onClick={data.refresh} title="刷新数据" type="button">
|
|
||||||
<RefreshOutlined fontSize="small" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="sbi-topbar-row">
|
|
||||||
<AppChips data={data} filters={filters} updateFilter={updateFilter} viewKey={viewKey} />
|
|
||||||
<RegionSelect data={data} filters={filters} updateFilter={updateFilter} />
|
|
||||||
<span className="sbi-range-label">{rangeLabel(data.range)}</span>
|
|
||||||
{data.isLoading ? <span className="sbi-loading-dot" aria-label="加载中" /> : null}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DatePresetControl({ filters, updateFilter }) {
|
|
||||||
return (
|
|
||||||
<div className="sbi-date-presets" role="radiogroup" aria-label="日期区间">
|
|
||||||
{DATE_PRESETS.map((preset) => (
|
|
||||||
<button
|
|
||||||
aria-checked={filters.preset === preset.key}
|
|
||||||
className={filters.preset === preset.key ? "is-active" : ""}
|
|
||||||
key={preset.key}
|
|
||||||
onClick={() => updateFilter("preset", preset.key)}
|
|
||||||
role="radio"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{preset.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{filters.preset === "custom" ? (
|
|
||||||
<TimeRangeFilter
|
|
||||||
className="sbi-custom-range"
|
|
||||||
label="自定义区间"
|
|
||||||
value={{ endMs: dayTextToEndMs(filters.customEnd), startMs: dayTextToStartMs(filters.customStart) }}
|
|
||||||
withTime={false}
|
|
||||||
onChange={(range) => {
|
|
||||||
updateFilter("customStart", msToDayText(range.startMs));
|
|
||||||
updateFilter("customEnd", msToDayText(range.endMs));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 自定义区间在筛选状态里以天字符串(YYYY-MM-DD)存储,选择器以本地时区毫秒交互,这里做双向换算。
|
|
||||||
function parseDayText(value) {
|
|
||||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value || ""));
|
|
||||||
return match ? { day: Number(match[3]), month: Number(match[2]), year: Number(match[1]) } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function dayTextToStartMs(dayText) {
|
|
||||||
const parts = parseDayText(dayText);
|
|
||||||
return parts ? new Date(parts.year, parts.month - 1, parts.day, 0, 0, 0, 0).getTime() : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function dayTextToEndMs(dayText) {
|
|
||||||
const parts = parseDayText(dayText);
|
|
||||||
return parts ? new Date(parts.year, parts.month - 1, parts.day, 23, 59, 59, 999).getTime() : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function msToDayText(ms) {
|
|
||||||
if (!ms) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
const date = new Date(Number(ms));
|
|
||||||
const pad = (value) => String(value).padStart(2, "0");
|
|
||||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function GranularityControl({ filters, updateFilter }) {
|
|
||||||
return (
|
|
||||||
<div className="sbi-granularity" role="radiogroup" aria-label="趋势粒度">
|
|
||||||
{GRANULARITIES.map((item) => (
|
|
||||||
<button
|
|
||||||
aria-checked={filters.granularity === item.key}
|
|
||||||
className={filters.granularity === item.key ? "is-active" : ""}
|
|
||||||
key={item.key}
|
|
||||||
onClick={() => updateFilter("granularity", item.key)}
|
|
||||||
role="radio"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AppChips({ data, filters, updateFilter, viewKey }) {
|
|
||||||
const apps = viewKey === "funnel"
|
|
||||||
? (data.master?.apps || []).filter((app) => SOCIAL_BI_FUNNEL_APP_CODES.includes(app.app_code))
|
|
||||||
: data.master?.apps || [];
|
|
||||||
const allCodes = apps.map((app) => app.app_code);
|
|
||||||
const hasSelectedVisibleApp = filters.apps.some((appCode) => allCodes.includes(appCode));
|
|
||||||
const isAll = filters.apps.includes(ALL) || (viewKey === "funnel" && !hasSelectedVisibleApp);
|
|
||||||
return (
|
|
||||||
<div className="sbi-app-chips" role="group" aria-label="App 筛选">
|
|
||||||
<button
|
|
||||||
aria-pressed={isAll}
|
|
||||||
className={isAll ? "sbi-chip is-active" : "sbi-chip"}
|
|
||||||
onClick={() => updateFilter("apps", [ALL])}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{viewKey === "funnel" ? "全部漏斗 App" : "全部 App"}
|
|
||||||
</button>
|
|
||||||
{apps.map((app) => {
|
|
||||||
const selected = !isAll && filters.apps.includes(app.app_code);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
aria-pressed={selected}
|
|
||||||
className={selected ? "sbi-chip is-active" : "sbi-chip"}
|
|
||||||
key={app.app_code}
|
|
||||||
onClick={() => updateFilter("apps", toggleMultiValue(filters.apps, app.app_code, allCodes))}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<span className="sbi-chip-dot" style={{ background: appColor(app.app_code, allCodes) }} />
|
|
||||||
{app.app_name || app.app_code}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RegionSelect({ data, filters, updateFilter }) {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const containerRef = useRef(null);
|
|
||||||
const apps = data.master?.apps || [];
|
|
||||||
const regions = data.master?.regions || [];
|
|
||||||
const groups = apps
|
|
||||||
.map((app) => ({
|
|
||||||
app,
|
|
||||||
regions: regions.filter((region) => region.app_code === app.app_code)
|
|
||||||
}))
|
|
||||||
.filter((group) => group.regions.length);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handlePointerDown = (event) => {
|
|
||||||
if (!containerRef.current?.contains(event.target)) {
|
|
||||||
setIsOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener("pointerdown", handlePointerDown);
|
|
||||||
return () => document.removeEventListener("pointerdown", handlePointerDown);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const selectedCount = filters.regions.includes(ALL) ? 0 : filters.regions.length;
|
|
||||||
return (
|
|
||||||
<div className="sbi-region-select" ref={containerRef}>
|
|
||||||
<button aria-expanded={isOpen} className="sbi-region-trigger" onClick={() => setIsOpen((open) => !open)} type="button">
|
|
||||||
地区:{selectedCount ? `已选 ${selectedCount} 项` : "全部"}
|
|
||||||
</button>
|
|
||||||
{isOpen ? (
|
|
||||||
<div className="sbi-region-menu" role="listbox" aria-label="地区筛选">
|
|
||||||
<button
|
|
||||||
className={filters.regions.includes(ALL) ? "sbi-region-option is-active" : "sbi-region-option"}
|
|
||||||
onClick={() => updateFilter("regions", [ALL])}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
全部地区
|
|
||||||
</button>
|
|
||||||
{groups.map((group) => (
|
|
||||||
<div className="sbi-region-group" key={group.app.app_code}>
|
|
||||||
<button
|
|
||||||
className={
|
|
||||||
filters.regions.includes(`app:${group.app.app_code}`) ? "sbi-region-option is-group is-active" : "sbi-region-option is-group"
|
|
||||||
}
|
|
||||||
onClick={() => updateFilter("regions", toggleMultiValue(filters.regions, `app:${group.app.app_code}`))}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{group.app.app_name || group.app.app_code} · 全部区域
|
|
||||||
</button>
|
|
||||||
{group.regions.map((region) => {
|
|
||||||
const value = `${region.app_code}:${region.region_id}`;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={filters.regions.includes(value) ? "sbi-region-option is-active" : "sbi-region-option"}
|
|
||||||
key={value}
|
|
||||||
onClick={() => updateFilter("regions", toggleMultiValue(filters.regions, value))}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{region.region_name || region.region_code}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{!groups.length ? <p className="sbi-region-empty">暂无区域目录</p> : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,121 +0,0 @@
|
|||||||
// 社交 BI v2 的展示格式化层:所有视图共用,保证同一指标在任何视图里的写法一致。
|
|
||||||
// 后端金额一律是 USD 分(*_usd_minor),比例一律是 0-1 小数,这里统一换算。
|
|
||||||
|
|
||||||
const numberFormatter = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 });
|
|
||||||
const decimalFormatter = new Intl.NumberFormat("en-US", { maximumFractionDigits: 1, minimumFractionDigits: 1 });
|
|
||||||
|
|
||||||
export function isBlank(value) {
|
|
||||||
return value === null || value === undefined || value === "" || (typeof value === "number" && !Number.isFinite(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatMoneyMinor(value) {
|
|
||||||
if (isBlank(value)) {
|
|
||||||
return "--";
|
|
||||||
}
|
|
||||||
return formatMoney(Number(value) / 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatMoney(value) {
|
|
||||||
if (isBlank(value)) {
|
|
||||||
return "--";
|
|
||||||
}
|
|
||||||
const numeric = Number(value);
|
|
||||||
const digits = Math.abs(numeric) < 1000 && !Number.isInteger(numeric) ? 1 : 0;
|
|
||||||
return `$${numeric.toLocaleString("en-US", { maximumFractionDigits: digits, minimumFractionDigits: digits })}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatCount(value) {
|
|
||||||
if (isBlank(value)) {
|
|
||||||
return "--";
|
|
||||||
}
|
|
||||||
return numberFormatter.format(Number(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 金币等大数值在卡片/图表标签里用紧凑写法(1.2亿 视觉负担太大,用 en 缩写与后台其余模块一致)。
|
|
||||||
export function formatCompact(value) {
|
|
||||||
if (isBlank(value)) {
|
|
||||||
return "--";
|
|
||||||
}
|
|
||||||
const numeric = Number(value);
|
|
||||||
const abs = Math.abs(numeric);
|
|
||||||
if (abs >= 1_000_000_000) {
|
|
||||||
return `${(numeric / 1_000_000_000).toFixed(1)}B`;
|
|
||||||
}
|
|
||||||
if (abs >= 1_000_000) {
|
|
||||||
return `${(numeric / 1_000_000).toFixed(1)}M`;
|
|
||||||
}
|
|
||||||
if (abs >= 10_000) {
|
|
||||||
return `${(numeric / 1_000).toFixed(1)}K`;
|
|
||||||
}
|
|
||||||
return numberFormatter.format(numeric);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatRatioPercent(value) {
|
|
||||||
if (isBlank(value)) {
|
|
||||||
return "--";
|
|
||||||
}
|
|
||||||
return `${decimalFormatter.format(Number(value) * 100)}%`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatPercentValue(value) {
|
|
||||||
if (isBlank(value)) {
|
|
||||||
return "--";
|
|
||||||
}
|
|
||||||
return `${decimalFormatter.format(Number(value))}%`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatDurationMs(value) {
|
|
||||||
if (isBlank(value)) {
|
|
||||||
return "--";
|
|
||||||
}
|
|
||||||
const totalSeconds = Math.max(0, Math.round(Number(value) / 1000));
|
|
||||||
if (totalSeconds < 60) {
|
|
||||||
return `${totalSeconds}秒`;
|
|
||||||
}
|
|
||||||
const totalMinutes = Math.round(totalSeconds / 60);
|
|
||||||
if (totalMinutes < 60) {
|
|
||||||
return `${totalMinutes}分`;
|
|
||||||
}
|
|
||||||
const hours = Math.floor(totalMinutes / 60);
|
|
||||||
const minutes = totalMinutes % 60;
|
|
||||||
return minutes ? `${hours}小时${minutes}分` : `${hours}小时`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 环比:后端 delta 字段是 0-1 增幅(0.12 = +12%)。
|
|
||||||
export function formatDeltaRate(value) {
|
|
||||||
if (isBlank(value)) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
const numeric = Number(value) * 100;
|
|
||||||
const sign = numeric >= 0 ? "+" : "";
|
|
||||||
return `${sign}${decimalFormatter.format(numeric)}%`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deltaDirection(value) {
|
|
||||||
if (isBlank(value) || Number(value) === 0) {
|
|
||||||
return "flat";
|
|
||||||
}
|
|
||||||
return Number(value) > 0 ? "up" : "down";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function csvEscape(value) {
|
|
||||||
const text = String(value ?? "");
|
|
||||||
if (/[",\n]/.test(text)) {
|
|
||||||
return `"${text.replace(/"/g, '""')}"`;
|
|
||||||
}
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function downloadCsv(filename, headerCells, rows) {
|
|
||||||
const lines = [headerCells.map(csvEscape).join(",")];
|
|
||||||
rows.forEach((cells) => {
|
|
||||||
lines.push(cells.map(csvEscape).join(","));
|
|
||||||
});
|
|
||||||
const blob = new Blob(["\uFEFF", lines.join("\n")], { type: "text/csv;charset=utf-8" });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const anchor = document.createElement("a");
|
|
||||||
anchor.href = url;
|
|
||||||
anchor.download = filename;
|
|
||||||
anchor.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
@ -1,267 +0,0 @@
|
|||||||
// 社交 BI v2 的指标口径中心:所有视图通过 METRIC_DEFS 取 label/格式化/口径说明,
|
|
||||||
// 行数据保持后端原始 snake_case 字段(金额 USD 分、比例 0-1),渲染时才格式化。
|
|
||||||
|
|
||||||
import { formatCompact, formatCount, formatDurationMs, formatMoneyMinor, formatRatioPercent, isBlank } from "./format.js";
|
|
||||||
|
|
||||||
export const METRIC_DEFS = {
|
|
||||||
new_users: { label: "新增", tooltip: "当日新注册用户数(legacy 按 user_run_profile 创建时间)", type: "count" },
|
|
||||||
active_users: { label: "日活 (DAU)", tooltip: "当日活跃去重用户;跨天合计为人天", type: "count" },
|
|
||||||
paid_users: { label: "付费用户", tooltip: "发生成功充值的去重用户", type: "count" },
|
|
||||||
recharge_users: { label: "充值用户", tooltip: "与付费用户同口径", type: "count" },
|
|
||||||
recharge_usd_minor: { label: "充值 ($)", tooltip: "全部渠道充值合计(USD)", type: "money" },
|
|
||||||
new_user_recharge_usd_minor: { label: "新用户充值", tooltip: "当日注册用户当日的充值金额", type: "money" },
|
|
||||||
google_recharge_usd_minor: { label: "谷歌充值", tooltip: "Google Play 渠道充值", type: "money" },
|
|
||||||
mifapay_recharge_usd_minor: { label: "三方充值", tooltip: "非谷歌的三方渠道充值合计", type: "money" },
|
|
||||||
coin_seller_recharge_usd_minor: { label: "币商充值", tooltip: "币商进货充值金额", type: "money" },
|
|
||||||
coin_seller_stock_coin: { label: "币商充值金币", tooltip: "币商进货获得的金币", type: "coin" },
|
|
||||||
coin_seller_transfer_coin: { label: "币商出货金币", tooltip: "币商转给用户的金币(legacy 按 SELLER_AGENT 流水)", type: "coin" },
|
|
||||||
game_turnover: { label: "游戏流水", tooltip: "游戏下注金币流水", type: "coin" },
|
|
||||||
game_profit_rate: { label: "游戏利润率", tooltip: "(流水-返奖)/流水", type: "ratio" },
|
|
||||||
lucky_gift_turnover: { label: "幸运礼物流水", tooltip: "幸运礼物赠送金币流水", type: "coin" },
|
|
||||||
lucky_gift_payout: { label: "幸运礼物返奖", tooltip: "幸运礼物中奖返还金币", type: "coin" },
|
|
||||||
lucky_gift_profit_rate: { label: "幸运礼物利润率", tooltip: "(流水-返奖)/流水", type: "ratio" },
|
|
||||||
super_lucky_gift_turnover: { label: "超级幸运礼物流水", tooltip: "超级幸运礼物赠送金币流水(legacy 平台无此玩法)", type: "coin" },
|
|
||||||
super_lucky_gift_profit_rate: { label: "超级幸运礼物利润率", tooltip: "(流水-返奖)/流水", type: "ratio" },
|
|
||||||
gift_coin_spent: { label: "礼物流水", tooltip: "全部礼物赠送金币流水(含幸运礼物)", type: "coin" },
|
|
||||||
coin_total: { label: "金币数量", tooltip: "用户金币余额快照", type: "coin" },
|
|
||||||
consumed_coin: { label: "消耗金币", tooltip: "金币消耗流水合计(legacy 仅全 App 口径)", type: "coin" },
|
|
||||||
output_coin: { label: "产出金币", tooltip: "金币产出流水合计(legacy 仅全 App 口径)", type: "coin" },
|
|
||||||
consume_output_ratio: { label: "消耗产出比", tooltip: "消耗金币 / 产出金币", type: "ratio" },
|
|
||||||
platform_grant_coin: { label: "平台发放金币", tooltip: "平台活动发放金币", type: "coin" },
|
|
||||||
manual_grant_coin: { label: "人工发放金币", tooltip: "后台人工发放金币", type: "coin" },
|
|
||||||
salary_usd_minor: { label: "存量工资 ($)", tooltip: "主播工资余额快照(USD)", type: "money" },
|
|
||||||
salary_transfer_coin: { label: "工资兑换金币", tooltip: "工资兑换成金币的数量", type: "coin" },
|
|
||||||
avg_mic_online_ms: { label: "平均麦上时间", tooltip: "人均麦上时长", type: "duration" },
|
|
||||||
mic_online_ms: { label: "麦上总时长", tooltip: "全部用户麦上时长合计", type: "duration" },
|
|
||||||
arpu_usd_minor: { label: "ARPU ($)", tooltip: "充值 / 日活", type: "money" },
|
|
||||||
arppu_usd_minor: { label: "ARPPU ($)", tooltip: "充值 / 付费用户", type: "money" },
|
|
||||||
paid_conversion_rate: { label: "付费转化率", tooltip: "付费用户 / 日活", type: "ratio" },
|
|
||||||
recharge_conversion_rate: { label: "充值转化率", tooltip: "充值用户 / 日活", type: "ratio" },
|
|
||||||
d1_retention_rate: { label: "次日留存", tooltip: "注册次日仍活跃的比例", type: "ratio" },
|
|
||||||
d7_retention_rate: { label: "7日留存", tooltip: "注册第 7 日仍活跃的比例", type: "ratio" },
|
|
||||||
d30_retention_rate: { label: "30日留存", tooltip: "注册第 30 日仍活跃的比例", type: "ratio" }
|
|
||||||
};
|
|
||||||
|
|
||||||
// 数据明细宽表的列组:与运营的心智分组一致,支持整组折叠。
|
|
||||||
export const METRIC_GROUPS = [
|
|
||||||
{ key: "users", label: "用户", metrics: ["new_users", "active_users", "paid_users", "recharge_users"] },
|
|
||||||
{
|
|
||||||
key: "recharge",
|
|
||||||
label: "充值",
|
|
||||||
metrics: [
|
|
||||||
"recharge_usd_minor",
|
|
||||||
"new_user_recharge_usd_minor",
|
|
||||||
"google_recharge_usd_minor",
|
|
||||||
"mifapay_recharge_usd_minor",
|
|
||||||
"coin_seller_recharge_usd_minor"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{ key: "coinSeller", label: "币商", metrics: ["coin_seller_stock_coin", "coin_seller_transfer_coin"] },
|
|
||||||
{
|
|
||||||
key: "play",
|
|
||||||
label: "玩法流水",
|
|
||||||
metrics: [
|
|
||||||
"game_turnover",
|
|
||||||
"game_profit_rate",
|
|
||||||
"lucky_gift_turnover",
|
|
||||||
"lucky_gift_profit_rate",
|
|
||||||
"super_lucky_gift_turnover",
|
|
||||||
"super_lucky_gift_profit_rate",
|
|
||||||
"gift_coin_spent"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "coins",
|
|
||||||
label: "金币",
|
|
||||||
metrics: ["coin_total", "consumed_coin", "output_coin", "consume_output_ratio", "platform_grant_coin", "manual_grant_coin"]
|
|
||||||
},
|
|
||||||
{ key: "salary", label: "工资", metrics: ["salary_usd_minor", "salary_transfer_coin"] },
|
|
||||||
{ key: "engagement", label: "时长", metrics: ["avg_mic_online_ms", "mic_online_ms"] },
|
|
||||||
{
|
|
||||||
key: "quality",
|
|
||||||
label: "质量",
|
|
||||||
metrics: [
|
|
||||||
"arpu_usd_minor",
|
|
||||||
"arppu_usd_minor",
|
|
||||||
"paid_conversion_rate",
|
|
||||||
"recharge_conversion_rate",
|
|
||||||
"d1_retention_rate",
|
|
||||||
"d7_retention_rate",
|
|
||||||
"d30_retention_rate"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export function metricLabel(key) {
|
|
||||||
return METRIC_DEFS[key]?.label || key;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function metricTooltip(key) {
|
|
||||||
return METRIC_DEFS[key]?.tooltip || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatMetric(key, value) {
|
|
||||||
const type = METRIC_DEFS[key]?.type;
|
|
||||||
if (type === "money") {
|
|
||||||
return formatMoneyMinor(value);
|
|
||||||
}
|
|
||||||
if (type === "ratio") {
|
|
||||||
return formatRatioPercent(value);
|
|
||||||
}
|
|
||||||
if (type === "coin") {
|
|
||||||
return formatCount(value);
|
|
||||||
}
|
|
||||||
if (type === "duration") {
|
|
||||||
return formatDurationMs(value);
|
|
||||||
}
|
|
||||||
return formatCount(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatMetricCompact(key, value) {
|
|
||||||
const type = METRIC_DEFS[key]?.type;
|
|
||||||
if (type === "money") {
|
|
||||||
return isBlank(value) ? "--" : `$${formatCompact(Number(value) / 100)}`;
|
|
||||||
}
|
|
||||||
if (type === "ratio") {
|
|
||||||
return formatRatioPercent(value);
|
|
||||||
}
|
|
||||||
if (type === "duration") {
|
|
||||||
return formatDurationMs(value);
|
|
||||||
}
|
|
||||||
return formatCompact(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 图表取数值:金额换算成美元,比例换算成百分数,其余原样;缺失返回 null 让 ECharts 断线。
|
|
||||||
export function metricChartValue(key, value) {
|
|
||||||
if (isBlank(value)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const type = METRIC_DEFS[key]?.type;
|
|
||||||
if (type === "money") {
|
|
||||||
return Number(value) / 100;
|
|
||||||
}
|
|
||||||
if (type === "ratio") {
|
|
||||||
return Number(value) * 100;
|
|
||||||
}
|
|
||||||
return Number(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 可线性相加的字段;与后端 databi/metrics.go 的 additiveMetricKeys 对齐,前端聚合只做同一口径。
|
|
||||||
const ADDITIVE_METRIC_KEYS = [
|
|
||||||
"new_users",
|
|
||||||
"active_users",
|
|
||||||
"paid_users",
|
|
||||||
"recharge_users",
|
|
||||||
"recharge_usd_minor",
|
|
||||||
"new_user_recharge_usd_minor",
|
|
||||||
"google_recharge_usd_minor",
|
|
||||||
"mifapay_recharge_usd_minor",
|
|
||||||
"coin_seller_recharge_usd_minor",
|
|
||||||
"coin_seller_stock_coin",
|
|
||||||
"coin_seller_transfer_coin",
|
|
||||||
"game_turnover",
|
|
||||||
"game_payout",
|
|
||||||
"lucky_gift_turnover",
|
|
||||||
"lucky_gift_payout",
|
|
||||||
"super_lucky_gift_turnover",
|
|
||||||
"super_lucky_gift_payout",
|
|
||||||
"gift_coin_spent",
|
|
||||||
"coin_total",
|
|
||||||
"consumed_coin",
|
|
||||||
"output_coin",
|
|
||||||
"platform_grant_coin",
|
|
||||||
"manual_grant_coin",
|
|
||||||
"salary_usd_minor",
|
|
||||||
"salary_transfer_coin",
|
|
||||||
"mic_online_ms",
|
|
||||||
"mic_online_users",
|
|
||||||
"d1_retention_users",
|
|
||||||
"d1_retention_base_users",
|
|
||||||
"d7_retention_users",
|
|
||||||
"d7_retention_base_users",
|
|
||||||
"d30_retention_users",
|
|
||||||
"d30_retention_base_users"
|
|
||||||
];
|
|
||||||
|
|
||||||
function readNumeric(row, key) {
|
|
||||||
const value = row?.[key];
|
|
||||||
if (isBlank(value)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const numeric = Number(value);
|
|
||||||
return Number.isFinite(numeric) ? numeric : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 余额快照类指标只在"空间"维度可加(跨国家/区域/App),跨天求和没有业务含义;
|
|
||||||
// 跨天聚合时取最后一天的快照(与后端整段口径一致)。
|
|
||||||
const SNAPSHOT_METRIC_KEYS = new Set(["coin_total", "salary_usd_minor"]);
|
|
||||||
|
|
||||||
// aggregateMetricMaps 把若干行(App 合计、区域行、日行)合并成一行:
|
|
||||||
// 可加字段求和(全缺失保持 null → 显示 "--"),比例/均值按分子分母重算,避免"平均百分比"这类错误口径。
|
|
||||||
// 行集跨多天时传 { acrossTime: true },快照字段只累计最后一天的行。
|
|
||||||
export function aggregateMetricMaps(rows, { acrossTime = false } = {}) {
|
|
||||||
const out = {};
|
|
||||||
let latestDay = "";
|
|
||||||
if (acrossTime) {
|
|
||||||
rows.forEach((row) => {
|
|
||||||
const day = String(row?.stat_day || "");
|
|
||||||
if (day > latestDay) {
|
|
||||||
latestDay = day;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
ADDITIVE_METRIC_KEYS.forEach((key) => {
|
|
||||||
const snapshotOnly = acrossTime && SNAPSHOT_METRIC_KEYS.has(key);
|
|
||||||
let sum = null;
|
|
||||||
rows.forEach((row) => {
|
|
||||||
if (snapshotOnly && String(row?.stat_day || "") !== latestDay) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const value = readNumeric(row, key);
|
|
||||||
if (value !== null) {
|
|
||||||
sum = (sum ?? 0) + value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
out[key] = sum;
|
|
||||||
});
|
|
||||||
|
|
||||||
const recharge = out.recharge_usd_minor;
|
|
||||||
const active = out.active_users;
|
|
||||||
const paid = out.paid_users;
|
|
||||||
out.arpu_usd_minor = recharge !== null && active ? Math.round(recharge / active) : null;
|
|
||||||
out.arppu_usd_minor = recharge !== null && paid ? Math.round(recharge / paid) : 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.game_profit_rate = out.game_turnover ? (out.game_turnover - (out.game_payout ?? 0)) / out.game_turnover : null;
|
|
||||||
out.lucky_gift_profit_rate = out.lucky_gift_turnover
|
|
||||||
? (out.lucky_gift_turnover - (out.lucky_gift_payout ?? 0)) / out.lucky_gift_turnover
|
|
||||||
: null;
|
|
||||||
out.super_lucky_gift_profit_rate = out.super_lucky_gift_turnover
|
|
||||||
? (out.super_lucky_gift_turnover - (out.super_lucky_gift_payout ?? 0)) / out.super_lucky_gift_turnover
|
|
||||||
: null;
|
|
||||||
out.consume_output_ratio = out.consumed_coin !== null && out.output_coin ? out.consumed_coin / out.output_coin : null;
|
|
||||||
out.avg_mic_online_ms = out.mic_online_ms !== null && out.mic_online_users ? Math.round(out.mic_online_ms / out.mic_online_users) : null;
|
|
||||||
["d1", "d7", "d30"].forEach((day) => {
|
|
||||||
const users = out[`${day}_retention_users`];
|
|
||||||
const base = out[`${day}_retention_base_users`];
|
|
||||||
out[`${day}_retention_rate`] = users !== null && base ? users / base : null;
|
|
||||||
});
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// App 品牌色:chips、图表系列、行标识统一取色,保证同一 App 全站同色。
|
|
||||||
// 已知 App 用固定映射(与筛选状态无关);未知 App 按 code 哈希取色,避免"选中子集后颜色漂移"。
|
|
||||||
const APP_COLOR_PALETTE = ["#2563eb", "#0e9488", "#d97706", "#7c3aed", "#db2777", "#059669", "#dc2626", "#4f46e5"];
|
|
||||||
const APP_COLOR_FIXED = { aslan: 2, fami: 3, huwaa: 4, lalu: 0, yumi: 1 };
|
|
||||||
|
|
||||||
export function appColor(appCode) {
|
|
||||||
const code = String(appCode || "").toLowerCase();
|
|
||||||
if (code in APP_COLOR_FIXED) {
|
|
||||||
return APP_COLOR_PALETTE[APP_COLOR_FIXED[code]];
|
|
||||||
}
|
|
||||||
let hash = 0;
|
|
||||||
for (let index = 0; index < code.length; index += 1) {
|
|
||||||
hash = (hash * 31 + code.charCodeAt(index)) >>> 0;
|
|
||||||
}
|
|
||||||
return APP_COLOR_PALETTE[hash % APP_COLOR_PALETTE.length];
|
|
||||||
}
|
|
||||||
@ -1,206 +0,0 @@
|
|||||||
// 社交 BI v2 的全局筛选状态:日期预设/自定义区间、App 多选、区域多选、趋势粒度,
|
|
||||||
// 并同步到 URL query,保证筛选状态可以直接作为链接分享。
|
|
||||||
|
|
||||||
import { lastDaysRange, thisMonthRange, todayRange } from "../utils/time.js";
|
|
||||||
|
|
||||||
export const ALL = "all";
|
|
||||||
|
|
||||||
export const DATE_PRESETS = [
|
|
||||||
{ key: "today", label: "今日" },
|
|
||||||
{ key: "yesterday", label: "昨日" },
|
|
||||||
{ key: "last7", label: "近 7 日" },
|
|
||||||
{ key: "last30", label: "近 30 日" },
|
|
||||||
{ key: "month", label: "本月" },
|
|
||||||
{ key: "prevMonth", label: "上月" },
|
|
||||||
{ key: "custom", label: "自定义" }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const GRANULARITIES = [
|
|
||||||
{ key: "day", label: "日" },
|
|
||||||
{ key: "week", label: "周" },
|
|
||||||
{ key: "month", label: "月" }
|
|
||||||
];
|
|
||||||
|
|
||||||
export function createDefaultFilters() {
|
|
||||||
return {
|
|
||||||
apps: [ALL],
|
|
||||||
customEnd: "",
|
|
||||||
customStart: "",
|
|
||||||
granularity: "day",
|
|
||||||
preset: "yesterday",
|
|
||||||
regions: [ALL]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveDateRange(filters, timeZone) {
|
|
||||||
const today = todayRange(timeZone).start;
|
|
||||||
switch (filters.preset) {
|
|
||||||
case "today":
|
|
||||||
return { end: today, start: today };
|
|
||||||
case "yesterday": {
|
|
||||||
const yesterday = shiftDate(today, -1);
|
|
||||||
return { end: yesterday, start: yesterday };
|
|
||||||
}
|
|
||||||
case "last7": {
|
|
||||||
const range = lastDaysRange(7, timeZone);
|
|
||||||
return { end: range.end, start: range.start };
|
|
||||||
}
|
|
||||||
case "last30": {
|
|
||||||
const range = lastDaysRange(30, timeZone);
|
|
||||||
return { end: range.end, start: range.start };
|
|
||||||
}
|
|
||||||
case "month": {
|
|
||||||
const range = thisMonthRange(timeZone);
|
|
||||||
return { end: range.end, start: range.start };
|
|
||||||
}
|
|
||||||
case "prevMonth": {
|
|
||||||
const monthStart = thisMonthRange(timeZone).start;
|
|
||||||
const prevEnd = shiftDate(monthStart, -1);
|
|
||||||
return { end: prevEnd, start: `${prevEnd.slice(0, 7)}-01` };
|
|
||||||
}
|
|
||||||
case "custom": {
|
|
||||||
const start = normalizeDateText(filters.customStart) || shiftDate(today, -7);
|
|
||||||
const end = normalizeDateText(filters.customEnd) || today;
|
|
||||||
return end < start ? { end: start, start: end } : { end, start };
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
const yesterday = shiftDate(today, -1);
|
|
||||||
return { end: yesterday, start: yesterday };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function rangeDayCount(range) {
|
|
||||||
const start = Date.parse(`${range.start}T00:00:00Z`);
|
|
||||||
const end = Date.parse(`${range.end}T00:00:00Z`);
|
|
||||||
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
return Math.round((end - start) / 86400000) + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function rangeLabel(range) {
|
|
||||||
return range.start === range.end ? range.start : `${range.start} ~ ${range.end}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shiftDate(dateText, days) {
|
|
||||||
const [year, month, day] = String(dateText).split("-").map(Number);
|
|
||||||
const date = new Date(Date.UTC(year || 1970, (month || 1) - 1, (day || 1) + days));
|
|
||||||
return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDateText(value) {
|
|
||||||
const text = String(value || "").trim();
|
|
||||||
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function pad2(value) {
|
|
||||||
return String(value).padStart(2, "0");
|
|
||||||
}
|
|
||||||
|
|
||||||
// stat_day → 周/月粒度的分桶 key;周取周一为锚点,与运营周报口径一致。
|
|
||||||
export function bucketDay(statDay, granularity) {
|
|
||||||
if (granularity === "month") {
|
|
||||||
return String(statDay).slice(0, 7);
|
|
||||||
}
|
|
||||||
if (granularity === "week") {
|
|
||||||
const time = Date.parse(`${statDay}T00:00:00Z`);
|
|
||||||
if (!Number.isFinite(time)) {
|
|
||||||
return statDay;
|
|
||||||
}
|
|
||||||
const date = new Date(time);
|
|
||||||
const weekday = (date.getUTCDay() + 6) % 7;
|
|
||||||
date.setUTCDate(date.getUTCDate() - weekday);
|
|
||||||
return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())}`;
|
|
||||||
}
|
|
||||||
return statDay;
|
|
||||||
}
|
|
||||||
|
|
||||||
const URL_KEYS = ["preset", "start", "end", "apps", "regions", "view", "granularity"];
|
|
||||||
|
|
||||||
export function readStateFromURL(location = window.location) {
|
|
||||||
const params = new URLSearchParams(location.search);
|
|
||||||
const filters = createDefaultFilters();
|
|
||||||
let view = "";
|
|
||||||
if (params.get("preset") && DATE_PRESETS.some((item) => item.key === params.get("preset"))) {
|
|
||||||
filters.preset = params.get("preset");
|
|
||||||
}
|
|
||||||
filters.customStart = normalizeDateText(params.get("start"));
|
|
||||||
filters.customEnd = normalizeDateText(params.get("end"));
|
|
||||||
if (params.get("apps")) {
|
|
||||||
filters.apps = params.get("apps").split(",").map((item) => item.trim().toLowerCase()).filter(Boolean);
|
|
||||||
}
|
|
||||||
if (params.get("regions")) {
|
|
||||||
filters.regions = params.get("regions").split(",").map((item) => item.trim()).filter(Boolean);
|
|
||||||
}
|
|
||||||
if (params.get("granularity") && GRANULARITIES.some((item) => item.key === params.get("granularity"))) {
|
|
||||||
filters.granularity = params.get("granularity");
|
|
||||||
}
|
|
||||||
if (params.get("view")) {
|
|
||||||
view = params.get("view");
|
|
||||||
}
|
|
||||||
if (!filters.apps.length) {
|
|
||||||
filters.apps = [ALL];
|
|
||||||
}
|
|
||||||
if (!filters.regions.length) {
|
|
||||||
filters.regions = [ALL];
|
|
||||||
}
|
|
||||||
return { filters, view };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function writeStateToURL(filters, view, history = window.history, location = window.location) {
|
|
||||||
const params = new URLSearchParams(location.search);
|
|
||||||
URL_KEYS.forEach((key) => params.delete(key));
|
|
||||||
if (filters.preset !== "yesterday") {
|
|
||||||
params.set("preset", filters.preset);
|
|
||||||
}
|
|
||||||
if (filters.preset === "custom") {
|
|
||||||
if (filters.customStart) {
|
|
||||||
params.set("start", filters.customStart);
|
|
||||||
}
|
|
||||||
if (filters.customEnd) {
|
|
||||||
params.set("end", filters.customEnd);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (filters.apps.length && !filters.apps.includes(ALL)) {
|
|
||||||
params.set("apps", filters.apps.join(","));
|
|
||||||
}
|
|
||||||
if (filters.regions.length && !filters.regions.includes(ALL)) {
|
|
||||||
params.set("regions", filters.regions.join(","));
|
|
||||||
}
|
|
||||||
if (filters.granularity !== "day") {
|
|
||||||
params.set("granularity", filters.granularity);
|
|
||||||
}
|
|
||||||
if (view && view !== "overview") {
|
|
||||||
params.set("view", view);
|
|
||||||
}
|
|
||||||
const query = params.toString();
|
|
||||||
history.replaceState(null, "", `${location.pathname}${query ? `?${query}` : ""}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 区域选择值格式:整 App = `app:<appCode>`,单区域 = `<appCode>:<regionId>`。
|
|
||||||
export function regionSelectionMatches(selection, appCode, regionId) {
|
|
||||||
if (!selection?.length || selection.includes(ALL)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return selection.includes(`app:${appCode}`) || selection.includes(`${appCode}:${regionId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function appSelectionMatches(selection, appCode) {
|
|
||||||
if (!selection?.length || selection.includes(ALL)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return selection.includes(String(appCode || "").toLowerCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toggleMultiValue(current, value, allValues) {
|
|
||||||
if (value === ALL) {
|
|
||||||
return [ALL];
|
|
||||||
}
|
|
||||||
const withoutAll = current.filter((item) => item !== ALL);
|
|
||||||
const next = withoutAll.includes(value) ? withoutAll.filter((item) => item !== value) : [...withoutAll, value];
|
|
||||||
if (!next.length || (allValues?.length && allValues.every((item) => next.includes(item)))) {
|
|
||||||
return [ALL];
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
@ -1,238 +0,0 @@
|
|||||||
// 社交 BI v2 的数据层:负责 master/overview/funnel/kpi 接口的拉取与派生行,
|
|
||||||
// 视图不直接调 API,只消费这里的产物(原始 snake_case 指标行 + 维度字段)。
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { fetchSocialBiFunnel, fetchSocialBiKpi, fetchSocialBiMaster, fetchSocialBiOverview } from "../api.js";
|
|
||||||
import { DEFAULT_TIME_ZONE, rangeEndMs, rangeStartMs } from "../utils/time.js";
|
|
||||||
import { ALL, appSelectionMatches, regionSelectionMatches, resolveDateRange } from "./state.js";
|
|
||||||
|
|
||||||
export const SOCIAL_BI_TZ = DEFAULT_TIME_ZONE;
|
|
||||||
export const SOCIAL_BI_FUNNEL_APP_CODES = ["lalu", "huwaa", "fami"];
|
|
||||||
|
|
||||||
export function useSocialBiData(filters) {
|
|
||||||
const [master, setMaster] = useState(null);
|
|
||||||
// 数据请求等 master 结果落地后再发:否则会先用兜底 App 列表打一轮,master 到达后立刻重打一轮(整轮浪费)。
|
|
||||||
const [isMasterSettled, setIsMasterSettled] = useState(false);
|
|
||||||
const [overview, setOverview] = useState(null);
|
|
||||||
const [funnel, setFunnel] = useState(null);
|
|
||||||
const [kpi, setKpi] = useState(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [errors, setErrors] = useState([]);
|
|
||||||
const [refreshToken, setRefreshToken] = useState(0);
|
|
||||||
const requestSeq = useRef(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let ignore = false;
|
|
||||||
fetchSocialBiMaster()
|
|
||||||
.then((data) => {
|
|
||||||
if (!ignore && data) {
|
|
||||||
setMaster(data);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (!ignore) {
|
|
||||||
setMaster(null);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (!ignore) {
|
|
||||||
setIsMasterSettled(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
ignore = true;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// range 只依赖日期相关筛选:切换粒度/区域/App 多选不应触发接口重拉(那些是纯前端派生)。
|
|
||||||
const { customEnd, customStart, preset } = filters;
|
|
||||||
const range = useMemo(
|
|
||||||
() => resolveDateRange({ customEnd, customStart, preset }, SOCIAL_BI_TZ),
|
|
||||||
[customEnd, customStart, preset]
|
|
||||||
);
|
|
||||||
const appCodes = useMemo(() => {
|
|
||||||
const available = (master?.apps || []).map((app) => app.app_code);
|
|
||||||
if (!filters.apps.length || filters.apps.includes(ALL)) {
|
|
||||||
return available;
|
|
||||||
}
|
|
||||||
return filters.apps.filter((appCode) => !available.length || available.includes(appCode));
|
|
||||||
}, [filters.apps, master]);
|
|
||||||
const funnelAppCodes = useMemo(() => {
|
|
||||||
const available = (master?.apps || []).map((app) => app.app_code).filter((appCode) => SOCIAL_BI_FUNNEL_APP_CODES.includes(appCode));
|
|
||||||
if (!filters.apps.length || filters.apps.includes(ALL)) {
|
|
||||||
return available;
|
|
||||||
}
|
|
||||||
const selected = filters.apps.filter((appCode) => available.includes(appCode));
|
|
||||||
return selected.length ? selected : available;
|
|
||||||
}, [filters.apps, master]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isMasterSettled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const sequence = ++requestSeq.current;
|
|
||||||
const startMs = rangeStartMs(range, SOCIAL_BI_TZ);
|
|
||||||
const endMs = rangeEndMs(range, SOCIAL_BI_TZ);
|
|
||||||
setIsLoading(true);
|
|
||||||
Promise.allSettled([
|
|
||||||
fetchSocialBiOverview({ appCodes, endMs, startMs, statTz: SOCIAL_BI_TZ }),
|
|
||||||
fetchSocialBiFunnel({ appCodes: funnelAppCodes, endMs, startMs, statTz: SOCIAL_BI_TZ }),
|
|
||||||
fetchSocialBiKpi({ appCodes, endMs, startMs, statTz: SOCIAL_BI_TZ })
|
|
||||||
]).then(([overviewResult, funnelResult, kpiResult]) => {
|
|
||||||
if (sequence !== requestSeq.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const nextErrors = [];
|
|
||||||
if (overviewResult.status === "fulfilled") {
|
|
||||||
setOverview(overviewResult.value);
|
|
||||||
(overviewResult.value?.apps || []).forEach((app) => {
|
|
||||||
if (app.error) {
|
|
||||||
nextErrors.push(`${app.app_name || app.app_code}: ${app.error}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setOverview(null);
|
|
||||||
nextErrors.push(`统计数据加载失败: ${overviewResult.reason?.message || "未知错误"}`);
|
|
||||||
}
|
|
||||||
if (funnelResult.status === "fulfilled") {
|
|
||||||
setFunnel(funnelResult.value);
|
|
||||||
(funnelResult.value?.apps || []).forEach((app) => {
|
|
||||||
if (app.error) {
|
|
||||||
nextErrors.push(`${app.app_name || app.app_code}: ${app.error}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setFunnel(null);
|
|
||||||
nextErrors.push(`埋点漏斗加载失败: ${funnelResult.reason?.message || "未知错误"}`);
|
|
||||||
}
|
|
||||||
if (kpiResult.status === "fulfilled") {
|
|
||||||
setKpi(kpiResult.value);
|
|
||||||
Object.entries(kpiResult.value?.app_errors || {}).forEach(([appCode, message]) => {
|
|
||||||
nextErrors.push(`${appCode}: ${message}`);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setKpi(null);
|
|
||||||
nextErrors.push(`运营充值数据加载失败: ${kpiResult.reason?.message || "未知错误"}`);
|
|
||||||
}
|
|
||||||
setErrors(nextErrors);
|
|
||||||
setIsLoading(false);
|
|
||||||
});
|
|
||||||
}, [appCodes, funnelAppCodes, isMasterSettled, range, refreshToken]);
|
|
||||||
|
|
||||||
const refresh = useCallback(() => {
|
|
||||||
setRefreshToken((current) => current + 1);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const derived = useMemo(() => deriveRows(overview, filters, master), [filters, master, overview]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
appCodes,
|
|
||||||
derived,
|
|
||||||
errors,
|
|
||||||
funnel,
|
|
||||||
funnelAppCodes,
|
|
||||||
isLoading,
|
|
||||||
kpi,
|
|
||||||
master,
|
|
||||||
overview,
|
|
||||||
range,
|
|
||||||
refresh
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// deriveRows 输出四类行,全部带维度字段 + 原始指标字段:
|
|
||||||
// appTotals/appDaily(App 粒度)、regionTotals/regionDaily(大区粒度,已按顶栏区域选择过滤)、
|
|
||||||
// countryTotals(国家粒度,已按国家→区域目录归一 region_id 并跟随区域筛选)。restricted=true 表示该 App 已被数据范围裁剪。
|
|
||||||
function deriveRows(overview, filters, master) {
|
|
||||||
const appTotals = [];
|
|
||||||
const appDaily = [];
|
|
||||||
const regionTotals = [];
|
|
||||||
const regionDaily = [];
|
|
||||||
const countryTotals = [];
|
|
||||||
const appErrors = [];
|
|
||||||
const regionByCountry = buildRegionByCountryIndex(master);
|
|
||||||
|
|
||||||
(overview?.apps || []).forEach((app) => {
|
|
||||||
if (!appSelectionMatches(filters.apps, app.app_code)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const context = {
|
|
||||||
app_code: app.app_code,
|
|
||||||
app_name: app.app_name || app.app_code,
|
|
||||||
kind: app.kind,
|
|
||||||
restricted: Boolean(app.restricted)
|
|
||||||
};
|
|
||||||
if (app.error) {
|
|
||||||
appErrors.push({ ...context, error: app.error });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
appTotals.push({ ...context, ...(app.total || {}) });
|
|
||||||
(app.daily_series || []).forEach((row) => {
|
|
||||||
appDaily.push({ ...context, ...row, stat_day: row.stat_day || row.label });
|
|
||||||
});
|
|
||||||
(app.region_breakdown || []).forEach((row) => {
|
|
||||||
if (!regionSelectionMatches(filters.regions, app.app_code, Number(row.region_id ?? 0))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
regionTotals.push({
|
|
||||||
...context,
|
|
||||||
...row,
|
|
||||||
region_id: Number(row.region_id ?? 0),
|
|
||||||
region_name: row.region_name || row.region_code || `区域 ${row.region_id}`
|
|
||||||
});
|
|
||||||
});
|
|
||||||
(app.daily_region_breakdown || []).forEach((row) => {
|
|
||||||
if (!regionSelectionMatches(filters.regions, app.app_code, Number(row.region_id ?? 0))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
regionDaily.push({
|
|
||||||
...context,
|
|
||||||
...row,
|
|
||||||
region_id: Number(row.region_id ?? 0),
|
|
||||||
region_name: row.region_name || row.region_code || `区域 ${row.region_id}`,
|
|
||||||
stat_day: row.stat_day || row.label
|
|
||||||
});
|
|
||||||
});
|
|
||||||
(app.country_breakdown || []).forEach((row) => {
|
|
||||||
const countryCode = row.country_code || String(row.country_id || "");
|
|
||||||
// legacy 国家行不带 region_id,用区域目录(countries 列表)归一,保证下钻与区域筛选口径一致。
|
|
||||||
let regionId = Number(row.region_id ?? 0);
|
|
||||||
let regionName = row.region_name || "";
|
|
||||||
if (!regionId) {
|
|
||||||
const region = regionByCountry.get(`${app.app_code}:${String(countryCode).toUpperCase()}`);
|
|
||||||
if (region) {
|
|
||||||
regionId = region.region_id;
|
|
||||||
regionName = region.region_name || region.region_code || regionName;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!regionSelectionMatches(filters.regions, app.app_code, regionId)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
countryTotals.push({
|
|
||||||
...context,
|
|
||||||
...row,
|
|
||||||
country_code: countryCode,
|
|
||||||
country_name: row.country_name || row.country || row.country_code || `国家 ${row.country_id}`,
|
|
||||||
region_id: regionId,
|
|
||||||
region_name: regionName
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// 顶栏选了区域时,App 合计/日行切换成所选区域行的聚合视角由各视图用 regionScoped 判断。
|
|
||||||
const regionScoped = Boolean(filters.regions.length && !filters.regions.includes(ALL));
|
|
||||||
return { appDaily, appErrors, appTotals, countryTotals, regionDaily, regionScoped, regionTotals };
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRegionByCountryIndex(master) {
|
|
||||||
const index = new Map();
|
|
||||||
(master?.regions || []).forEach((region) => {
|
|
||||||
(region.countries || []).forEach((countryCode) => {
|
|
||||||
const key = `${region.app_code}:${String(countryCode).toUpperCase()}`;
|
|
||||||
if (!index.has(key)) {
|
|
||||||
index.set(key, region);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return index;
|
|
||||||
}
|
|
||||||
@ -1,318 +0,0 @@
|
|||||||
// DataTableView:数据明细视图 —— Excel 式全字段宽表(维度切换 / 列组开关 / 指标排序 /
|
|
||||||
// 首列冻结 / CSV 导出)。行数据保持后端原始 snake_case 口径,渲染时经 metrics.js 统一格式化。
|
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import Button from "@mui/material/Button";
|
|
||||||
import FileDownloadOutlined from "@mui/icons-material/FileDownloadOutlined";
|
|
||||||
import { useSocialBi } from "../SocialBiApp.jsx";
|
|
||||||
import {
|
|
||||||
METRIC_GROUPS,
|
|
||||||
aggregateMetricMaps,
|
|
||||||
formatMetric,
|
|
||||||
metricChartValue,
|
|
||||||
metricLabel,
|
|
||||||
metricTooltip
|
|
||||||
} from "../metrics.js";
|
|
||||||
import { downloadCsv, isBlank } from "../format.js";
|
|
||||||
import { rangeLabel } from "../state.js";
|
|
||||||
import "./data-table-view.css";
|
|
||||||
|
|
||||||
const GROUPS_STORAGE_KEY = "hyapp-admin.sbi-table-groups";
|
|
||||||
const MAX_RENDER_ROWS = 400;
|
|
||||||
const SKELETON_WIDTHS = ["42%", "100%", "96%", "91%", "86%", "81%"];
|
|
||||||
const EMPTY_ROWS = [];
|
|
||||||
|
|
||||||
// 维度列定义:value 取行上的展示文本,totalText 是合计行占位(日期列"汇总",其余"全部")。
|
|
||||||
const DIM_COLUMNS = {
|
|
||||||
app: { label: "App", totalText: "全部", value: (row) => row.app_name || row.app_code || "--" },
|
|
||||||
country: { label: "国家", totalText: "全部", value: (row) => row.country_name || row.country_code || "--" },
|
|
||||||
day: { label: "日期", totalText: "汇总", value: (row) => row.stat_day || "--" },
|
|
||||||
region: { label: "区域", totalText: "全部", value: (row) => row.region_name || row.region_code || "--" }
|
|
||||||
};
|
|
||||||
|
|
||||||
// 五种明细维度:key 与 derived 的行集字段一一对应。
|
|
||||||
const DIMENSIONS = [
|
|
||||||
{ csvName: "app-daily", dims: ["day", "app"], hasDay: true, key: "appDaily", label: "App×日" },
|
|
||||||
{ csvName: "app-total", dims: ["app"], hasDay: false, key: "appTotals", label: "App 合计" },
|
|
||||||
{ csvName: "region-daily", dims: ["day", "app", "region"], hasDay: true, key: "regionDaily", label: "区域×日" },
|
|
||||||
{ csvName: "region-total", dims: ["app", "region"], hasDay: false, key: "regionTotals", label: "区域合计" },
|
|
||||||
{ csvName: "country", dims: ["app", "country"], hasDay: false, key: "countryTotals", label: "国家" }
|
|
||||||
];
|
|
||||||
|
|
||||||
function readClosedGroups() {
|
|
||||||
try {
|
|
||||||
const raw = window.localStorage.getItem(GROUPS_STORAGE_KEY);
|
|
||||||
const parsed = raw ? JSON.parse(raw) : [];
|
|
||||||
if (Array.isArray(parsed)) {
|
|
||||||
const validKeys = new Set(METRIC_GROUPS.map((group) => group.key));
|
|
||||||
return parsed.filter((key) => validKeys.has(key));
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// localStorage 不可用或内容损坏时回退为全部列组展开。
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeClosedGroups(keys) {
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem(GROUPS_STORAGE_KEY, JSON.stringify(keys));
|
|
||||||
} catch {
|
|
||||||
// 存储失败只影响持久化,不影响本次会话内的开关状态。
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function metricSortValue(row, key) {
|
|
||||||
const value = row?.[key];
|
|
||||||
if (isBlank(value)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const numeric = Number(value);
|
|
||||||
return Number.isFinite(numeric) ? numeric : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// null 视为最小:desc 时缺失口径沉底,asc 时排最前。
|
|
||||||
function compareMetric(a, b, key, dir) {
|
|
||||||
const left = metricSortValue(a, key);
|
|
||||||
const right = metricSortValue(b, key);
|
|
||||||
const leftValue = left === null ? Number.NEGATIVE_INFINITY : left;
|
|
||||||
const rightValue = right === null ? Number.NEGATIVE_INFINITY : right;
|
|
||||||
if (leftValue === rightValue) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return dir === "asc" ? leftValue - rightValue : rightValue - leftValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rowIdentity(row, dimKey, index) {
|
|
||||||
const parts = [row.app_code, row.region_id, row.country_code, row.stat_day].filter(
|
|
||||||
(part) => part !== undefined && part !== null && part !== ""
|
|
||||||
);
|
|
||||||
return parts.length ? `${dimKey}:${parts.join("|")}` : `${dimKey}:${index}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DataTableView() {
|
|
||||||
const { derived, isLoading, range } = useSocialBi();
|
|
||||||
const [dimKey, setDimKey] = useState("appDaily");
|
|
||||||
const [closedGroups, setClosedGroups] = useState(readClosedGroups);
|
|
||||||
const [sort, setSort] = useState(null);
|
|
||||||
|
|
||||||
const dim = DIMENSIONS.find((item) => item.key === dimKey) || DIMENSIONS[0];
|
|
||||||
const dimColumns = dim.dims.map((name) => DIM_COLUMNS[name]);
|
|
||||||
const hasDay = dim.hasDay;
|
|
||||||
const baseRows = derived?.[dim.key] || EMPTY_ROWS;
|
|
||||||
|
|
||||||
const visibleGroups = useMemo(() => METRIC_GROUPS.filter((group) => !closedGroups.includes(group.key)), [closedGroups]);
|
|
||||||
const visibleMetrics = useMemo(() => visibleGroups.flatMap((group) => group.metrics), [visibleGroups]);
|
|
||||||
|
|
||||||
// 排序列被列组开关隐藏时回退到维度默认排序。
|
|
||||||
const activeSort = sort && visibleMetrics.includes(sort.key) ? sort : null;
|
|
||||||
|
|
||||||
// 排序/合计每次渲染重算(行集千行量级可接受);react-hooks 的 preserve-manual-memoization
|
|
||||||
// 规则不允许在这里包 useMemo(依赖来自模块常量推导,编译器无法证明不可变)。
|
|
||||||
const sortedRows = [...baseRows];
|
|
||||||
if (activeSort) {
|
|
||||||
sortedRows.sort((a, b) => compareMetric(a, b, activeSort.key, activeSort.dir));
|
|
||||||
} else if (hasDay) {
|
|
||||||
sortedRows.sort((a, b) => {
|
|
||||||
if (a.stat_day !== b.stat_day) {
|
|
||||||
return String(a.stat_day) < String(b.stat_day) ? 1 : -1;
|
|
||||||
}
|
|
||||||
return compareMetric(a, b, "recharge_usd_minor", "desc");
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
sortedRows.sort((a, b) => compareMetric(a, b, "recharge_usd_minor", "desc"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 日维度的汇总跨天:余额快照类指标(金币数量/存量工资)只取最后一天,避免按天数放大。
|
|
||||||
const totalRow = sortedRows.length ? aggregateMetricMaps(sortedRows, { acrossTime: hasDay }) : null;
|
|
||||||
const renderRows = sortedRows.slice(0, MAX_RENDER_ROWS);
|
|
||||||
const isTruncated = sortedRows.length > MAX_RENDER_ROWS;
|
|
||||||
const hasRestricted = baseRows.some((row) => row.restricted);
|
|
||||||
const columnCount = dimColumns.length + visibleMetrics.length;
|
|
||||||
|
|
||||||
const handleDimChange = (key) => {
|
|
||||||
setDimKey(key);
|
|
||||||
setSort(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleGroup = (key) => {
|
|
||||||
const next = closedGroups.includes(key) ? closedGroups.filter((item) => item !== key) : [...closedGroups, key];
|
|
||||||
setClosedGroups(next);
|
|
||||||
writeClosedGroups(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSort = (key) => {
|
|
||||||
setSort((current) => (current?.key === key ? { dir: current.dir === "desc" ? "asc" : "desc", key } : { dir: "desc", key }));
|
|
||||||
};
|
|
||||||
|
|
||||||
// 导出当前可见列与排序后的全部行:金额转美元、比例转百分数、缺失导出空串。
|
|
||||||
const handleExport = () => {
|
|
||||||
const header = [...dimColumns.map((column) => column.label), ...visibleMetrics.map((key) => metricLabel(key))];
|
|
||||||
const rows = sortedRows.map((row) => [
|
|
||||||
...dimColumns.map((column) => column.value(row)),
|
|
||||||
...visibleMetrics.map((key) => {
|
|
||||||
const value = metricChartValue(key, row[key]);
|
|
||||||
return value === null ? "" : value;
|
|
||||||
})
|
|
||||||
]);
|
|
||||||
downloadCsv(`social-bi-${dim.csvName}-${rangeLabel(range).replace(/\s+/g, "")}.csv`, header, rows);
|
|
||||||
};
|
|
||||||
|
|
||||||
let content;
|
|
||||||
if (isLoading && !baseRows.length) {
|
|
||||||
content = (
|
|
||||||
<div className="sbi-dt-skeleton" aria-label="数据加载中">
|
|
||||||
{SKELETON_WIDTHS.map((width) => (
|
|
||||||
<span className="sbi-skeleton" key={width} style={{ width }} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else if (!baseRows.length) {
|
|
||||||
content = (
|
|
||||||
<div className="sbi-empty">
|
|
||||||
<strong>当前维度暂无数据</strong>
|
|
||||||
<span>
|
|
||||||
{dim.dims.includes("region")
|
|
||||||
? "该区间没有区域级数据,可能尚未配置区域目录;试试更换日期区间或切换其它维度"
|
|
||||||
: "试试更换日期区间、调整 App 筛选或切换其它维度"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else if (!visibleMetrics.length) {
|
|
||||||
content = (
|
|
||||||
<div className="sbi-empty">
|
|
||||||
<strong>所有列组已隐藏</strong>
|
|
||||||
<span>点击上方列组开关,恢复需要查看的指标列</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
content = (
|
|
||||||
<div className="sbi-table-scroll sbi-dt-scroll">
|
|
||||||
<table className="sbi-table sbi-dt-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
{dimColumns.map((column, index) => (
|
|
||||||
<th className={index === 0 ? "is-left sbi-dt-sticky" : "is-left"} key={column.label} rowSpan={2}>
|
|
||||||
{column.label}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
{visibleGroups.map((group) => (
|
|
||||||
<th className="sbi-dt-group-th" colSpan={group.metrics.length} key={group.key}>
|
|
||||||
{group.label}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
{visibleMetrics.map((key) => {
|
|
||||||
const isSorted = activeSort?.key === key;
|
|
||||||
return (
|
|
||||||
<th
|
|
||||||
aria-sort={isSorted ? (activeSort.dir === "desc" ? "descending" : "ascending") : undefined}
|
|
||||||
className={isSorted ? "sbi-dt-metric-th is-sorted" : "sbi-dt-metric-th"}
|
|
||||||
key={key}
|
|
||||||
onClick={() => handleSort(key)}
|
|
||||||
>
|
|
||||||
<span className="sbi-metric-hint" title={metricTooltip(key)}>
|
|
||||||
{metricLabel(key)}
|
|
||||||
</span>
|
|
||||||
{isSorted ? <span className="sbi-dt-sort">{activeSort.dir === "desc" ? "↓" : "↑"}</span> : null}
|
|
||||||
</th>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{totalRow ? (
|
|
||||||
<tr className="is-total">
|
|
||||||
{dimColumns.map((column, index) => (
|
|
||||||
<td className={index === 0 ? "is-left sbi-dt-sticky" : "is-left"} key={column.label}>
|
|
||||||
{column.totalText}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
{visibleMetrics.map((key) => (
|
|
||||||
<td key={key}>{formatMetric(key, totalRow[key])}</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
) : null}
|
|
||||||
{renderRows.map((row, rowIndex) => (
|
|
||||||
<tr key={rowIdentity(row, dim.key, rowIndex)}>
|
|
||||||
{dimColumns.map((column, index) => (
|
|
||||||
<td className={index === 0 ? "is-left sbi-dt-sticky" : "is-left"} key={column.label}>
|
|
||||||
{column.value(row)}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
{visibleMetrics.map((key) => (
|
|
||||||
<td key={key}>{formatMetric(key, row[key])}</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
{isTruncated ? (
|
|
||||||
<tfoot>
|
|
||||||
<tr className="sbi-dt-note">
|
|
||||||
<td colSpan={columnCount}>已截断,共 {sortedRows.length} 行,可导出 CSV 查看全部</td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
) : null}
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="sbi-card">
|
|
||||||
<div className="sbi-card-header">
|
|
||||||
<strong>数据明细</strong>
|
|
||||||
<small>
|
|
||||||
{rangeLabel(range)} · 共 {sortedRows.length} 行
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
<div className="sbi-dt-tools">
|
|
||||||
<div className="sbi-seg" role="radiogroup" aria-label="明细维度">
|
|
||||||
{DIMENSIONS.map((item) => (
|
|
||||||
<button
|
|
||||||
aria-checked={dim.key === item.key}
|
|
||||||
className={dim.key === item.key ? "is-active" : ""}
|
|
||||||
key={item.key}
|
|
||||||
onClick={() => handleDimChange(item.key)}
|
|
||||||
role="radio"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="sbi-dt-groups" role="group" aria-label="列组开关">
|
|
||||||
<span className="sbi-dt-groups-label">列组</span>
|
|
||||||
{METRIC_GROUPS.map((group) => {
|
|
||||||
const isOpen = !closedGroups.includes(group.key);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
aria-pressed={isOpen}
|
|
||||||
className={isOpen ? "sbi-chip is-active" : "sbi-chip"}
|
|
||||||
key={group.key}
|
|
||||||
onClick={() => toggleGroup(group.key)}
|
|
||||||
title={isOpen ? "点击隐藏该组指标列" : "点击显示该组指标列"}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{group.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{hasRestricted ? <span className="sbi-badge">已按数据范围过滤</span> : null}
|
|
||||||
<div className="sbi-dt-export">
|
|
||||||
<Button
|
|
||||||
disabled={!sortedRows.length || !visibleMetrics.length}
|
|
||||||
onClick={handleExport}
|
|
||||||
size="small"
|
|
||||||
startIcon={<FileDownloadOutlined />}
|
|
||||||
variant="outlined"
|
|
||||||
>
|
|
||||||
导出 CSV
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{content}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,382 +0,0 @@
|
|||||||
// 埋点漏斗:展示 App 原始 tracking 事件的总体漏斗、失败诊断和 D1 cohort。
|
|
||||||
// 数据源为 statistics-service 的 app_tracking_events 聚合结果,视图只做跨 App 求和和排序。
|
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { EChart } from "../../charts/EChart.jsx";
|
|
||||||
import { formatCount, formatRatioPercent, isBlank } from "../format.js";
|
|
||||||
import { rangeLabel } from "../state.js";
|
|
||||||
import { useSocialBi } from "../SocialBiApp.jsx";
|
|
||||||
import "./funnel-view.css";
|
|
||||||
|
|
||||||
const COHORT_DIMENSIONS = [
|
|
||||||
{ key: "country", label: "国家" },
|
|
||||||
{ key: "language", label: "语言" },
|
|
||||||
{ key: "channel", label: "渠道" },
|
|
||||||
{ key: "login_method", label: "登录方式" },
|
|
||||||
{ key: "first_room_stay", label: "首房停留时长" }
|
|
||||||
];
|
|
||||||
|
|
||||||
const COHORT_STEP_COLUMNS = [
|
|
||||||
{ key: "login_success", label: "登录成功" },
|
|
||||||
{ key: "profile_complete", label: "资料完成" },
|
|
||||||
{ key: "room_join_success", label: "进房成功" },
|
|
||||||
{ key: "stay_3m", label: "停留 3m" },
|
|
||||||
{ key: "stay_10m", label: "停留 10m" },
|
|
||||||
{ key: "send_message", label: "发消息" }
|
|
||||||
];
|
|
||||||
|
|
||||||
const SUMMARY_CARDS = [
|
|
||||||
{ key: "login_start_users", label: "登录开始", type: "count" },
|
|
||||||
{ key: "login_success_rate", label: "登录成功率", type: "ratio" },
|
|
||||||
{ key: "room_join_success_users", label: "进房成功", type: "count" },
|
|
||||||
{ key: "d1_retention_rate", label: "D1 留存", type: "ratio" },
|
|
||||||
{ key: "room_join_fail_users", label: "进房失败", type: "count" }
|
|
||||||
];
|
|
||||||
|
|
||||||
const SUPPORTED_FUNNEL_APPS = "Lalu / Huwaa / Fami";
|
|
||||||
|
|
||||||
export function FunnelView() {
|
|
||||||
const { funnel, isLoading, range } = useSocialBi();
|
|
||||||
const [dimension, setDimension] = useState("country");
|
|
||||||
const appRows = useMemo(() => (funnel?.apps || []).filter((app) => !app.error), [funnel]);
|
|
||||||
const appErrors = useMemo(() => (funnel?.apps || []).filter((app) => app.error), [funnel]);
|
|
||||||
const steps = useMemo(() => aggregateSteps(appRows), [appRows]);
|
|
||||||
const totals = useMemo(() => aggregateTotals(appRows), [appRows]);
|
|
||||||
const cohorts = useMemo(() => aggregateCohorts(appRows), [appRows]);
|
|
||||||
const selectedCohorts = useMemo(
|
|
||||||
() => (cohorts.get(dimension) || []).slice(0, 50),
|
|
||||||
[cohorts, dimension]
|
|
||||||
);
|
|
||||||
const chartOption = useMemo(() => funnelChartOption(steps), [steps]);
|
|
||||||
|
|
||||||
if (isLoading && !appRows.length) {
|
|
||||||
return (
|
|
||||||
<div className="sbi-funnel" aria-label="埋点漏斗加载中">
|
|
||||||
<div className="sbi-funnel-skeleton">
|
|
||||||
{["70%", "92%", "84%", "76%", "88%"].map((width) => (
|
|
||||||
<span className="sbi-skeleton" key={width} style={{ width }} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!appRows.length) {
|
|
||||||
return (
|
|
||||||
<div className="sbi-funnel">
|
|
||||||
<div className="sbi-empty">
|
|
||||||
<strong>当前无埋点漏斗数据</strong>
|
|
||||||
<span>
|
|
||||||
{appErrors.length
|
|
||||||
? "所选 App 暂未接入漏斗或统计服务返回错误"
|
|
||||||
: `埋点漏斗 App 筛选目前仅支持 ${SUPPORTED_FUNNEL_APPS}`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="sbi-funnel">
|
|
||||||
<div className="sbi-funnel-head">
|
|
||||||
<div>
|
|
||||||
<h1>埋点漏斗</h1>
|
|
||||||
<span>
|
|
||||||
{rangeLabel(range)} · 仅支持 {SUPPORTED_FUNNEL_APPS}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="sbi-funnel-apps">
|
|
||||||
{appRows.map((app) => (
|
|
||||||
<span key={app.app_code}>{app.app_name || app.app_code}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{appErrors.length ? (
|
|
||||||
<div className="sbi-funnel-warning">
|
|
||||||
{appErrors.map((app) => (
|
|
||||||
<span key={app.app_code}>
|
|
||||||
{app.app_name || app.app_code}: {app.error}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<section className="sbi-funnel-summary" aria-label="漏斗核心指标">
|
|
||||||
{SUMMARY_CARDS.map((item) => (
|
|
||||||
<article className="sbi-card sbi-funnel-kpi" key={item.key}>
|
|
||||||
<span>{item.label}</span>
|
|
||||||
<strong>{item.type === "ratio" ? formatRatioPercent(totals[item.key]) : formatCount(totals[item.key])}</strong>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="sbi-funnel-grid">
|
|
||||||
<article className="sbi-card sbi-funnel-chart-card">
|
|
||||||
<div className="sbi-card-title">
|
|
||||||
<div>
|
|
||||||
<h2>主路径转化</h2>
|
|
||||||
<span>按去重用户数计算</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<EChart className="sbi-funnel-chart" option={chartOption} />
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article className="sbi-card sbi-funnel-table-card">
|
|
||||||
<div className="sbi-card-title">
|
|
||||||
<div>
|
|
||||||
<h2>事件明细</h2>
|
|
||||||
<span>含进房失败和互动动作</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="sbi-table-scroll sbi-funnel-step-scroll">
|
|
||||||
<table className="sbi-table sbi-funnel-step-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th className="is-left">事件</th>
|
|
||||||
<th>用户</th>
|
|
||||||
<th>次数</th>
|
|
||||||
<th>上一步</th>
|
|
||||||
<th>总转化</th>
|
|
||||||
<th>流失</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{steps.map((step) => (
|
|
||||||
<tr className={step.is_failure ? "is-failure" : ""} key={step.event_name}>
|
|
||||||
<td className="is-left">
|
|
||||||
<strong>{step.label || step.event_name}</strong>
|
|
||||||
<span>{step.event_name}</span>
|
|
||||||
</td>
|
|
||||||
<td>{formatCount(step.user_count)}</td>
|
|
||||||
<td>{formatCount(step.event_count)}</td>
|
|
||||||
<td>{formatRatioPercent(step.previous_conversion_rate)}</td>
|
|
||||||
<td>{formatRatioPercent(step.overall_conversion_rate)}</td>
|
|
||||||
<td>{formatCount(step.dropoff_users)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="sbi-card sbi-funnel-cohort-card">
|
|
||||||
<div className="sbi-card-title sbi-funnel-cohort-title">
|
|
||||||
<div>
|
|
||||||
<h2>D1 Cohort</h2>
|
|
||||||
<span>基准用户为登录成功用户</span>
|
|
||||||
</div>
|
|
||||||
<div className="sbi-funnel-tabs" role="tablist" aria-label="D1 cohort 维度">
|
|
||||||
{COHORT_DIMENSIONS.map((item) => (
|
|
||||||
<button
|
|
||||||
aria-selected={dimension === item.key}
|
|
||||||
className={dimension === item.key ? "is-active" : ""}
|
|
||||||
key={item.key}
|
|
||||||
onClick={() => setDimension(item.key)}
|
|
||||||
role="tab"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="sbi-table-scroll sbi-funnel-cohort-scroll">
|
|
||||||
<table className="sbi-table sbi-funnel-cohort-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th className="is-left">{COHORT_DIMENSIONS.find((item) => item.key === dimension)?.label || "Cohort"}</th>
|
|
||||||
<th>基准用户</th>
|
|
||||||
<th>D1 用户</th>
|
|
||||||
<th>D1 留存</th>
|
|
||||||
{COHORT_STEP_COLUMNS.map((item) => (
|
|
||||||
<th key={item.key}>{item.label}</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{selectedCohorts.length ? (
|
|
||||||
selectedCohorts.map((row) => (
|
|
||||||
<tr key={`${row.dimension}:${row.value}`}>
|
|
||||||
<td className="is-left">
|
|
||||||
<strong>{row.label || row.value || "unknown"}</strong>
|
|
||||||
</td>
|
|
||||||
<td>{formatCount(row.base_users)}</td>
|
|
||||||
<td>{formatCount(row.d1_retention_users)}</td>
|
|
||||||
<td>{formatRatioPercent(row.d1_retention_rate)}</td>
|
|
||||||
{COHORT_STEP_COLUMNS.map((item) => (
|
|
||||||
<td key={item.key}>{formatCount(stepUserCount(row, item.key))}</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<tr>
|
|
||||||
<td className="sbi-funnel-empty-cell" colSpan={4 + COHORT_STEP_COLUMNS.length}>
|
|
||||||
当前维度暂无 cohort 数据
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function aggregateSteps(appRows) {
|
|
||||||
const order = [];
|
|
||||||
const byEvent = new Map();
|
|
||||||
appRows.forEach((app) => {
|
|
||||||
(app.steps || []).forEach((step) => {
|
|
||||||
const key = step.event_name || step.key;
|
|
||||||
if (!key) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!byEvent.has(key)) {
|
|
||||||
order.push(key);
|
|
||||||
byEvent.set(key, {
|
|
||||||
...step,
|
|
||||||
device_count: 0,
|
|
||||||
event_count: 0,
|
|
||||||
user_count: 0
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const current = byEvent.get(key);
|
|
||||||
current.device_count += Number(step.device_count || 0);
|
|
||||||
current.event_count += Number(step.event_count || 0);
|
|
||||||
current.user_count += Number(step.user_count || 0);
|
|
||||||
current.is_failure = Boolean(current.is_failure || step.is_failure);
|
|
||||||
if (!current.label) {
|
|
||||||
current.label = step.label;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
const baseUsers = Number(byEvent.get("login_start")?.user_count || (order.length ? byEvent.get(order[0])?.user_count || 0 : 0));
|
|
||||||
let previousUsers = 0;
|
|
||||||
return order.map((key) => {
|
|
||||||
const step = byEvent.get(key);
|
|
||||||
const userCount = Number(step.user_count || 0);
|
|
||||||
const out = {
|
|
||||||
...step,
|
|
||||||
dropoff_users: previousUsers > userCount ? previousUsers - userCount : 0,
|
|
||||||
overall_conversion_rate: ratio(userCount, baseUsers),
|
|
||||||
previous_conversion_rate: ratio(userCount, previousUsers)
|
|
||||||
};
|
|
||||||
previousUsers = userCount;
|
|
||||||
return out;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function aggregateTotals(appRows) {
|
|
||||||
const totals = {};
|
|
||||||
let d1Base = 0;
|
|
||||||
let d1Users = 0;
|
|
||||||
appRows.forEach((app) => {
|
|
||||||
Object.entries(app.totals || {}).forEach(([key, value]) => {
|
|
||||||
if (key.endsWith("_rate")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
totals[key] = Number(totals[key] || 0) + Number(value || 0);
|
|
||||||
});
|
|
||||||
d1Base += Number(app.totals?.d1_retention_base_users || 0);
|
|
||||||
d1Users += Number(app.totals?.d1_retention_users || 0);
|
|
||||||
});
|
|
||||||
totals.login_success_rate = ratio(totals.login_success_users, totals.login_start_users);
|
|
||||||
totals.d1_retention_rate = ratio(d1Users, d1Base);
|
|
||||||
return totals;
|
|
||||||
}
|
|
||||||
|
|
||||||
function aggregateCohorts(appRows) {
|
|
||||||
const grouped = new Map();
|
|
||||||
appRows.forEach((app) => {
|
|
||||||
(app.cohorts || []).forEach((cohort) => {
|
|
||||||
const dimension = cohort.dimension || "unknown";
|
|
||||||
const value = cohort.value || cohort.label || "unknown";
|
|
||||||
if (!grouped.has(dimension)) {
|
|
||||||
grouped.set(dimension, new Map());
|
|
||||||
}
|
|
||||||
const bucket = grouped.get(dimension);
|
|
||||||
if (!bucket.has(value)) {
|
|
||||||
bucket.set(value, {
|
|
||||||
dimension,
|
|
||||||
label: cohort.label || value,
|
|
||||||
value,
|
|
||||||
base_users: 0,
|
|
||||||
d1_retention_users: 0,
|
|
||||||
d1_retention_rate: 0,
|
|
||||||
steps: new Map()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const row = bucket.get(value);
|
|
||||||
row.base_users += Number(cohort.base_users || 0);
|
|
||||||
row.d1_retention_users += Number(cohort.d1_retention_users || 0);
|
|
||||||
(cohort.steps || []).forEach((step) => {
|
|
||||||
const eventName = step.event_name;
|
|
||||||
if (!eventName) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
row.steps.set(eventName, Number(row.steps.get(eventName) || 0) + Number(step.user_count || 0));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
const out = new Map();
|
|
||||||
grouped.forEach((bucket, dimension) => {
|
|
||||||
const rows = [...bucket.values()].map((row) => ({
|
|
||||||
...row,
|
|
||||||
d1_retention_rate: ratio(row.d1_retention_users, row.base_users),
|
|
||||||
steps: [...row.steps.entries()].map(([event_name, user_count]) => ({ event_name, user_count }))
|
|
||||||
}));
|
|
||||||
rows.sort((left, right) => Number(right.base_users || 0) - Number(left.base_users || 0));
|
|
||||||
out.set(dimension, rows);
|
|
||||||
});
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stepUserCount(row, eventName) {
|
|
||||||
const step = (row.steps || []).find((item) => item.event_name === eventName);
|
|
||||||
return step?.user_count;
|
|
||||||
}
|
|
||||||
|
|
||||||
function funnelChartOption(steps) {
|
|
||||||
const data = steps
|
|
||||||
.filter((step) => !step.is_failure && Number(step.user_count || 0) > 0)
|
|
||||||
.map((step) => ({
|
|
||||||
name: step.label || step.event_name,
|
|
||||||
value: Number(step.user_count || 0)
|
|
||||||
}));
|
|
||||||
return {
|
|
||||||
color: ["#3056d3", "#3b82f6", "#14b8a6", "#22c55e", "#f59e0b", "#ef4444"],
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
bottom: 18,
|
|
||||||
data,
|
|
||||||
gap: 4,
|
|
||||||
label: {
|
|
||||||
color: "#1f2a44",
|
|
||||||
formatter: ({ name, value }) => `${name}\n${formatCount(value)}`
|
|
||||||
},
|
|
||||||
left: 18,
|
|
||||||
minSize: "8%",
|
|
||||||
right: 18,
|
|
||||||
sort: "none",
|
|
||||||
top: 10,
|
|
||||||
type: "funnel"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
tooltip: {
|
|
||||||
backgroundColor: "#ffffff",
|
|
||||||
borderColor: "#e3eaf3",
|
|
||||||
textStyle: { color: "#263246" },
|
|
||||||
valueFormatter: (value) => formatCount(value)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function ratio(numerator, denominator) {
|
|
||||||
if (isBlank(denominator) || Number(denominator) <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return Number(numerator || 0) / Number(denominator);
|
|
||||||
}
|
|
||||||
@ -1,489 +0,0 @@
|
|||||||
// OverviewView:经营概览首屏 —— Hero KPI、充值趋势、渠道构成、App 对比、区域 Top5。
|
|
||||||
// 数据契约见 SocialBiApp.jsx 的 useSocialBi();本视图只做展示与聚合,不直接调 API。
|
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { EChart } from "../../charts/EChart.jsx";
|
|
||||||
import { deltaDirection, formatCount, formatDeltaRate, formatMoney, isBlank } from "../format.js";
|
|
||||||
import { aggregateMetricMaps, appColor, formatMetric, metricChartValue, metricLabel, metricTooltip } from "../metrics.js";
|
|
||||||
import { useSocialBi } from "../SocialBiApp.jsx";
|
|
||||||
import { bucketDay } from "../state.js";
|
|
||||||
import "./overview-view.css";
|
|
||||||
|
|
||||||
const HERO_METRICS = [
|
|
||||||
{ deltaKey: "recharge_delta_rate", key: "recharge_usd_minor" },
|
|
||||||
{ deltaKey: "new_users_delta_rate", key: "new_users" },
|
|
||||||
{ deltaKey: "active_users_delta_rate", key: "active_users" },
|
|
||||||
{ deltaKey: "paid_users_delta_rate", key: "paid_users" },
|
|
||||||
{ deltaKey: "arppu_delta_rate", key: "arppu_usd_minor" },
|
|
||||||
{ deltaKey: "", key: "paid_conversion_rate" }
|
|
||||||
];
|
|
||||||
|
|
||||||
const TREND_METRICS = [
|
|
||||||
{ key: "recharge_usd_minor", label: "充值" },
|
|
||||||
{ key: "new_users", label: "新增" },
|
|
||||||
{ key: "active_users", label: "日活" },
|
|
||||||
{ key: "gift_coin_spent", label: "礼物流水" }
|
|
||||||
];
|
|
||||||
|
|
||||||
const COMPARE_METRICS = [
|
|
||||||
{ key: "recharge_usd_minor", label: "充值" },
|
|
||||||
{ key: "active_users", label: "日活" },
|
|
||||||
{ key: "new_users", label: "新增" }
|
|
||||||
];
|
|
||||||
|
|
||||||
const CHANNEL_METRICS = ["google_recharge_usd_minor", "mifapay_recharge_usd_minor", "coin_seller_recharge_usd_minor"];
|
|
||||||
const CHANNEL_COLORS = ["#2557d6", "#0e7490", "#d97706"];
|
|
||||||
|
|
||||||
const MONEY_METRIC_KEYS = new Set([
|
|
||||||
"recharge_usd_minor",
|
|
||||||
"google_recharge_usd_minor",
|
|
||||||
"mifapay_recharge_usd_minor",
|
|
||||||
"coin_seller_recharge_usd_minor"
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 图表值已由 metricChartValue 换算(金额→美元),tooltip 里按指标类型补格式。
|
|
||||||
function formatChartAmount(metricKey, value) {
|
|
||||||
if (isBlank(value)) {
|
|
||||||
return "--";
|
|
||||||
}
|
|
||||||
return MONEY_METRIC_KEYS.has(metricKey) ? formatMoney(value) : formatCount(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 日行按 bucketDay 分桶,桶 key 升序;每桶保留原始行,由调用方再聚合。
|
|
||||||
function buildBuckets(rows, granularity) {
|
|
||||||
const map = new Map();
|
|
||||||
rows.forEach((row) => {
|
|
||||||
const key = bucketDay(row.stat_day, granularity);
|
|
||||||
if (!map.has(key)) {
|
|
||||||
map.set(key, []);
|
|
||||||
}
|
|
||||||
map.get(key).push(row);
|
|
||||||
});
|
|
||||||
return [...map.keys()].sort().map((key) => ({ key, rows: map.get(key) }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildStackedBarOption({ bucketKeys, series, valueMetric }) {
|
|
||||||
return {
|
|
||||||
grid: { bottom: 4, containLabel: true, left: 8, right: 8, top: 28 },
|
|
||||||
legend: { itemHeight: 10, itemWidth: 10, left: 0, textStyle: { color: "#5b7089", fontSize: 12 }, top: 0 },
|
|
||||||
series,
|
|
||||||
tooltip: {
|
|
||||||
axisPointer: { type: "shadow" },
|
|
||||||
backgroundColor: "#ffffff",
|
|
||||||
borderColor: "#e3eaf3",
|
|
||||||
formatter: (params) => {
|
|
||||||
const list = Array.isArray(params) ? params : [params];
|
|
||||||
let total = null;
|
|
||||||
const lines = list.map((item) => {
|
|
||||||
const numeric = isBlank(item.value) || !Number.isFinite(Number(item.value)) ? null : Number(item.value);
|
|
||||||
if (numeric !== null) {
|
|
||||||
total = (total ?? 0) + numeric;
|
|
||||||
}
|
|
||||||
return `${item.marker}${item.seriesName} <b>${formatChartAmount(valueMetric, numeric)}</b>`;
|
|
||||||
});
|
|
||||||
const header = list[0]?.axisValueLabel || list[0]?.name || "";
|
|
||||||
if (list.length > 1) {
|
|
||||||
lines.push(`合计 <b>${formatChartAmount(valueMetric, total)}</b>`);
|
|
||||||
}
|
|
||||||
return [header, ...lines].filter(Boolean).join("<br/>");
|
|
||||||
},
|
|
||||||
textStyle: { color: "#14263c", fontSize: 12 },
|
|
||||||
trigger: "axis"
|
|
||||||
},
|
|
||||||
xAxis: {
|
|
||||||
axisLabel: { color: "#5b7089", fontSize: 11 },
|
|
||||||
axisLine: { lineStyle: { color: "#eef3f9" } },
|
|
||||||
axisTick: { show: false },
|
|
||||||
data: bucketKeys,
|
|
||||||
type: "category"
|
|
||||||
},
|
|
||||||
yAxis: {
|
|
||||||
axisLabel: { color: "#5b7089", fontSize: 11 },
|
|
||||||
splitLine: { lineStyle: { color: "#eef3f9" } },
|
|
||||||
type: "value"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSparkOption(spark) {
|
|
||||||
return {
|
|
||||||
grid: { bottom: 2, left: 0, right: 0, top: 2 },
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
areaStyle: { color: "rgba(37, 87, 214, 0.12)" },
|
|
||||||
data: spark.map((point) => point.value),
|
|
||||||
lineStyle: { color: "#2557d6", width: 2 },
|
|
||||||
showSymbol: false,
|
|
||||||
smooth: true,
|
|
||||||
symbol: "none",
|
|
||||||
type: "line"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
xAxis: { boundaryGap: false, data: spark.map((point) => point.key), show: false, type: "category" },
|
|
||||||
yAxis: { show: false, type: "value" }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function HeroCard({ deltaValue, metricKey, spark, value }) {
|
|
||||||
const sparkOption = useMemo(() => buildSparkOption(spark), [spark]);
|
|
||||||
const hasSpark = spark.length > 1 && spark.some((point) => point.value !== null);
|
|
||||||
return (
|
|
||||||
<article className="sbi-card sbi-ov-hero-card">
|
|
||||||
<header className="sbi-ov-hero-top">
|
|
||||||
<span className="sbi-metric-hint sbi-ov-hero-label" title={metricTooltip(metricKey)}>
|
|
||||||
{metricLabel(metricKey)}
|
|
||||||
</span>
|
|
||||||
{!isBlank(deltaValue) ? (
|
|
||||||
<span className={`sbi-delta is-${deltaDirection(deltaValue)}`}>{formatDeltaRate(deltaValue)}</span>
|
|
||||||
) : null}
|
|
||||||
</header>
|
|
||||||
<strong className="sbi-ov-hero-value">{formatMetric(metricKey, value)}</strong>
|
|
||||||
<div className="sbi-ov-hero-spark">{hasSpark ? <EChart className="sbi-ov-spark-chart" option={sparkOption} /> : null}</div>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function OverviewSkeleton() {
|
|
||||||
return (
|
|
||||||
<section className="sbi-ov">
|
|
||||||
<div className="sbi-ov-hero">
|
|
||||||
{HERO_METRICS.map((metric) => (
|
|
||||||
<article className="sbi-card sbi-ov-hero-card" key={metric.key}>
|
|
||||||
<span className="sbi-skeleton" style={{ height: 14, width: 72 }} />
|
|
||||||
<span className="sbi-skeleton" style={{ height: 26, width: 110 }} />
|
|
||||||
<span className="sbi-skeleton" style={{ height: 38, width: "100%" }} />
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<section className="sbi-card sbi-ov-skeleton-card">
|
|
||||||
<span className="sbi-skeleton" style={{ height: 300, width: "100%" }} />
|
|
||||||
</section>
|
|
||||||
<div className="sbi-ov-duo">
|
|
||||||
<section className="sbi-card sbi-ov-skeleton-card">
|
|
||||||
<span className="sbi-skeleton" style={{ height: 220, width: "100%" }} />
|
|
||||||
</section>
|
|
||||||
<section className="sbi-card sbi-ov-skeleton-card">
|
|
||||||
<span className="sbi-skeleton" style={{ height: 220, width: "100%" }} />
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
<section className="sbi-card sbi-ov-skeleton-card">
|
|
||||||
<span className="sbi-skeleton" style={{ height: 180, width: "100%" }} />
|
|
||||||
</section>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OverviewView() {
|
|
||||||
const { appCodes, derived, filters, isLoading, setViewKey, updateFilter } = useSocialBi();
|
|
||||||
const [trendMetric, setTrendMetric] = useState("recharge_usd_minor");
|
|
||||||
const [compareMetric, setCompareMetric] = useState("recharge_usd_minor");
|
|
||||||
|
|
||||||
// regionScoped 时 App 级视角统一切到区域行聚合,保证与顶栏区域选择一致。
|
|
||||||
const dailyRows = derived.regionScoped ? derived.regionDaily : derived.appDaily;
|
|
||||||
|
|
||||||
const totals = useMemo(
|
|
||||||
() => aggregateMetricMaps(derived.regionScoped ? derived.regionTotals : derived.appTotals),
|
|
||||||
[derived.appTotals, derived.regionScoped, derived.regionTotals]
|
|
||||||
);
|
|
||||||
|
|
||||||
const buckets = useMemo(() => buildBuckets(dailyRows, filters.granularity), [dailyRows, filters.granularity]);
|
|
||||||
|
|
||||||
const bucketAggregates = useMemo(
|
|
||||||
() => buckets.map((bucket) => ({ key: bucket.key, metrics: aggregateMetricMaps(bucket.rows) })),
|
|
||||||
[buckets]
|
|
||||||
);
|
|
||||||
|
|
||||||
const heroSparks = useMemo(() => {
|
|
||||||
const map = {};
|
|
||||||
HERO_METRICS.forEach((metric) => {
|
|
||||||
map[metric.key] = bucketAggregates.map((bucket) => ({
|
|
||||||
key: bucket.key,
|
|
||||||
value: metricChartValue(metric.key, bucket.metrics[metric.key])
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
return map;
|
|
||||||
}, [bucketAggregates]);
|
|
||||||
|
|
||||||
// 每 App × 每桶的聚合单元,供主趋势堆叠柱使用;App 顺序跟随顶栏 appCodes。
|
|
||||||
const appBuckets = useMemo(() => {
|
|
||||||
const keys = buckets.map((bucket) => bucket.key);
|
|
||||||
const appIndex = new Map();
|
|
||||||
buckets.forEach((bucket, bucketIdx) => {
|
|
||||||
const byApp = new Map();
|
|
||||||
bucket.rows.forEach((row) => {
|
|
||||||
if (!byApp.has(row.app_code)) {
|
|
||||||
byApp.set(row.app_code, []);
|
|
||||||
}
|
|
||||||
byApp.get(row.app_code).push(row);
|
|
||||||
});
|
|
||||||
byApp.forEach((rows, code) => {
|
|
||||||
if (!appIndex.has(code)) {
|
|
||||||
appIndex.set(code, { app_code: code, app_name: rows[0].app_name, cells: new Array(buckets.length).fill(null) });
|
|
||||||
}
|
|
||||||
appIndex.get(code).cells[bucketIdx] = aggregateMetricMaps(rows);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
const apps = [...appIndex.values()].sort((a, b) => appCodes.indexOf(a.app_code) - appCodes.indexOf(b.app_code));
|
|
||||||
return { apps, keys };
|
|
||||||
}, [appCodes, buckets]);
|
|
||||||
|
|
||||||
const trendOption = useMemo(() => {
|
|
||||||
const series = appBuckets.apps.map((app) => ({
|
|
||||||
barMaxWidth: 26,
|
|
||||||
data: app.cells.map((cell) => (cell ? metricChartValue(trendMetric, cell[trendMetric]) : null)),
|
|
||||||
itemStyle: { color: appColor(app.app_code, appCodes) },
|
|
||||||
name: app.app_name,
|
|
||||||
stack: "total",
|
|
||||||
type: "bar"
|
|
||||||
}));
|
|
||||||
return buildStackedBarOption({ bucketKeys: appBuckets.keys, series, valueMetric: trendMetric });
|
|
||||||
}, [appBuckets, appCodes, trendMetric]);
|
|
||||||
|
|
||||||
const channelOption = useMemo(() => {
|
|
||||||
const series = CHANNEL_METRICS.map((key, index) => ({
|
|
||||||
barMaxWidth: 26,
|
|
||||||
data: bucketAggregates.map((bucket) => metricChartValue(key, bucket.metrics[key])),
|
|
||||||
itemStyle: { color: CHANNEL_COLORS[index] },
|
|
||||||
name: metricLabel(key),
|
|
||||||
stack: "channel",
|
|
||||||
type: "bar"
|
|
||||||
}));
|
|
||||||
return buildStackedBarOption({ bucketKeys: bucketAggregates.map((bucket) => bucket.key), series, valueMetric: CHANNEL_METRICS[0] });
|
|
||||||
}, [bucketAggregates]);
|
|
||||||
|
|
||||||
const hasChannelData = useMemo(
|
|
||||||
() => bucketAggregates.some((bucket) => CHANNEL_METRICS.some((key) => Number(bucket.metrics[key] ?? 0) > 0)),
|
|
||||||
[bucketAggregates]
|
|
||||||
);
|
|
||||||
|
|
||||||
const compareRows = useMemo(() => {
|
|
||||||
let rows;
|
|
||||||
if (derived.regionScoped) {
|
|
||||||
const byApp = new Map();
|
|
||||||
derived.regionTotals.forEach((row) => {
|
|
||||||
if (!byApp.has(row.app_code)) {
|
|
||||||
byApp.set(row.app_code, { app_code: row.app_code, app_name: row.app_name, rows: [] });
|
|
||||||
}
|
|
||||||
byApp.get(row.app_code).rows.push(row);
|
|
||||||
});
|
|
||||||
rows = [...byApp.values()].map((entry) => ({
|
|
||||||
app_code: entry.app_code,
|
|
||||||
app_name: entry.app_name,
|
|
||||||
metrics: aggregateMetricMaps(entry.rows)
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
rows = derived.appTotals.map((row) => ({ app_code: row.app_code, app_name: row.app_name, metrics: row }));
|
|
||||||
}
|
|
||||||
return rows.sort((a, b) => {
|
|
||||||
const left = Number(a.metrics[compareMetric]);
|
|
||||||
const right = Number(b.metrics[compareMetric]);
|
|
||||||
return (Number.isFinite(right) ? right : 0) - (Number.isFinite(left) ? left : 0);
|
|
||||||
});
|
|
||||||
}, [compareMetric, derived.appTotals, derived.regionScoped, derived.regionTotals]);
|
|
||||||
|
|
||||||
const compareTotal = compareRows.reduce((sum, row) => {
|
|
||||||
const value = Number(row.metrics[compareMetric]);
|
|
||||||
return Number.isFinite(value) && value > 0 ? sum + value : sum;
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
const topRegions = useMemo(() => {
|
|
||||||
return [...derived.regionTotals]
|
|
||||||
.sort((a, b) => (Number(b.recharge_usd_minor) || 0) - (Number(a.recharge_usd_minor) || 0))
|
|
||||||
.slice(0, 5);
|
|
||||||
}, [derived.regionTotals]);
|
|
||||||
const topRegionMax = topRegions.reduce((max, row) => Math.max(max, Number(row.recharge_usd_minor) || 0), 0);
|
|
||||||
|
|
||||||
const soloApp = !derived.regionScoped && derived.appTotals.length === 1 ? derived.appTotals[0] : null;
|
|
||||||
const hasRestricted = derived.appTotals.some((row) => row.restricted) || derived.appErrors.some((row) => row.restricted);
|
|
||||||
const hasTrend = appBuckets.keys.length > 0 && appBuckets.apps.length > 0;
|
|
||||||
const trendLabel = TREND_METRICS.find((item) => item.key === trendMetric)?.label || "";
|
|
||||||
|
|
||||||
if (isLoading && !derived.appTotals.length && !derived.regionTotals.length) {
|
|
||||||
return <OverviewSkeleton />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="sbi-ov">
|
|
||||||
{derived.appErrors.length || hasRestricted ? (
|
|
||||||
<div className="sbi-ov-info">
|
|
||||||
{derived.appErrors.map((item) => (
|
|
||||||
<span className="sbi-badge is-warn" key={item.app_code}>
|
|
||||||
{item.app_name}: {item.error}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{hasRestricted ? <span className="sbi-badge">已按你的数据范围过滤</span> : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="sbi-ov-hero">
|
|
||||||
{HERO_METRICS.map((metric) => (
|
|
||||||
<HeroCard
|
|
||||||
deltaValue={soloApp && metric.deltaKey ? soloApp[metric.deltaKey] : null}
|
|
||||||
key={metric.key}
|
|
||||||
metricKey={metric.key}
|
|
||||||
spark={heroSparks[metric.key]}
|
|
||||||
value={totals[metric.key]}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section className="sbi-card">
|
|
||||||
<header className="sbi-card-header">
|
|
||||||
<strong>{trendLabel}趋势</strong>
|
|
||||||
<small>按 App 堆叠</small>
|
|
||||||
<div className="sbi-card-toolbar">
|
|
||||||
<div className="sbi-seg" role="radiogroup" aria-label="趋势指标">
|
|
||||||
{TREND_METRICS.map((item) => (
|
|
||||||
<button
|
|
||||||
aria-checked={trendMetric === item.key}
|
|
||||||
className={trendMetric === item.key ? "is-active" : ""}
|
|
||||||
key={item.key}
|
|
||||||
onClick={() => setTrendMetric(item.key)}
|
|
||||||
role="radio"
|
|
||||||
title={metricTooltip(item.key)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<div className="sbi-ov-card-body">
|
|
||||||
{hasTrend ? (
|
|
||||||
<EChart className="sbi-ov-trend-chart" option={trendOption} />
|
|
||||||
) : (
|
|
||||||
<div className="sbi-empty">
|
|
||||||
<strong>暂无趋势数据</strong>
|
|
||||||
<span>当前筛选范围内没有日级数据,试试放宽日期或 App 筛选</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="sbi-ov-duo">
|
|
||||||
<section className="sbi-card">
|
|
||||||
<header className="sbi-card-header">
|
|
||||||
<strong>渠道构成</strong>
|
|
||||||
<small>谷歌 / 三方 / 币商充值</small>
|
|
||||||
</header>
|
|
||||||
<div className="sbi-ov-card-body">
|
|
||||||
{hasChannelData ? (
|
|
||||||
<EChart className="sbi-ov-channel-chart" option={channelOption} />
|
|
||||||
) : (
|
|
||||||
<div className="sbi-empty">
|
|
||||||
<strong>暂无渠道拆分数据</strong>
|
|
||||||
<span>所选范围内各渠道充值均为空或 0</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="sbi-card">
|
|
||||||
<header className="sbi-card-header">
|
|
||||||
<strong>App 对比</strong>
|
|
||||||
<small title={metricTooltip(compareMetric)}>按{COMPARE_METRICS.find((item) => item.key === compareMetric)?.label}占比</small>
|
|
||||||
<div className="sbi-card-toolbar">
|
|
||||||
<div className="sbi-seg" role="radiogroup" aria-label="对比指标">
|
|
||||||
{COMPARE_METRICS.map((item) => (
|
|
||||||
<button
|
|
||||||
aria-checked={compareMetric === item.key}
|
|
||||||
className={compareMetric === item.key ? "is-active" : ""}
|
|
||||||
key={item.key}
|
|
||||||
onClick={() => setCompareMetric(item.key)}
|
|
||||||
role="radio"
|
|
||||||
title={metricTooltip(item.key)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<div className="sbi-ov-card-body">
|
|
||||||
{compareRows.length ? (
|
|
||||||
<div className="sbi-ov-compare">
|
|
||||||
{compareRows.map((row) => {
|
|
||||||
const raw = Number(row.metrics[compareMetric]);
|
|
||||||
const share = compareTotal > 0 && Number.isFinite(raw) && raw > 0 ? (raw / compareTotal) * 100 : 0;
|
|
||||||
const color = appColor(row.app_code, appCodes);
|
|
||||||
return (
|
|
||||||
<div className="sbi-ov-compare-row" key={row.app_code}>
|
|
||||||
<span className="sbi-ov-compare-name">
|
|
||||||
<span className="sbi-ov-dot" style={{ background: color }} />
|
|
||||||
{row.app_name}
|
|
||||||
</span>
|
|
||||||
<div className="sbi-ov-bar-track">
|
|
||||||
<div className="sbi-ov-bar-fill" style={{ background: color, width: `${share}%` }} />
|
|
||||||
</div>
|
|
||||||
<span className="sbi-ov-num">{formatMetric(compareMetric, row.metrics[compareMetric])}</span>
|
|
||||||
<button
|
|
||||||
className="sbi-ov-link"
|
|
||||||
onClick={() => {
|
|
||||||
updateFilter("apps", [row.app_code]);
|
|
||||||
setViewKey("regions");
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
地区 →
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="sbi-empty">
|
|
||||||
<strong>暂无 App 数据</strong>
|
|
||||||
<span>调整顶部 App / 日期筛选后重试</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section className="sbi-card">
|
|
||||||
<header className="sbi-card-header">
|
|
||||||
<strong>区域 Top5</strong>
|
|
||||||
<small title={metricTooltip("recharge_usd_minor")}>按充值降序</small>
|
|
||||||
<div className="sbi-card-toolbar">
|
|
||||||
<button className="sbi-ov-link" onClick={() => setViewKey("regions")} type="button">
|
|
||||||
查看全部 →
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<div className="sbi-ov-card-body">
|
|
||||||
{topRegions.length ? (
|
|
||||||
<div className="sbi-ov-top">
|
|
||||||
{topRegions.map((row) => {
|
|
||||||
const raw = Number(row.recharge_usd_minor);
|
|
||||||
const share = topRegionMax > 0 && Number.isFinite(raw) && raw > 0 ? (raw / topRegionMax) * 100 : 0;
|
|
||||||
const color = appColor(row.app_code, appCodes);
|
|
||||||
return (
|
|
||||||
<div className="sbi-ov-top-row" key={`${row.app_code}:${row.region_id}`}>
|
|
||||||
<span className="sbi-ov-top-name">
|
|
||||||
<strong>{row.region_name}</strong>
|
|
||||||
<span className="sbi-badge">
|
|
||||||
<span className="sbi-ov-dot" style={{ background: color }} />
|
|
||||||
{row.app_name}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<div className="sbi-ov-bar-track">
|
|
||||||
<div className="sbi-ov-bar-fill" style={{ background: color, width: `${share}%` }} />
|
|
||||||
</div>
|
|
||||||
<span className="sbi-ov-num">{formatMetric("recharge_usd_minor", row.recharge_usd_minor)}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="sbi-empty">
|
|
||||||
<strong>暂无区域数据</strong>
|
|
||||||
<span>该范围未配置区域目录,或你没有对应区域的数据权限</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,465 +0,0 @@
|
|||||||
// RegionsView(地区洞察):概要条 + 世界地图(国家粒度)+ 区域卡片网格 + 区域→国家下钻表。
|
|
||||||
// 数据契约见 SocialBiApp.jsx 的 useSocialBi();金额为 USD 分、比例为 0-1,渲染时统一走 metrics/format 层。
|
|
||||||
|
|
||||||
import { Fragment, useMemo, useState } from "react";
|
|
||||||
import { VisualMapComponent } from "echarts/components";
|
|
||||||
import * as echarts from "echarts/core";
|
|
||||||
import { EChart } from "../../charts/EChart.jsx";
|
|
||||||
import { resolveCountryMeta } from "../../data/countryMeta.js";
|
|
||||||
import worldMap from "../../data/world.json";
|
|
||||||
import { formatCount, formatMoneyMinor, formatRatioPercent, isBlank } from "../format.js";
|
|
||||||
import { aggregateMetricMaps, appColor, formatMetric, metricChartValue, metricLabel, metricTooltip } from "../metrics.js";
|
|
||||||
import { useSocialBi } from "../SocialBiApp.jsx";
|
|
||||||
import "./regions-view.css";
|
|
||||||
|
|
||||||
// EChart.jsx 未注册 visualMap 组件,这里按需补注册(echarts.use 幂等,不影响其它图表)。
|
|
||||||
echarts.use([VisualMapComponent]);
|
|
||||||
|
|
||||||
const MAP_METRICS = [
|
|
||||||
{ key: "recharge_usd_minor", label: "充值" },
|
|
||||||
{ key: "new_users", label: "新增" },
|
|
||||||
{ key: "active_users", label: "日活" }
|
|
||||||
];
|
|
||||||
|
|
||||||
const CARD_METRICS = ["new_users", "active_users", "arppu_usd_minor"];
|
|
||||||
|
|
||||||
const TABLE_METRICS = [
|
|
||||||
"recharge_usd_minor",
|
|
||||||
"new_users",
|
|
||||||
"active_users",
|
|
||||||
"paid_users",
|
|
||||||
"arppu_usd_minor",
|
|
||||||
"d1_retention_rate",
|
|
||||||
"gift_coin_spent"
|
|
||||||
];
|
|
||||||
|
|
||||||
// databi-world 的合法地名索引(小写 → 原始 feature name),用于把 ISO alpha-2 / 后端国家名归一成地图地名。
|
|
||||||
const MAP_NAME_INDEX = new Map();
|
|
||||||
(worldMap.features || []).forEach((feature) => {
|
|
||||||
const name = feature?.properties?.name;
|
|
||||||
if (name) {
|
|
||||||
MAP_NAME_INDEX.set(String(name).trim().toLowerCase(), name);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 复用 countryMeta 的 ISO alpha-2 → 备选英文/中文名列表,再校验哪一个是 databi-world 的 feature name。
|
|
||||||
function resolveMapName(countryCode, countryName) {
|
|
||||||
const meta = resolveCountryMeta({ code: countryCode, name: countryName });
|
|
||||||
const candidates = [...(meta?.names || []), countryName];
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
const hit = MAP_NAME_INDEX.get(String(candidate || "").trim().toLowerCase());
|
|
||||||
if (hit) {
|
|
||||||
return hit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function countryDisplayName(countryCode, countryName) {
|
|
||||||
const meta = resolveCountryMeta({ code: countryCode, name: countryName });
|
|
||||||
return meta?.label || countryName || countryCode || "未知国家";
|
|
||||||
}
|
|
||||||
|
|
||||||
function rechargeValue(row) {
|
|
||||||
return isBlank(row?.recharge_usd_minor) ? -1 : Number(row.recharge_usd_minor);
|
|
||||||
}
|
|
||||||
|
|
||||||
function byRechargeDesc(a, b) {
|
|
||||||
return rechargeValue(b) - rechargeValue(a);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RegionsView() {
|
|
||||||
const { appCodes, derived, isLoading } = useSocialBi();
|
|
||||||
const { appTotals, countryTotals, regionTotals } = derived;
|
|
||||||
const [mapMetric, setMapMetric] = useState("recharge_usd_minor");
|
|
||||||
const [expandedRegions, setExpandedRegions] = useState(() => new Set());
|
|
||||||
|
|
||||||
const regionRows = useMemo(() => [...regionTotals].sort(byRechargeDesc), [regionTotals]);
|
|
||||||
|
|
||||||
const regionSummary = useMemo(() => aggregateMetricMaps(regionTotals), [regionTotals]);
|
|
||||||
|
|
||||||
const regionCount = useMemo(
|
|
||||||
() => new Set(regionTotals.map((row) => `${row.app_code}:${row.region_id}`)).size,
|
|
||||||
[regionTotals]
|
|
||||||
);
|
|
||||||
|
|
||||||
const appCount = useMemo(() => new Set(regionTotals.map((row) => row.app_code)).size, [regionTotals]);
|
|
||||||
|
|
||||||
const countryCount = useMemo(() => {
|
|
||||||
const codes = new Set();
|
|
||||||
countryTotals.forEach((row) => {
|
|
||||||
const key = String(row.country_code || row.country_name || "").trim();
|
|
||||||
if (key) {
|
|
||||||
codes.add(key);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return codes.size;
|
|
||||||
}, [countryTotals]);
|
|
||||||
|
|
||||||
// 每个 App 的总充值(占比条分母):优先 appTotals 的整 App 口径,缺失时退化为可见区域行求和。
|
|
||||||
const appRechargeTotals = useMemo(() => {
|
|
||||||
const totals = new Map();
|
|
||||||
appTotals.forEach((row) => {
|
|
||||||
if (!isBlank(row.recharge_usd_minor)) {
|
|
||||||
totals.set(row.app_code, Number(row.recharge_usd_minor));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const fallback = new Map();
|
|
||||||
regionTotals.forEach((row) => {
|
|
||||||
if (!isBlank(row.recharge_usd_minor)) {
|
|
||||||
fallback.set(row.app_code, (fallback.get(row.app_code) || 0) + Number(row.recharge_usd_minor));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
fallback.forEach((value, code) => {
|
|
||||||
if (!totals.has(code)) {
|
|
||||||
totals.set(code, value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return totals;
|
|
||||||
}, [appTotals, regionTotals]);
|
|
||||||
|
|
||||||
// 下钻子行:countryTotals 按 app_code + region_id 分组(region_id 缺失已在数据层归一成 0,对应"未划分区域")。
|
|
||||||
const countriesByRegion = useMemo(() => {
|
|
||||||
const groups = new Map();
|
|
||||||
countryTotals.forEach((row) => {
|
|
||||||
const key = `${row.app_code}:${Number(row.region_id ?? 0)}`;
|
|
||||||
const bucket = groups.get(key) || [];
|
|
||||||
bucket.push(row);
|
|
||||||
groups.set(key, bucket);
|
|
||||||
});
|
|
||||||
groups.forEach((rows) => rows.sort(byRechargeDesc));
|
|
||||||
return groups;
|
|
||||||
}, [countryTotals]);
|
|
||||||
|
|
||||||
// 地图数据:跨 App 同国家先按地图地名合桶,再用 aggregateMetricMaps 合并(比例按分子分母重算)。
|
|
||||||
const mapIndex = useMemo(() => {
|
|
||||||
const buckets = new Map();
|
|
||||||
const unmatched = new Set();
|
|
||||||
countryTotals.forEach((row) => {
|
|
||||||
const mapName = resolveMapName(row.country_code, row.country_name);
|
|
||||||
if (!mapName) {
|
|
||||||
unmatched.add(String(row.country_code || row.country_name || "?"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const bucket = buckets.get(mapName) || { label: countryDisplayName(row.country_code, row.country_name), rows: [] };
|
|
||||||
bucket.rows.push(row);
|
|
||||||
buckets.set(mapName, bucket);
|
|
||||||
});
|
|
||||||
const entries = new Map();
|
|
||||||
buckets.forEach((bucket, mapName) => {
|
|
||||||
entries.set(mapName, { label: bucket.label, metrics: aggregateMetricMaps(bucket.rows) });
|
|
||||||
});
|
|
||||||
return { entries, unmatchedCount: unmatched.size };
|
|
||||||
}, [countryTotals]);
|
|
||||||
|
|
||||||
const mapOption = useMemo(() => {
|
|
||||||
const data = [];
|
|
||||||
mapIndex.entries.forEach((entry, mapName) => {
|
|
||||||
const value = metricChartValue(mapMetric, entry.metrics[mapMetric]);
|
|
||||||
if (value !== null) {
|
|
||||||
data.push({ name: mapName, value });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const max = data.reduce((acc, item) => Math.max(acc, item.value), 0) || 1;
|
|
||||||
return {
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
data,
|
|
||||||
emphasis: { itemStyle: { areaColor: "#93b6f2" }, label: { show: false } },
|
|
||||||
itemStyle: { areaColor: "#e9eef5", borderColor: "#d5dfeb", borderWidth: 0.6 },
|
|
||||||
label: { show: false },
|
|
||||||
map: "databi-world",
|
|
||||||
name: metricLabel(mapMetric),
|
|
||||||
roam: false,
|
|
||||||
select: { disabled: true },
|
|
||||||
type: "map"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
tooltip: {
|
|
||||||
backgroundColor: "#ffffff",
|
|
||||||
borderColor: "#e3eaf3",
|
|
||||||
extraCssText: "box-shadow: 0 4px 16px rgba(16, 34, 56, 0.12);",
|
|
||||||
formatter: (params) => {
|
|
||||||
const entry = mapIndex.entries.get(params.name);
|
|
||||||
if (!entry) {
|
|
||||||
return `${params.name}<br/>暂无数据`;
|
|
||||||
}
|
|
||||||
return `${entry.label}<br/>${metricLabel(mapMetric)}:${formatMetric(mapMetric, entry.metrics[mapMetric])}`;
|
|
||||||
},
|
|
||||||
textStyle: { color: "#14263c", fontSize: 12 }
|
|
||||||
},
|
|
||||||
visualMap: {
|
|
||||||
bottom: 10,
|
|
||||||
calculable: false,
|
|
||||||
inRange: { color: ["#dbeafe", "#2557d6"] },
|
|
||||||
itemHeight: 110,
|
|
||||||
itemWidth: 10,
|
|
||||||
left: 12,
|
|
||||||
max,
|
|
||||||
min: 0,
|
|
||||||
orient: "horizontal",
|
|
||||||
text: ["高", "低"],
|
|
||||||
textStyle: { color: "#5b7089", fontSize: 11 }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [mapIndex, mapMetric]);
|
|
||||||
|
|
||||||
const toggleRegion = (key) => {
|
|
||||||
setExpandedRegions((current) => {
|
|
||||||
const next = new Set(current);
|
|
||||||
if (next.has(key)) {
|
|
||||||
next.delete(key);
|
|
||||||
} else {
|
|
||||||
next.add(key);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading && !regionTotals.length && !countryTotals.length) {
|
|
||||||
return <RegionsSkeleton />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!regionTotals.length) {
|
|
||||||
return (
|
|
||||||
<section className="sbi-card">
|
|
||||||
<div className="sbi-empty">
|
|
||||||
<strong>暂无区域数据</strong>
|
|
||||||
<span>检查顶栏区域筛选,或该 App 尚未配置区域目录(legacy App 在其 Mongo sys_region_config,hyapp 在区域管理)</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="sbi-rg-summary">
|
|
||||||
<section className="sbi-card sbi-rg-summary-card">
|
|
||||||
<span className="sbi-rg-summary-label">覆盖区域数</span>
|
|
||||||
<span className="sbi-rg-summary-value">{formatCount(regionCount)}</span>
|
|
||||||
<span className="sbi-rg-summary-sub">按 App × 区域去重 · 覆盖 {formatCount(appCount)} 个 App</span>
|
|
||||||
</section>
|
|
||||||
<section className="sbi-card sbi-rg-summary-card">
|
|
||||||
<span className="sbi-rg-summary-label">覆盖国家数</span>
|
|
||||||
<span className="sbi-rg-summary-value">{formatCount(countryCount)}</span>
|
|
||||||
<span className="sbi-rg-summary-sub">countryTotals 按国家去重(跨 App 合并)</span>
|
|
||||||
</section>
|
|
||||||
<section className="sbi-card sbi-rg-summary-card">
|
|
||||||
<span className="sbi-rg-summary-label" title={metricTooltip("recharge_usd_minor")}>
|
|
||||||
区域充值合计
|
|
||||||
</span>
|
|
||||||
<span className="sbi-rg-summary-value">{formatMoneyMinor(regionSummary.recharge_usd_minor)}</span>
|
|
||||||
<span className="sbi-rg-summary-sub">当前区间 · 可见区域行合计</span>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section className="sbi-card sbi-rg-map-card">
|
|
||||||
<div className="sbi-card-header">
|
|
||||||
<strong>全球分布</strong>
|
|
||||||
<small>
|
|
||||||
{countryCount ? `${formatCount(countryCount)} 个国家 · 跨 App 合并` : "国家粒度"}
|
|
||||||
{mapIndex.unmatchedCount ? ` · ${mapIndex.unmatchedCount} 个国家未匹配到地图` : ""}
|
|
||||||
</small>
|
|
||||||
<div className="sbi-card-toolbar">
|
|
||||||
<div className="sbi-seg" role="radiogroup" aria-label="地图指标">
|
|
||||||
{MAP_METRICS.map((item) => (
|
|
||||||
<button
|
|
||||||
aria-checked={mapMetric === item.key}
|
|
||||||
className={mapMetric === item.key ? "is-active" : ""}
|
|
||||||
key={item.key}
|
|
||||||
onClick={() => setMapMetric(item.key)}
|
|
||||||
role="radio"
|
|
||||||
title={metricTooltip(item.key)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="sbi-rg-map-body">
|
|
||||||
{mapIndex.entries.size ? (
|
|
||||||
<EChart className="sbi-rg-map-chart" option={mapOption} />
|
|
||||||
) : (
|
|
||||||
<div className="sbi-empty">
|
|
||||||
<strong>暂无国家数据</strong>
|
|
||||||
<span>当前区间没有国家粒度数据,或国家编码未能匹配到世界地图</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="sbi-rg-grid">
|
|
||||||
{regionRows.map((row) => {
|
|
||||||
const key = `${row.app_code}:${row.region_id}`;
|
|
||||||
const appTotal = appRechargeTotals.get(row.app_code);
|
|
||||||
const share = appTotal && !isBlank(row.recharge_usd_minor) ? Number(row.recharge_usd_minor) / appTotal : null;
|
|
||||||
return (
|
|
||||||
<section className="sbi-card sbi-rg-region-card" key={key}>
|
|
||||||
<div className="sbi-rg-region-head">
|
|
||||||
<span className="sbi-rg-region-name" title={row.region_name}>
|
|
||||||
{row.region_name}
|
|
||||||
</span>
|
|
||||||
{row.restricted ? (
|
|
||||||
<span className="sbi-badge" title="数据按你的负责范围裁剪">
|
|
||||||
范围裁剪
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
<span className="sbi-rg-app-tag">
|
|
||||||
<span className="sbi-rg-app-dot" style={{ background: appColor(row.app_code, appCodes) }} />
|
|
||||||
{row.app_name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="sbi-rg-region-money">
|
|
||||||
<small className="sbi-metric-hint" title={metricTooltip("recharge_usd_minor")}>
|
|
||||||
{metricLabel("recharge_usd_minor")}
|
|
||||||
</small>
|
|
||||||
<strong>{formatMoneyMinor(row.recharge_usd_minor)}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="sbi-rg-region-metrics">
|
|
||||||
{CARD_METRICS.map((metric) => (
|
|
||||||
<div className="sbi-rg-region-metric" key={metric}>
|
|
||||||
<small className="sbi-metric-hint" title={metricTooltip(metric)}>
|
|
||||||
{metricLabel(metric)}
|
|
||||||
</small>
|
|
||||||
<span>{formatMetric(metric, row[metric])}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="sbi-rg-share">
|
|
||||||
<span className="sbi-rg-share-head">
|
|
||||||
<span>占 {row.app_name} 总充值</span>
|
|
||||||
<span>{share === null ? "--" : formatRatioPercent(share)}</span>
|
|
||||||
</span>
|
|
||||||
<span className="sbi-rg-share-track">
|
|
||||||
<span
|
|
||||||
className="sbi-rg-share-fill"
|
|
||||||
style={{ width: share === null ? 0 : `${Math.min(100, Math.max(0, share * 100))}%` }}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section className="sbi-card sbi-rg-table-card">
|
|
||||||
<div className="sbi-card-header">
|
|
||||||
<strong>区域下钻</strong>
|
|
||||||
<small>点击区域行展开国家明细 · 按充值降序</small>
|
|
||||||
</div>
|
|
||||||
<div className="sbi-table-scroll">
|
|
||||||
<table className="sbi-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th className="is-left">地区 / 国家</th>
|
|
||||||
<th className="is-left">App</th>
|
|
||||||
{TABLE_METRICS.map((metric) => (
|
|
||||||
<th key={metric}>
|
|
||||||
<span className="sbi-metric-hint" title={metricTooltip(metric)}>
|
|
||||||
{metricLabel(metric)}
|
|
||||||
</span>
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr className="is-total">
|
|
||||||
<td className="is-left">全部区域</td>
|
|
||||||
<td className="is-left">{formatCount(appCount)} 个 App</td>
|
|
||||||
{TABLE_METRICS.map((metric) => (
|
|
||||||
<td key={metric}>{formatMetric(metric, regionSummary[metric])}</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
{regionRows.map((row) => {
|
|
||||||
const key = `${row.app_code}:${row.region_id}`;
|
|
||||||
const isOpen = expandedRegions.has(key);
|
|
||||||
const children = isOpen ? countriesByRegion.get(key) || [] : [];
|
|
||||||
return (
|
|
||||||
<Fragment key={key}>
|
|
||||||
<tr aria-expanded={isOpen} className="sbi-rg-row" onClick={() => toggleRegion(key)}>
|
|
||||||
<td className="is-left">
|
|
||||||
<span className="sbi-rg-region-cell">
|
|
||||||
<span aria-hidden="true" className={isOpen ? "sbi-rg-caret is-open" : "sbi-rg-caret"}>
|
|
||||||
▸
|
|
||||||
</span>
|
|
||||||
{row.region_name}
|
|
||||||
{row.restricted ? (
|
|
||||||
<span className="sbi-badge" title="数据按你的负责范围裁剪">
|
|
||||||
范围裁剪
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="is-left">
|
|
||||||
<span className="sbi-rg-app-tag">
|
|
||||||
<span className="sbi-rg-app-dot" style={{ background: appColor(row.app_code, appCodes) }} />
|
|
||||||
{row.app_name}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
{TABLE_METRICS.map((metric) => (
|
|
||||||
<td key={metric}>{formatMetric(metric, row[metric])}</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
{isOpen && !children.length ? (
|
|
||||||
<tr className="sbi-rg-child">
|
|
||||||
<td className="is-left" colSpan={2 + TABLE_METRICS.length}>
|
|
||||||
<span className="sbi-rg-child-name">该区域暂无国家明细</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : null}
|
|
||||||
{children.map((child) => (
|
|
||||||
<tr className="sbi-rg-child" key={`${key}:${child.country_code || child.country_name}`}>
|
|
||||||
<td className="is-left">
|
|
||||||
<span className="sbi-rg-child-name">{countryDisplayName(child.country_code, child.country_name)}</span>
|
|
||||||
</td>
|
|
||||||
<td className="is-left sbi-rg-child-app">{child.app_name}</td>
|
|
||||||
{TABLE_METRICS.map((metric) => (
|
|
||||||
<td key={metric}>{formatMetric(metric, child[metric])}</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RegionsSkeleton() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="sbi-rg-summary" aria-busy="true">
|
|
||||||
{[0, 1, 2].map((index) => (
|
|
||||||
<section className="sbi-card sbi-rg-summary-card" key={index}>
|
|
||||||
<span className="sbi-skeleton" style={{ height: 12, width: 90 }} />
|
|
||||||
<span className="sbi-skeleton" style={{ height: 26, width: 140 }} />
|
|
||||||
<span className="sbi-skeleton" style={{ height: 11, width: 170 }} />
|
|
||||||
</section>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<section className="sbi-card sbi-rg-map-card" aria-busy="true">
|
|
||||||
<div className="sbi-card-header">
|
|
||||||
<span className="sbi-skeleton" style={{ height: 14, width: 120 }} />
|
|
||||||
</div>
|
|
||||||
<div className="sbi-rg-map-body">
|
|
||||||
<span className="sbi-skeleton" style={{ display: "block", height: "100%", width: "100%" }} />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<div className="sbi-rg-grid" aria-busy="true">
|
|
||||||
{[0, 1, 2].map((index) => (
|
|
||||||
<section className="sbi-card sbi-rg-region-card" key={index}>
|
|
||||||
<span className="sbi-skeleton" style={{ height: 14, width: 150 }} />
|
|
||||||
<span className="sbi-skeleton" style={{ height: 24, width: 130 }} />
|
|
||||||
<span className="sbi-skeleton" style={{ height: 34, width: "100%" }} />
|
|
||||||
</section>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,400 +0,0 @@
|
|||||||
// RetentionView(留存质量):整体 D1/D7/D30 概要卡、新增×次日留存组合图、
|
|
||||||
// 留存趋势(汇总/单 App)、App/区域留存热力对比表。数据契约见 SocialBiApp.jsx 的 useSocialBi()。
|
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { useSocialBi } from "../SocialBiApp.jsx";
|
|
||||||
import { EChart } from "../../charts/EChart.jsx";
|
|
||||||
import { aggregateMetricMaps, formatMetric, metricChartValue, metricLabel, metricTooltip } from "../metrics.js";
|
|
||||||
import { formatCount, formatPercentValue, formatRatioPercent, isBlank } from "../format.js";
|
|
||||||
import { ALL, bucketDay } from "../state.js";
|
|
||||||
import "./retention-view.css";
|
|
||||||
|
|
||||||
// 图表 canvas 内无法读 CSS 变量,这里用与 social-v2.css token 一致的十六进制值。
|
|
||||||
const CHART_ACCENT = "#2557d6"; // var(--sbi-accent)
|
|
||||||
const CHART_ACCENT_2 = "#0e7490"; // var(--sbi-accent-2)
|
|
||||||
const CHART_GRID_LINE = "#eef3f9";
|
|
||||||
const CHART_TEXT = "#5b7089";
|
|
||||||
const CHART_GRID = { bottom: 4, containLabel: true, left: 8, right: 8, top: 28 };
|
|
||||||
const CHART_LEGEND = { itemHeight: 9, itemWidth: 14, textStyle: { color: CHART_TEXT, fontSize: 12 }, top: 0 };
|
|
||||||
const CHART_TOOLTIP = { backgroundColor: "#ffffff", borderColor: CHART_GRID_LINE, textStyle: { color: CHART_TEXT }, trigger: "axis" };
|
|
||||||
|
|
||||||
const RETENTION_SERIES = [
|
|
||||||
{ color: CHART_ACCENT, key: "d1_retention_rate" },
|
|
||||||
{ color: CHART_ACCENT_2, key: "d7_retention_rate" },
|
|
||||||
{ color: "#7c3aed", key: "d30_retention_rate" }
|
|
||||||
];
|
|
||||||
|
|
||||||
const HEAT_KEYS = new Set(["d1_retention_rate", "d7_retention_rate", "d30_retention_rate"]);
|
|
||||||
const TABLE_METRIC_KEYS = [
|
|
||||||
"new_users",
|
|
||||||
"active_users",
|
|
||||||
"d1_retention_rate",
|
|
||||||
"d7_retention_rate",
|
|
||||||
"d30_retention_rate",
|
|
||||||
"paid_conversion_rate"
|
|
||||||
];
|
|
||||||
|
|
||||||
const GRANULARITY_LABELS = { day: "日", month: "月", week: "周" };
|
|
||||||
|
|
||||||
// 把日粒度行按 bucketDay 分桶后逐桶聚合(比例按分子分母重算),桶 key 升序。
|
|
||||||
function bucketAggregates(rows, granularity) {
|
|
||||||
const map = new Map();
|
|
||||||
rows.forEach((row) => {
|
|
||||||
const bucket = bucketDay(row.stat_day, granularity);
|
|
||||||
const list = map.get(bucket);
|
|
||||||
if (list) {
|
|
||||||
list.push(row);
|
|
||||||
} else {
|
|
||||||
map.set(bucket, [row]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const buckets = [...map.keys()].sort();
|
|
||||||
return { aggregates: buckets.map((key) => aggregateMetricMaps(map.get(key))), buckets };
|
|
||||||
}
|
|
||||||
|
|
||||||
function categoryAxis(buckets) {
|
|
||||||
return {
|
|
||||||
axisLabel: { color: CHART_TEXT },
|
|
||||||
axisLine: { lineStyle: { color: CHART_GRID_LINE } },
|
|
||||||
axisTick: { show: false },
|
|
||||||
data: buckets,
|
|
||||||
type: "category"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 留存热力单元格:值越高蓝底越深(上限 0.85),深度超过 0.35 换白字保证对比度;null 无底色显示 "--"。
|
|
||||||
function renderMetricCell(key, value) {
|
|
||||||
if (!HEAT_KEYS.has(key)) {
|
|
||||||
return <td key={key}>{formatMetric(key, value)}</td>;
|
|
||||||
}
|
|
||||||
if (isBlank(value)) {
|
|
||||||
return <td key={key}>--</td>;
|
|
||||||
}
|
|
||||||
const depth = Math.min(0.85, Number(value) * 2);
|
|
||||||
const style = { backgroundColor: `rgba(37, 87, 214, ${Number(depth.toFixed(3))})` };
|
|
||||||
if (depth > 0.35) {
|
|
||||||
style.color = "#ffffff";
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<td key={key} style={style}>
|
|
||||||
{formatRatioPercent(value)}
|
|
||||||
</td>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RetentionView() {
|
|
||||||
const { derived, filters, isLoading } = useSocialBi();
|
|
||||||
const { appDaily, appTotals, regionDaily, regionScoped, regionTotals } = derived;
|
|
||||||
const [trendApp, setTrendApp] = useState(ALL);
|
|
||||||
const [tableDim, setTableDim] = useState("app");
|
|
||||||
|
|
||||||
const granularityLabel = GRANULARITY_LABELS[filters.granularity] || "日";
|
|
||||||
// 筛选变化后所选 App 可能不在结果里,回退到汇总(不直接改 state,渲染期兜底即可)。
|
|
||||||
const activeTrendApp = trendApp !== ALL && appTotals.some((row) => row.app_code === trendApp) ? trendApp : ALL;
|
|
||||||
// 选了具体区域时,全视图(概要/图表/对比表)统一切到区域行口径,避免同屏两套数字。
|
|
||||||
const dailyRows = regionScoped ? regionDaily : appDaily;
|
|
||||||
|
|
||||||
// 1) 概要卡:选了具体区域时 App 级合计改用区域行聚合,与顶栏语义一致。
|
|
||||||
const summary = useMemo(() => {
|
|
||||||
const rows = regionScoped ? regionTotals : appTotals;
|
|
||||||
return rows.length ? aggregateMetricMaps(rows) : null;
|
|
||||||
}, [appTotals, regionScoped, regionTotals]);
|
|
||||||
|
|
||||||
// 2) 新增(柱/左轴)× 次日留存率(线/右轴),按全局粒度分桶;null 让线断开。
|
|
||||||
const comboOption = useMemo(() => {
|
|
||||||
const { aggregates, buckets } = bucketAggregates(dailyRows, filters.granularity);
|
|
||||||
if (!buckets.length) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
grid: { ...CHART_GRID },
|
|
||||||
legend: { ...CHART_LEGEND },
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
barMaxWidth: 26,
|
|
||||||
data: aggregates.map((row) => metricChartValue("new_users", row.new_users)),
|
|
||||||
itemStyle: { borderRadius: [3, 3, 0, 0], color: CHART_ACCENT },
|
|
||||||
name: metricLabel("new_users"),
|
|
||||||
tooltip: { valueFormatter: (value) => formatCount(value) },
|
|
||||||
type: "bar"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
connectNulls: false,
|
|
||||||
data: aggregates.map((row) => metricChartValue("d1_retention_rate", row.d1_retention_rate)),
|
|
||||||
itemStyle: { color: CHART_ACCENT_2 },
|
|
||||||
lineStyle: { color: CHART_ACCENT_2, width: 2.5 },
|
|
||||||
name: metricLabel("d1_retention_rate"),
|
|
||||||
symbol: "circle",
|
|
||||||
symbolSize: 6,
|
|
||||||
tooltip: { valueFormatter: (value) => formatPercentValue(value) },
|
|
||||||
type: "line",
|
|
||||||
yAxisIndex: 1
|
|
||||||
}
|
|
||||||
],
|
|
||||||
tooltip: { ...CHART_TOOLTIP, axisPointer: { type: "shadow" } },
|
|
||||||
xAxis: categoryAxis(buckets),
|
|
||||||
yAxis: [
|
|
||||||
{
|
|
||||||
axisLabel: { color: CHART_TEXT },
|
|
||||||
splitLine: { lineStyle: { color: CHART_GRID_LINE } },
|
|
||||||
type: "value"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
axisLabel: { color: CHART_TEXT, formatter: "{value}%" },
|
|
||||||
splitLine: { show: false },
|
|
||||||
type: "value"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
}, [dailyRows, filters.granularity]);
|
|
||||||
|
|
||||||
// 3) D1/D7/D30 留存趋势:汇总或单 App 的日行分桶聚合(区域筛选时用区域日行)。
|
|
||||||
const trendOption = useMemo(() => {
|
|
||||||
const rows = activeTrendApp === ALL ? dailyRows : dailyRows.filter((row) => row.app_code === activeTrendApp);
|
|
||||||
const { aggregates, buckets } = bucketAggregates(rows, filters.granularity);
|
|
||||||
if (!buckets.length) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
grid: { ...CHART_GRID },
|
|
||||||
legend: { ...CHART_LEGEND },
|
|
||||||
series: RETENTION_SERIES.map(({ color, key }) => ({
|
|
||||||
connectNulls: false,
|
|
||||||
data: aggregates.map((row) => metricChartValue(key, row[key])),
|
|
||||||
itemStyle: { color },
|
|
||||||
lineStyle: { color, width: 2.5 },
|
|
||||||
name: metricLabel(key),
|
|
||||||
symbol: "circle",
|
|
||||||
symbolSize: 5,
|
|
||||||
tooltip: { valueFormatter: (value) => formatPercentValue(value) },
|
|
||||||
type: "line"
|
|
||||||
})),
|
|
||||||
tooltip: { ...CHART_TOOLTIP },
|
|
||||||
xAxis: categoryAxis(buckets),
|
|
||||||
yAxis: {
|
|
||||||
axisLabel: { color: CHART_TEXT, formatter: "{value}%" },
|
|
||||||
splitLine: { lineStyle: { color: CHART_GRID_LINE } },
|
|
||||||
type: "value"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [activeTrendApp, dailyRows, filters.granularity]);
|
|
||||||
|
|
||||||
// 4) 热力对比表:按 App / 按区域;区域筛选时"按 App"改为区域行按 App 聚合,保持口径一致。
|
|
||||||
const tableSource = useMemo(() => {
|
|
||||||
if (tableDim === "region") {
|
|
||||||
return regionTotals;
|
|
||||||
}
|
|
||||||
if (!regionScoped) {
|
|
||||||
return appTotals;
|
|
||||||
}
|
|
||||||
const byApp = new Map();
|
|
||||||
regionTotals.forEach((row) => {
|
|
||||||
const list = byApp.get(row.app_code) || [];
|
|
||||||
list.push(row);
|
|
||||||
byApp.set(row.app_code, list);
|
|
||||||
});
|
|
||||||
return [...byApp.entries()].map(([appCode, rows]) => ({
|
|
||||||
app_code: appCode,
|
|
||||||
app_name: rows[0].app_name,
|
|
||||||
...aggregateMetricMaps(rows)
|
|
||||||
}));
|
|
||||||
}, [appTotals, regionScoped, regionTotals, tableDim]);
|
|
||||||
|
|
||||||
const tableRows = useMemo(() => {
|
|
||||||
return tableSource
|
|
||||||
.map((row) => ({
|
|
||||||
key: tableDim === "region" ? `${row.app_code}:${row.region_id}` : row.app_code,
|
|
||||||
label: tableDim === "region" ? `${row.app_name}·${row.region_name}` : row.app_name,
|
|
||||||
row
|
|
||||||
}))
|
|
||||||
.sort((a, b) => (Number(b.row.new_users) || 0) - (Number(a.row.new_users) || 0));
|
|
||||||
}, [tableDim, tableSource]);
|
|
||||||
|
|
||||||
const tableTotal = useMemo(() => {
|
|
||||||
return tableSource.length ? aggregateMetricMaps(tableSource) : null;
|
|
||||||
}, [tableSource]);
|
|
||||||
|
|
||||||
const hasRows = appTotals.length > 0 || appDaily.length > 0 || regionTotals.length > 0;
|
|
||||||
|
|
||||||
if (isLoading && !hasRows) {
|
|
||||||
return (
|
|
||||||
<div className="sbi-rt-view">
|
|
||||||
<div className="sbi-rt-summary">
|
|
||||||
{RETENTION_SERIES.map(({ key }) => (
|
|
||||||
<section className="sbi-card sbi-rt-summary-card" key={key}>
|
|
||||||
<span className="sbi-skeleton sbi-rt-skeleton-line" />
|
|
||||||
<span className="sbi-skeleton sbi-rt-skeleton-value" />
|
|
||||||
</section>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<section className="sbi-card">
|
|
||||||
<span className="sbi-skeleton sbi-rt-skeleton-chart" />
|
|
||||||
</section>
|
|
||||||
<section className="sbi-card">
|
|
||||||
<span className="sbi-skeleton sbi-rt-skeleton-chart" />
|
|
||||||
</section>
|
|
||||||
<section className="sbi-card">
|
|
||||||
<span className="sbi-skeleton sbi-rt-skeleton-table" />
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasRows) {
|
|
||||||
return (
|
|
||||||
<div className="sbi-rt-view">
|
|
||||||
<section className="sbi-card">
|
|
||||||
<div className="sbi-empty">
|
|
||||||
<strong>暂无留存数据</strong>
|
|
||||||
<span>当前筛选没有返回任何 App 数据,试试调整日期区间或放宽 App/区域筛选。</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="sbi-rt-view">
|
|
||||||
<div className="sbi-rt-summary">
|
|
||||||
{RETENTION_SERIES.map(({ key }) => {
|
|
||||||
const baseUsers = summary?.[key.replace("_retention_rate", "_retention_base_users")];
|
|
||||||
return (
|
|
||||||
<section className="sbi-card sbi-rt-summary-card" key={key}>
|
|
||||||
<span className="sbi-rt-summary-label sbi-metric-hint" title={metricTooltip(key)}>
|
|
||||||
整体{metricLabel(key)}
|
|
||||||
</span>
|
|
||||||
<span className="sbi-rt-summary-value">{formatRatioPercent(summary?.[key])}</span>
|
|
||||||
<span className="sbi-rt-summary-note">
|
|
||||||
{regionScoped ? "所选区域汇总" : "全部所选 App 汇总"}
|
|
||||||
{isBlank(baseUsers) ? "" : ` · cohort 样本 ${formatCount(baseUsers)} 人`}
|
|
||||||
</span>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section className="sbi-card">
|
|
||||||
<div className="sbi-card-header">
|
|
||||||
<strong>新增与次日留存</strong>
|
|
||||||
<small>柱=新增(左轴)· 线=次日留存率(右轴)· 按{granularityLabel}分桶</small>
|
|
||||||
</div>
|
|
||||||
{comboOption ? (
|
|
||||||
<EChart className="sbi-rt-chart-combo" option={comboOption} />
|
|
||||||
) : (
|
|
||||||
<div className="sbi-empty">
|
|
||||||
<strong>暂无按日数据</strong>
|
|
||||||
<span>当前筛选没有返回按日序列,无法绘制新增与留存趋势。</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="sbi-card">
|
|
||||||
<div className="sbi-card-header">
|
|
||||||
<strong>留存趋势</strong>
|
|
||||||
<small>D1 / D7 / D30 · 按{granularityLabel}分桶</small>
|
|
||||||
<div className="sbi-card-toolbar">
|
|
||||||
<div className="sbi-seg" role="radiogroup" aria-label="留存趋势维度">
|
|
||||||
<button
|
|
||||||
aria-checked={activeTrendApp === ALL}
|
|
||||||
className={activeTrendApp === ALL ? "is-active" : ""}
|
|
||||||
onClick={() => setTrendApp(ALL)}
|
|
||||||
role="radio"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
汇总
|
|
||||||
</button>
|
|
||||||
{appTotals.map((row) => (
|
|
||||||
<button
|
|
||||||
aria-checked={activeTrendApp === row.app_code}
|
|
||||||
className={activeTrendApp === row.app_code ? "is-active" : ""}
|
|
||||||
key={row.app_code}
|
|
||||||
onClick={() => setTrendApp(row.app_code)}
|
|
||||||
role="radio"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{row.app_name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{trendOption ? (
|
|
||||||
<EChart className="sbi-rt-chart-trend" option={trendOption} />
|
|
||||||
) : (
|
|
||||||
<div className="sbi-empty">
|
|
||||||
<strong>暂无留存趋势数据</strong>
|
|
||||||
<span>{activeTrendApp === ALL ? "当前筛选没有返回按日序列。" : "该 App 在当前区间没有按日序列。"}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="sbi-card sbi-rt-table-card">
|
|
||||||
<div className="sbi-card-header">
|
|
||||||
<strong>留存对比</strong>
|
|
||||||
<small>D1/D7/D30 底色越深留存越高</small>
|
|
||||||
<div className="sbi-card-toolbar">
|
|
||||||
<div className="sbi-seg" role="radiogroup" aria-label="留存对比维度">
|
|
||||||
<button
|
|
||||||
aria-checked={tableDim === "app"}
|
|
||||||
className={tableDim === "app" ? "is-active" : ""}
|
|
||||||
onClick={() => setTableDim("app")}
|
|
||||||
role="radio"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
按 App
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
aria-checked={tableDim === "region"}
|
|
||||||
className={tableDim === "region" ? "is-active" : ""}
|
|
||||||
onClick={() => setTableDim("region")}
|
|
||||||
role="radio"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
按区域
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{tableRows.length ? (
|
|
||||||
<div className="sbi-table-scroll">
|
|
||||||
<table className="sbi-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th className="is-left">对象</th>
|
|
||||||
{TABLE_METRIC_KEYS.map((key) => (
|
|
||||||
<th key={key}>
|
|
||||||
<span className="sbi-metric-hint" title={metricTooltip(key)}>
|
|
||||||
{metricLabel(key)}
|
|
||||||
</span>
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr className="is-total">
|
|
||||||
<td className="is-left">合计</td>
|
|
||||||
{TABLE_METRIC_KEYS.map((key) => renderMetricCell(key, tableTotal?.[key]))}
|
|
||||||
</tr>
|
|
||||||
{tableRows.map((item) => (
|
|
||||||
<tr key={item.key}>
|
|
||||||
<td className="is-left">{item.label}</td>
|
|
||||||
{TABLE_METRIC_KEYS.map((key) => renderMetricCell(key, item.row[key]))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="sbi-empty">
|
|
||||||
<strong>暂无对比数据</strong>
|
|
||||||
<span>{tableDim === "region" ? "当前筛选下没有区域行,试试放宽顶栏的区域选择。" : "当前筛选下没有 App 合计行。"}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<p className="sbi-rt-footnote">
|
|
||||||
留存按注册日 cohort 计算;观察期未到的日期显示 "--"。hyapp App 的按日留存暂不提供(仅整段区间口径)。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,322 +0,0 @@
|
|||||||
// TeamKpiView:运营中心视图。后端已按当前登录用户的数据范围裁剪,页面只展示可见运营负责范围内的充值数据。
|
|
||||||
// 金额字段为 USD 分,缺失值显示 "--",不在前端把缺失口径当作 0。
|
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { useSocialBi } from "../SocialBiApp.jsx";
|
|
||||||
import { appColor } from "../metrics.js";
|
|
||||||
import { formatCount, formatMoneyMinor, isBlank } from "../format.js";
|
|
||||||
import "./team-kpi-view.css";
|
|
||||||
|
|
||||||
const DIMENSIONS = [
|
|
||||||
{ key: "person", label: "按人员×区域" },
|
|
||||||
{ key: "operatorApp", label: "按人员×App" },
|
|
||||||
{ key: "team", label: "按部门" }
|
|
||||||
];
|
|
||||||
|
|
||||||
function addNullable(sum, value) {
|
|
||||||
if (isBlank(value)) {
|
|
||||||
return sum;
|
|
||||||
}
|
|
||||||
return (sum ?? 0) + Number(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function numberOr(value, fallback) {
|
|
||||||
return isBlank(value) ? fallback : Number(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function compareByMonthRecharge(left, right) {
|
|
||||||
return numberOr(right.monthMinor, -1) - numberOr(left.monthMinor, -1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function operatorLabelOf(item) {
|
|
||||||
return item.operator_name || item.operator_account || `#${item.operator_user_id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function regionLabelOf(item) {
|
|
||||||
return item.region_name || item.region_code || "全部区域";
|
|
||||||
}
|
|
||||||
|
|
||||||
function operatorCountOf(items) {
|
|
||||||
return new Set(items.map((item) => item.operator_user_id).filter((id) => !isBlank(id))).size;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TeamKpiView() {
|
|
||||||
const { appCodes, isLoading, kpi } = useSocialBi();
|
|
||||||
const [dimension, setDimension] = useState("person");
|
|
||||||
|
|
||||||
const items = useMemo(() => kpi?.items || [], [kpi]);
|
|
||||||
|
|
||||||
const personRows = useMemo(() => {
|
|
||||||
const rows = items.map((item, index) => ({
|
|
||||||
item,
|
|
||||||
key: `${item.operator_user_id}-${item.app_code}-${item.region_id}-${index}`,
|
|
||||||
monthMinor: item.month_recharge_usd_minor
|
|
||||||
}));
|
|
||||||
rows.sort(compareByMonthRecharge);
|
|
||||||
return rows;
|
|
||||||
}, [items]);
|
|
||||||
|
|
||||||
const operatorAppRows = useMemo(() => {
|
|
||||||
const rows = (kpi?.operator_app_rows || []).map((item, index) => ({
|
|
||||||
item,
|
|
||||||
key: `${item.operator_user_id}-${item.app_code}-${index}`,
|
|
||||||
monthMinor: item.month_recharge_usd_minor
|
|
||||||
}));
|
|
||||||
rows.sort(compareByMonthRecharge);
|
|
||||||
return rows;
|
|
||||||
}, [kpi]);
|
|
||||||
|
|
||||||
const teamRows = useMemo(() => {
|
|
||||||
const groups = new Map();
|
|
||||||
items.forEach((item) => {
|
|
||||||
const teamName = item.team || "未分组";
|
|
||||||
let group = groups.get(teamName);
|
|
||||||
if (!group) {
|
|
||||||
group = { members: new Set(), monthMinor: null, rangeMinor: null, team: teamName };
|
|
||||||
groups.set(teamName, group);
|
|
||||||
}
|
|
||||||
group.members.add(item.operator_user_id);
|
|
||||||
group.rangeMinor = addNullable(group.rangeMinor, item.range_recharge_usd_minor);
|
|
||||||
group.monthMinor = addNullable(group.monthMinor, item.month_recharge_usd_minor);
|
|
||||||
});
|
|
||||||
const rows = [...groups.values()].map((group) => ({
|
|
||||||
memberCount: group.members.size,
|
|
||||||
monthMinor: group.monthMinor,
|
|
||||||
rangeMinor: group.rangeMinor,
|
|
||||||
team: group.team
|
|
||||||
}));
|
|
||||||
rows.sort(compareByMonthRecharge);
|
|
||||||
return rows;
|
|
||||||
}, [items]);
|
|
||||||
|
|
||||||
if (!items.length) {
|
|
||||||
return isLoading ? (
|
|
||||||
<KpiSkeleton />
|
|
||||||
) : (
|
|
||||||
<section className="sbi-card">
|
|
||||||
<div className="sbi-empty">
|
|
||||||
<strong>暂无充值数据</strong>
|
|
||||||
<span>先在用户管理为运营人员分配 App/区域数据范围</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const summary = kpi.summary || {};
|
|
||||||
const operatorCount = isBlank(summary.operator_count) ? operatorCountOf(items) : summary.operator_count;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="sbi-kpi-view">
|
|
||||||
<section className="sbi-card sbi-kpi-board">
|
|
||||||
<div className="sbi-card-header">
|
|
||||||
<strong>充值排行</strong>
|
|
||||||
<small>
|
|
||||||
{kpi.period_month} · {formatCount(operatorCount)} 名运营
|
|
||||||
</small>
|
|
||||||
<div className="sbi-card-toolbar">
|
|
||||||
<div aria-label="排行维度" className="sbi-seg" role="radiogroup">
|
|
||||||
{DIMENSIONS.map((option) => (
|
|
||||||
<button
|
|
||||||
aria-checked={dimension === option.key}
|
|
||||||
className={dimension === option.key ? "is-active" : ""}
|
|
||||||
key={option.key}
|
|
||||||
onClick={() => setDimension(option.key)}
|
|
||||||
role="radio"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="sbi-table-scroll sbi-kpi-board-scroll">
|
|
||||||
{dimension === "person" ? (
|
|
||||||
<PersonTable appCodes={appCodes} rows={personRows} />
|
|
||||||
) : dimension === "operatorApp" ? (
|
|
||||||
<OperatorAppTable appCodes={appCodes} rows={operatorAppRows} />
|
|
||||||
) : (
|
|
||||||
<TeamTable rows={teamRows} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function KpiSkeleton() {
|
|
||||||
return (
|
|
||||||
<div aria-busy="true" className="sbi-kpi-view">
|
|
||||||
<section className="sbi-card">
|
|
||||||
<div className="sbi-kpi-table-skeleton">
|
|
||||||
{[0, 1, 2, 3, 4, 5].map((index) => (
|
|
||||||
<span className="sbi-skeleton" key={index} style={{ height: 18, width: "100%" }} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RankBadge({ rank }) {
|
|
||||||
const medal = rank === 1 ? " is-gold" : rank === 2 ? " is-silver" : rank === 3 ? " is-bronze" : "";
|
|
||||||
return <span className={`sbi-kpi-rank${medal}`}>{rank}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function OperatorAppTable({ appCodes, rows }) {
|
|
||||||
return (
|
|
||||||
<table className="sbi-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th className="is-left">名次</th>
|
|
||||||
<th className="is-left">运营人员</th>
|
|
||||||
<th className="is-left">App</th>
|
|
||||||
<th className="is-left">负责区域</th>
|
|
||||||
<th>
|
|
||||||
<span className="sbi-metric-hint" title="所选区间内该运营在此 App 负责范围的充值合计(USD)">
|
|
||||||
区间充值
|
|
||||||
</span>
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
<span className="sbi-metric-hint" title="本自然月至今该运营在此 App 负责范围的充值合计(USD)">
|
|
||||||
当月充值
|
|
||||||
</span>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{rows.map((row, index) => {
|
|
||||||
const item = row.item;
|
|
||||||
return (
|
|
||||||
<tr key={row.key}>
|
|
||||||
<td className="is-left">
|
|
||||||
<RankBadge rank={index + 1} />
|
|
||||||
</td>
|
|
||||||
<td className="is-left">
|
|
||||||
<OperatorCell item={item} />
|
|
||||||
</td>
|
|
||||||
<td className="is-left">
|
|
||||||
<AppCell appCodes={appCodes} item={item} />
|
|
||||||
</td>
|
|
||||||
<td className="is-left">{item.whole_app_scope ? "全部区域" : item.region_names || `${formatCount(item.region_count)} 个区域`}</td>
|
|
||||||
<td>{formatMoneyMinor(item.range_recharge_usd_minor)}</td>
|
|
||||||
<td>{formatMoneyMinor(item.month_recharge_usd_minor)}</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PersonTable({ appCodes, rows }) {
|
|
||||||
return (
|
|
||||||
<table className="sbi-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th className="is-left">名次</th>
|
|
||||||
<th className="is-left">运营人员</th>
|
|
||||||
<th className="is-left">App</th>
|
|
||||||
<th className="is-left">区域</th>
|
|
||||||
<th>
|
|
||||||
<span className="sbi-metric-hint" title="所选区间内负责范围的充值合计(USD)">
|
|
||||||
区间充值
|
|
||||||
</span>
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
<span className="sbi-metric-hint" title="本自然月至今负责范围的充值合计(USD)">
|
|
||||||
当月充值
|
|
||||||
</span>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{rows.map((row, index) => {
|
|
||||||
const item = row.item;
|
|
||||||
return (
|
|
||||||
<tr key={row.key}>
|
|
||||||
<td className="is-left">
|
|
||||||
<RankBadge rank={index + 1} />
|
|
||||||
</td>
|
|
||||||
<td className="is-left">
|
|
||||||
<OperatorCell item={item} />
|
|
||||||
</td>
|
|
||||||
<td className="is-left">
|
|
||||||
<AppCell appCodes={appCodes} item={item} />
|
|
||||||
</td>
|
|
||||||
<td className="is-left">{regionLabelOf(item)}</td>
|
|
||||||
<td>{formatMoneyMinor(item.range_recharge_usd_minor)}</td>
|
|
||||||
<td>{formatMoneyMinor(item.month_recharge_usd_minor)}</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TeamTable({ rows }) {
|
|
||||||
return (
|
|
||||||
<table className="sbi-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th className="is-left">名次</th>
|
|
||||||
<th className="is-left">部门</th>
|
|
||||||
<th>
|
|
||||||
<span className="sbi-metric-hint" title="部门内去重运营人数">
|
|
||||||
人数
|
|
||||||
</span>
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
<span className="sbi-metric-hint" title="部门内区间充值合计(USD)">
|
|
||||||
区间充值
|
|
||||||
</span>
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
<span className="sbi-metric-hint" title="部门内本月充值合计(USD)">
|
|
||||||
当月充值
|
|
||||||
</span>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{rows.map((row, index) => (
|
|
||||||
<tr key={row.team}>
|
|
||||||
<td className="is-left">
|
|
||||||
<RankBadge rank={index + 1} />
|
|
||||||
</td>
|
|
||||||
<td className="is-left">{row.team}</td>
|
|
||||||
<td>{formatCount(row.memberCount)}</td>
|
|
||||||
<td>{formatMoneyMinor(row.rangeMinor)}</td>
|
|
||||||
<td>{formatMoneyMinor(row.monthMinor)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function OperatorCell({ item }) {
|
|
||||||
return (
|
|
||||||
<div className="sbi-kpi-person">
|
|
||||||
<span className="sbi-kpi-person-top">
|
|
||||||
<strong>{operatorLabelOf(item)}</strong>
|
|
||||||
{item.data_error ? (
|
|
||||||
<span className="sbi-badge is-warn" title={item.data_error}>
|
|
||||||
数据异常
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
<small>{[item.operator_account, item.team || "未分组"].filter(Boolean).join(" · ")}</small>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AppCell({ appCodes, item }) {
|
|
||||||
return (
|
|
||||||
<span className="sbi-kpi-app">
|
|
||||||
<span className="sbi-kpi-app-dot" style={{ background: appColor(item.app_code, appCodes) }} />
|
|
||||||
{item.app_name || item.app_code}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,100 +0,0 @@
|
|||||||
/* 数据明细视图(DataTableView)专属样式,类名前缀 sbi-dt-。
|
|
||||||
颜色/圆角只使用 social-v2.css 的 token 与其既有中性色(#f1f5fa / #f7fafd / rgba(16,34,56,…))。 */
|
|
||||||
|
|
||||||
.sbi-dt-tools {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
padding: 12px 18px 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-dt-groups {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-dt-groups-label {
|
|
||||||
color: var(--sbi-text-3);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-dt-export {
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 表格纵向滚动容器:限制高度让两级表头的 sticky 生效,底部跟随卡片圆角。 */
|
|
||||||
.sbi-dt-scroll {
|
|
||||||
max-height: 72vh;
|
|
||||||
border-top: 1px solid var(--sbi-border);
|
|
||||||
border-radius: 0 0 var(--sbi-radius) var(--sbi-radius);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 第一行:列组表头(浅灰底、居中、固定高度,供第二行 sticky 偏移用)。 */
|
|
||||||
.sbi-dt-table th.sbi-dt-group-th {
|
|
||||||
box-sizing: border-box;
|
|
||||||
height: 34px;
|
|
||||||
padding: 0 12px;
|
|
||||||
background: #f1f5fa;
|
|
||||||
color: var(--sbi-text-3);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 第二行:指标表头,sticky 吸附在列组行下方,可点击排序。 */
|
|
||||||
.sbi-dt-table th.sbi-dt-metric-th {
|
|
||||||
top: 34px;
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-dt-table th.sbi-dt-metric-th:hover,
|
|
||||||
.sbi-dt-table th.sbi-dt-metric-th.is-sorted {
|
|
||||||
color: var(--sbi-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-dt-sort {
|
|
||||||
margin-left: 3px;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 首列冻结:白底 + 右侧阴影;合计行/悬浮行的底色由 social-v2.css 中更高优先级规则接管。 */
|
|
||||||
.sbi-dt-table .sbi-dt-sticky {
|
|
||||||
position: sticky;
|
|
||||||
left: 0;
|
|
||||||
box-shadow: 6px 0 10px -8px rgba(16, 34, 56, 0.28);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-dt-table td.sbi-dt-sticky {
|
|
||||||
z-index: 1;
|
|
||||||
background: var(--sbi-card);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-dt-table th.sbi-dt-sticky {
|
|
||||||
z-index: 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 截断提示(表尾整行)。 */
|
|
||||||
.sbi-dt-note td {
|
|
||||||
padding: 10px 12px;
|
|
||||||
background: #f7fafd;
|
|
||||||
color: var(--sbi-text-3);
|
|
||||||
font-size: 12px;
|
|
||||||
text-align: center;
|
|
||||||
white-space: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 无数据时的加载骨架。 */
|
|
||||||
.sbi-dt-skeleton {
|
|
||||||
display: grid;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 6px 18px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-dt-skeleton .sbi-skeleton {
|
|
||||||
height: 14px;
|
|
||||||
}
|
|
||||||
@ -1,221 +0,0 @@
|
|||||||
/* 埋点漏斗视图专属样式:类名前缀 sbi-funnel-,沿用 social-v2 token。 */
|
|
||||||
|
|
||||||
.sbi-funnel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-funnel-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-funnel-head h1 {
|
|
||||||
margin: 0 0 4px;
|
|
||||||
color: var(--sbi-text);
|
|
||||||
font-size: 22px;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-funnel-head span {
|
|
||||||
color: var(--sbi-text-2);
|
|
||||||
font-size: 12.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-funnel-apps {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-funnel-apps span {
|
|
||||||
padding: 5px 10px;
|
|
||||||
border: 1px solid var(--sbi-border);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #ffffff;
|
|
||||||
color: var(--sbi-text-2);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-funnel-warning {
|
|
||||||
display: grid;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
border: 1px solid #f3ddc0;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: var(--sbi-warn-bg);
|
|
||||||
color: var(--sbi-warn-text);
|
|
||||||
font-size: 12.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-funnel-summary {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-funnel-kpi {
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-funnel-kpi span {
|
|
||||||
color: var(--sbi-text-2);
|
|
||||||
font-size: 12.5px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-funnel-kpi strong {
|
|
||||||
color: var(--sbi-text);
|
|
||||||
font-size: 23px;
|
|
||||||
line-height: 1.1;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-funnel-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(380px, 0.95fr) minmax(520px, 1.25fr);
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-funnel-chart-card,
|
|
||||||
.sbi-funnel-table-card,
|
|
||||||
.sbi-funnel-cohort-card {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-card-title {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 14px 18px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-card-title h2 {
|
|
||||||
margin: 0 0 3px;
|
|
||||||
color: var(--sbi-text);
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-card-title span {
|
|
||||||
color: var(--sbi-text-3);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-funnel-chart {
|
|
||||||
height: 358px;
|
|
||||||
margin: 8px 8px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-funnel-step-scroll {
|
|
||||||
max-height: 390px;
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-funnel-step-table td:first-child {
|
|
||||||
min-width: 150px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-funnel-step-table td strong {
|
|
||||||
display: block;
|
|
||||||
color: var(--sbi-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-funnel-step-table td span {
|
|
||||||
display: block;
|
|
||||||
margin-top: 2px;
|
|
||||||
color: var(--sbi-text-3);
|
|
||||||
font-size: 11.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-funnel-step-table tr.is-failure td {
|
|
||||||
background: #fff7ed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-funnel-step-table tr.is-failure td:first-child strong {
|
|
||||||
color: #b45309;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-funnel-cohort-card {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-funnel-cohort-title {
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-funnel-tabs {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 3px;
|
|
||||||
border: 1px solid var(--sbi-border);
|
|
||||||
border-radius: 9px;
|
|
||||||
background: #f1f5fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-funnel-tabs button {
|
|
||||||
padding: 6px 10px;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 7px;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--sbi-text-2);
|
|
||||||
font-size: 12.5px;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-funnel-tabs button.is-active {
|
|
||||||
background: #ffffff;
|
|
||||||
color: var(--sbi-accent);
|
|
||||||
box-shadow: 0 1px 3px rgba(16, 34, 56, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-funnel-cohort-scroll {
|
|
||||||
max-height: 520px;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-funnel-cohort-table th,
|
|
||||||
.sbi-funnel-cohort-table td {
|
|
||||||
min-width: 104px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-funnel-cohort-table th:first-child,
|
|
||||||
.sbi-funnel-cohort-table td:first-child {
|
|
||||||
min-width: 150px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-funnel-empty-cell {
|
|
||||||
padding: 34px 12px !important;
|
|
||||||
color: var(--sbi-text-3);
|
|
||||||
text-align: center !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-funnel-skeleton {
|
|
||||||
display: grid;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-funnel-skeleton .sbi-skeleton {
|
|
||||||
height: 34px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1360px) {
|
|
||||||
.sbi-funnel-summary {
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-funnel-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,195 +0,0 @@
|
|||||||
/* 经营概览视图专属样式:只消费 social-v2.css 的 token,类名前缀 sbi-ov-。 */
|
|
||||||
|
|
||||||
.sbi-ov {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-ov-info {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- Hero KPI ---------- */
|
|
||||||
|
|
||||||
.sbi-ov-hero {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-ov-hero-card {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 14px 16px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-ov-hero-top {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-ov-hero-label {
|
|
||||||
overflow: hidden;
|
|
||||||
color: var(--sbi-text-2);
|
|
||||||
font-size: 12.5px;
|
|
||||||
font-weight: 640;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-ov-hero-value {
|
|
||||||
color: var(--sbi-text);
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 800;
|
|
||||||
line-height: 1.15;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-ov-hero-spark {
|
|
||||||
height: 38px;
|
|
||||||
margin-top: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-ov-spark-chart {
|
|
||||||
width: 100%;
|
|
||||||
height: 38px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- 卡片主体 / 图表容器 ---------- */
|
|
||||||
|
|
||||||
.sbi-ov-card-body {
|
|
||||||
padding: 12px 18px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-ov-trend-chart {
|
|
||||||
height: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-ov-channel-chart {
|
|
||||||
height: 240px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-ov-duo {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- App 对比 / 区域 Top5 横向条 ---------- */
|
|
||||||
|
|
||||||
.sbi-ov-compare {
|
|
||||||
display: grid;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-ov-compare-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(110px, 150px) minmax(0, 1fr) max-content max-content;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-ov-compare-name {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 7px;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
color: var(--sbi-text);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 640;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-ov-dot {
|
|
||||||
flex: none;
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-ov-bar-track {
|
|
||||||
height: 10px;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #eef3f9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-ov-bar-fill {
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--sbi-accent-grad);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-ov-num {
|
|
||||||
min-width: 84px;
|
|
||||||
color: var(--sbi-text);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-align: right;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-ov-link {
|
|
||||||
padding: 2px 6px;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--sbi-accent);
|
|
||||||
font-size: 12.5px;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-ov-link:hover {
|
|
||||||
background: rgba(37, 87, 214, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-ov-top {
|
|
||||||
display: grid;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-ov-top-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(180px, 260px) minmax(0, 1fr) max-content;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-ov-top-name {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
min-width: 0;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 640;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-ov-top-name > strong {
|
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- 骨架 ---------- */
|
|
||||||
|
|
||||||
.sbi-ov-skeleton-card {
|
|
||||||
display: grid;
|
|
||||||
padding: 16px 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1280px) {
|
|
||||||
.sbi-ov-hero {
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,222 +0,0 @@
|
|||||||
/* 地区洞察(RegionsView)专属样式,类名前缀 sbi-rg-;颜色/圆角一律取 social-v2.css 的 token。 */
|
|
||||||
|
|
||||||
/* ---------- 概要条 ---------- */
|
|
||||||
|
|
||||||
.sbi-rg-summary {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-rg-summary-card {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 16px 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-rg-summary-label {
|
|
||||||
color: var(--sbi-text-2);
|
|
||||||
font-size: 12.5px;
|
|
||||||
font-weight: 640;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-rg-summary-value {
|
|
||||||
color: var(--sbi-text);
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 760;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-rg-summary-sub {
|
|
||||||
color: var(--sbi-text-3);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- 世界地图卡 ---------- */
|
|
||||||
|
|
||||||
.sbi-rg-map-card {
|
|
||||||
display: flex;
|
|
||||||
height: 380px;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-rg-map-body {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
padding: 6px 12px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-rg-map-body .sbi-empty {
|
|
||||||
height: 100%;
|
|
||||||
place-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-rg-map-chart {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- 区域卡片网格 ---------- */
|
|
||||||
|
|
||||||
.sbi-rg-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-rg-region-card {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 16px 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-rg-region-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-rg-region-name {
|
|
||||||
overflow: hidden;
|
|
||||||
font-size: 14.5px;
|
|
||||||
font-weight: 720;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-rg-app-tag {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
color: var(--sbi-text-2);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 640;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-rg-region-head .sbi-rg-app-tag {
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-rg-app-dot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
flex: none;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-rg-region-money {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-rg-region-money small {
|
|
||||||
color: var(--sbi-text-3);
|
|
||||||
font-size: 11.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-rg-region-money strong {
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 760;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-rg-region-metrics {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-rg-region-metric {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-rg-region-metric small {
|
|
||||||
color: var(--sbi-text-3);
|
|
||||||
font-size: 11.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-rg-region-metric span {
|
|
||||||
font-size: 13.5px;
|
|
||||||
font-weight: 700;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-rg-share {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
margin-top: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-rg-share-head {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
color: var(--sbi-text-3);
|
|
||||||
font-size: 11.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-rg-share-track {
|
|
||||||
display: block;
|
|
||||||
height: 6px;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--sbi-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-rg-share-fill {
|
|
||||||
display: block;
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--sbi-accent-grad);
|
|
||||||
transition: width 200ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- 下钻表 ---------- */
|
|
||||||
|
|
||||||
.sbi-rg-table-card .sbi-table-scroll {
|
|
||||||
max-height: 540px;
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-rg-row {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-rg-region-cell {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-rg-caret {
|
|
||||||
display: inline-block;
|
|
||||||
width: 14px;
|
|
||||||
color: var(--sbi-text-3);
|
|
||||||
text-align: center;
|
|
||||||
transition: transform 120ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-rg-caret.is-open {
|
|
||||||
transform: rotate(90deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-rg-child td {
|
|
||||||
background: var(--sbi-bg);
|
|
||||||
color: var(--sbi-text-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-rg-child-name {
|
|
||||||
display: inline-block;
|
|
||||||
padding-left: 26px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-rg-child-app {
|
|
||||||
color: var(--sbi-text-3);
|
|
||||||
}
|
|
||||||
@ -1,102 +0,0 @@
|
|||||||
/* 留存质量视图(RetentionView)专属样式;公共原语(卡片/表格/seg/骨架)见 social-v2.css。 */
|
|
||||||
|
|
||||||
.sbi-rt-view {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- 概要卡 ---------- */
|
|
||||||
|
|
||||||
.sbi-rt-summary {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-rt-summary-card {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 16px 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-rt-summary-label {
|
|
||||||
color: var(--sbi-text-2);
|
|
||||||
font-size: 12.5px;
|
|
||||||
font-weight: 640;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-rt-summary-value {
|
|
||||||
color: var(--sbi-text);
|
|
||||||
font-size: 26px;
|
|
||||||
font-weight: 760;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
line-height: 1.15;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-rt-summary-note {
|
|
||||||
color: var(--sbi-text-3);
|
|
||||||
font-size: 11.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- 图表卡(EChart 容器必须有确定高度) ---------- */
|
|
||||||
|
|
||||||
.sbi-rt-chart-combo {
|
|
||||||
height: 300px;
|
|
||||||
margin: 4px 12px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-rt-chart-trend {
|
|
||||||
height: 280px;
|
|
||||||
margin: 4px 12px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- 热力表 ---------- */
|
|
||||||
|
|
||||||
.sbi-rt-table-card .sbi-table-scroll {
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- 底部口径说明 ---------- */
|
|
||||||
|
|
||||||
.sbi-rt-footnote {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--sbi-text-3);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- 骨架 ---------- */
|
|
||||||
|
|
||||||
.sbi-rt-skeleton-line {
|
|
||||||
display: block;
|
|
||||||
width: 96px;
|
|
||||||
height: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-rt-skeleton-value {
|
|
||||||
display: block;
|
|
||||||
width: 150px;
|
|
||||||
height: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-rt-skeleton-chart {
|
|
||||||
display: block;
|
|
||||||
width: auto;
|
|
||||||
height: 232px;
|
|
||||||
margin: 14px 16px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-rt-skeleton-table {
|
|
||||||
display: block;
|
|
||||||
width: auto;
|
|
||||||
height: 180px;
|
|
||||||
margin: 14px 16px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 960px) {
|
|
||||||
.sbi-rt-summary {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,76 +0,0 @@
|
|||||||
/* 运营中心充值排行视图专属样式:仅消费 social-v2.css 的 token,类名前缀 sbi-kpi-。 */
|
|
||||||
|
|
||||||
.sbi-kpi-view {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-kpi-board-scroll {
|
|
||||||
max-height: 620px;
|
|
||||||
margin-top: 12px;
|
|
||||||
border-radius: 0 0 var(--sbi-radius) var(--sbi-radius);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-kpi-rank {
|
|
||||||
display: inline-grid;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
place-items: center;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #f1f5fa;
|
|
||||||
color: var(--sbi-text-2);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 740;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-kpi-rank.is-gold {
|
|
||||||
background: linear-gradient(135deg, #f6c34c, #d28f0e);
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-kpi-rank.is-silver {
|
|
||||||
background: linear-gradient(135deg, #cfd8e3, #97a4b4);
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-kpi-rank.is-bronze {
|
|
||||||
background: linear-gradient(135deg, #d9a06b, #a56a3c);
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-kpi-person {
|
|
||||||
display: grid;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-kpi-person-top {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-kpi-person small {
|
|
||||||
color: var(--sbi-text-3);
|
|
||||||
font-size: 11.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-kpi-app {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 7px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-kpi-app-dot {
|
|
||||||
flex: none;
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-kpi-table-skeleton {
|
|
||||||
display: grid;
|
|
||||||
gap: 14px;
|
|
||||||
padding: 18px;
|
|
||||||
}
|
|
||||||
@ -6,4 +6,5 @@
|
|||||||
@import "./charts.css";
|
@import "./charts.css";
|
||||||
@import "./tables.css";
|
@import "./tables.css";
|
||||||
@import "./report.css";
|
@import "./report.css";
|
||||||
|
@import "./social-bi.css";
|
||||||
@import "./responsive.css";
|
@import "./responsive.css";
|
||||||
|
|||||||
656
databi/src/styles/social-bi.css
Normal file
656
databi/src/styles/social-bi.css
Normal file
@ -0,0 +1,656 @@
|
|||||||
|
.social-bi-shell {
|
||||||
|
--social-bi-control-height: 40px;
|
||||||
|
display: grid;
|
||||||
|
min-width: 1040px;
|
||||||
|
min-height: 100vh;
|
||||||
|
grid-template-columns: 72px minmax(0, 1fr);
|
||||||
|
background: #f5f7fb;
|
||||||
|
color: #172033;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 10px;
|
||||||
|
border-right: 1px solid #dfe8f2;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-sidebar-brand {
|
||||||
|
display: flex;
|
||||||
|
height: 42px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-sidebar-brand span {
|
||||||
|
display: grid;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #1688d9;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-sidebar-brand strong {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-sidebar-nav {
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
gap: 8px;
|
||||||
|
justify-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-nav-item {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
width: 48px;
|
||||||
|
height: 44px;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: #5f6f86;
|
||||||
|
font: inherit;
|
||||||
|
transition:
|
||||||
|
background-color 180ms cubic-bezier(0.2, 0, 0, 1),
|
||||||
|
border-color 180ms cubic-bezier(0.2, 0, 0, 1),
|
||||||
|
color 180ms cubic-bezier(0.2, 0, 0, 1),
|
||||||
|
transform 180ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-nav-item svg {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-nav-item span {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-nav-item:hover {
|
||||||
|
border-color: #e0ebf5;
|
||||||
|
background: #f4f8fc;
|
||||||
|
color: #34445a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-nav-item.is-active,
|
||||||
|
.social-bi-nav-item.is-active:hover {
|
||||||
|
border-color: #cfe3f5;
|
||||||
|
background: #eef7ff;
|
||||||
|
color: #126fb2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-nav-item:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-main {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
align-content: start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 14px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-filter-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #e4ebf3;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-filter-grid {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-field {
|
||||||
|
position: relative;
|
||||||
|
flex: 0 0 220px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-date-field {
|
||||||
|
flex-basis: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-dropdown--wide {
|
||||||
|
flex-basis: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-field-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
color: #718198;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 680;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-date-options {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
height: var(--social-bi-control-height);
|
||||||
|
gap: 3px;
|
||||||
|
padding: 3px;
|
||||||
|
border: 1px solid #d8e5f0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f8fbfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-date-options button {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
place-items: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: transparent;
|
||||||
|
color: #5f6f86;
|
||||||
|
font: inherit;
|
||||||
|
transition:
|
||||||
|
background-color 180ms cubic-bezier(0.2, 0, 0, 1),
|
||||||
|
color 180ms cubic-bezier(0.2, 0, 0, 1),
|
||||||
|
box-shadow 180ms cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-date-options button span {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 720;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-date-options button small {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #8c9aab;
|
||||||
|
font-size: 9px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-date-options button:hover,
|
||||||
|
.social-bi-date-options button.is-active {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #126fb2;
|
||||||
|
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-dropdown summary {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
height: var(--social-bi-control-height);
|
||||||
|
min-height: var(--social-bi-control-height);
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 30px 0 10px;
|
||||||
|
border: 1px solid #d8e5f0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #172033;
|
||||||
|
list-style: none;
|
||||||
|
transition:
|
||||||
|
border-color 180ms cubic-bezier(0.2, 0, 0, 1),
|
||||||
|
box-shadow 180ms cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-dropdown summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-dropdown summary::after {
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
top: 50%;
|
||||||
|
color: #8c9aab;
|
||||||
|
content: "⌄";
|
||||||
|
font-size: 16px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-dropdown summary:hover,
|
||||||
|
.social-bi-dropdown[open] summary {
|
||||||
|
border-color: #1688d9;
|
||||||
|
box-shadow: 0 0 0 3px rgba(22, 136, 217, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-dropdown summary strong {
|
||||||
|
display: block;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #172033;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 720;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-dropdown summary .social-bi-field-label {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 12;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
left: 0;
|
||||||
|
display: grid;
|
||||||
|
width: min(320px, 90vw);
|
||||||
|
max-height: 330px;
|
||||||
|
gap: 4px;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #d8e5f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-dropdown--wide .social-bi-dropdown-menu {
|
||||||
|
width: min(380px, 90vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-dropdown-menu--search input {
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: var(--social-bi-control-height);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid #d8e5f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #172033;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-dropdown-menu--search input:focus {
|
||||||
|
border-color: #1688d9;
|
||||||
|
box-shadow: 0 0 0 3px rgba(22, 136, 217, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-option {
|
||||||
|
display: flex;
|
||||||
|
min-height: 34px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0 8px 0 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: #34445a;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
transition:
|
||||||
|
background-color 160ms cubic-bezier(0.2, 0, 0, 1),
|
||||||
|
color 160ms cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-option:hover,
|
||||||
|
.social-bi-option.is-selected {
|
||||||
|
background: #eef7ff;
|
||||||
|
color: #126fb2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-option .MuiCheckbox-root {
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-cascade-menu {
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-cascade-group {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-cascade-children {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
padding-left: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-dropdown-empty {
|
||||||
|
padding: 10px 8px;
|
||||||
|
color: #718198;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-query-button.MuiButton-root {
|
||||||
|
height: var(--social-bi-control-height);
|
||||||
|
min-height: var(--social-bi-control-height);
|
||||||
|
min-width: 84px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #1688d9;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-query-button.MuiButton-root:hover {
|
||||||
|
background: #126fb2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-load-error {
|
||||||
|
align-self: center;
|
||||||
|
color: #c2410c;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-summary-section {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-summary-card {
|
||||||
|
display: grid;
|
||||||
|
min-height: 82px;
|
||||||
|
align-content: space-between;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #e4ebf3;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-summary-card > span {
|
||||||
|
color: #718198;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 680;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-summary-card strong {
|
||||||
|
color: #172033;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-delta {
|
||||||
|
width: max-content;
|
||||||
|
padding: 3px 6px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-delta.is-up {
|
||||||
|
background: #ecfdf3;
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-delta.is-down {
|
||||||
|
background: #fff1f2;
|
||||||
|
color: #be123c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-table-section {
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid #e4ebf3;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-table-head {
|
||||||
|
display: flex;
|
||||||
|
height: 42px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-bottom: 1px solid #e4ebf3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-table-head span {
|
||||||
|
color: #718198;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 680;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-table-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-table {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 1120px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-table th,
|
||||||
|
.social-bi-table td {
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-bottom: 1px solid #edf2f7;
|
||||||
|
color: #34445a;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-table th {
|
||||||
|
background: #f8fbfe;
|
||||||
|
color: #5f6f86;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-table th.is-left,
|
||||||
|
.social-bi-table td.is-left {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-table th button {
|
||||||
|
display: inline-flex;
|
||||||
|
max-width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-table th.is-left button {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-table th button:hover,
|
||||||
|
.social-bi-table th button.is-active {
|
||||||
|
color: #126fb2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-sort-arrow {
|
||||||
|
color: #8c9aab;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-table tr.is-total td {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background: #f0f7ff;
|
||||||
|
color: #172033;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-pair-cell {
|
||||||
|
display: inline-grid;
|
||||||
|
min-width: 86px;
|
||||||
|
gap: 2px;
|
||||||
|
justify-items: end;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-pair-cell strong {
|
||||||
|
color: #172033;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-pair-cell small {
|
||||||
|
color: #718198;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 680;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-progress-cell {
|
||||||
|
display: grid;
|
||||||
|
min-width: 150px;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-progress-copy {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-progress-copy strong {
|
||||||
|
color: #172033;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-progress-copy span {
|
||||||
|
color: #126fb2;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-progress-track {
|
||||||
|
height: 5px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e8eef5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-progress-track span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(90deg, #1688d9, #1fbf75);
|
||||||
|
transition: width 220ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-goal-empty {
|
||||||
|
color: #8c9aab;
|
||||||
|
font-weight: 680;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-empty-row td {
|
||||||
|
height: 64px;
|
||||||
|
color: #718198;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-skeleton {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #edf2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-skeleton::after {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
animation: social-bi-shimmer 1.15s infinite;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.75), transparent);
|
||||||
|
content: "";
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-skeleton--title {
|
||||||
|
width: 54%;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-skeleton--value {
|
||||||
|
width: 74%;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-skeleton--line {
|
||||||
|
width: 42%;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-skeleton--cell {
|
||||||
|
width: 100%;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes social-bi-shimmer {
|
||||||
|
to {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1360px) {
|
||||||
|
.social-bi-summary-section {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.social-bi-shell {
|
||||||
|
min-width: 0;
|
||||||
|
grid-template-columns: 64px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-filter-section {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-field,
|
||||||
|
.social-bi-date-field,
|
||||||
|
.social-bi-dropdown--wide {
|
||||||
|
flex: 1 1 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-query-button.MuiButton-root {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-summary-section {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.social-bi-shell *,
|
||||||
|
.social-bi-shell *::before,
|
||||||
|
.social-bi-shell *::after {
|
||||||
|
animation-duration: 1ms !important;
|
||||||
|
transition-duration: 1ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,588 +0,0 @@
|
|||||||
/* 社交 BI v2 设计基座:与 finance v2 同一设计语言(深色海军侧栏 + 浅色内容区 + 蓝青渐变强调)。
|
|
||||||
各视图自己的样式放在 databi/src/social/views/*.css,只允许使用这里定义的 token。 */
|
|
||||||
|
|
||||||
.sbi-shell {
|
|
||||||
--sbi-sidebar-bg: #101b2d;
|
|
||||||
--sbi-sidebar-hover: rgba(120, 165, 255, 0.12);
|
|
||||||
--sbi-accent: #2557d6;
|
|
||||||
--sbi-accent-2: #0e7490;
|
|
||||||
--sbi-accent-grad: linear-gradient(135deg, #2557d6, #0e7490);
|
|
||||||
--sbi-bg: #f6f8fb;
|
|
||||||
--sbi-card: #ffffff;
|
|
||||||
--sbi-border: #e3eaf3;
|
|
||||||
--sbi-text: #14263c;
|
|
||||||
--sbi-text-2: #5b7089;
|
|
||||||
--sbi-text-3: #8aa0b8;
|
|
||||||
--sbi-up: #0f9d58;
|
|
||||||
--sbi-down: #d93025;
|
|
||||||
--sbi-warn-bg: #fdf3e7;
|
|
||||||
--sbi-warn-text: #9a5b13;
|
|
||||||
--sbi-radius: 12px;
|
|
||||||
--sbi-shadow: 0 1px 2px rgba(16, 34, 56, 0.06), 0 4px 16px rgba(16, 34, 56, 0.05);
|
|
||||||
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 216px minmax(0, 1fr);
|
|
||||||
min-height: 100vh;
|
|
||||||
min-width: 1080px;
|
|
||||||
background: var(--sbi-bg);
|
|
||||||
color: var(--sbi-text);
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- 侧栏 ---------- */
|
|
||||||
|
|
||||||
.sbi-sidebar {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
display: flex;
|
|
||||||
height: 100vh;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 20px;
|
|
||||||
padding: 18px 12px;
|
|
||||||
background: var(--sbi-sidebar-bg);
|
|
||||||
color: #dbe7f7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-brand {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 2px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-brand-mark {
|
|
||||||
display: grid;
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
place-items: center;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: var(--sbi-accent-grad);
|
|
||||||
color: #ffffff;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-brand-copy {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-brand-copy strong {
|
|
||||||
font-size: 15px;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-brand-copy small {
|
|
||||||
color: #7e8db0;
|
|
||||||
font-size: 11px;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-nav {
|
|
||||||
display: grid;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-nav-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 9px;
|
|
||||||
background: transparent;
|
|
||||||
color: #a9bcd8;
|
|
||||||
font-size: 13.5px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 120ms ease, color 120ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-nav-item:hover {
|
|
||||||
background: var(--sbi-sidebar-hover);
|
|
||||||
color: #e9f2ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-nav-item.is-active {
|
|
||||||
background: var(--sbi-accent-grad);
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-scope-badge {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: auto;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border: 1px solid rgba(126, 141, 176, 0.35);
|
|
||||||
border-radius: 9px;
|
|
||||||
color: #a9bcd8;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-scope-dot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #38bdf8;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- 主区 / 顶栏 ---------- */
|
|
||||||
|
|
||||||
.sbi-main {
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-topbar {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 30;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 14px 22px;
|
|
||||||
border-bottom: 1px solid var(--sbi-border);
|
|
||||||
background: rgba(255, 255, 255, 0.92);
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-topbar-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-date-presets {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 3px;
|
|
||||||
border: 1px solid var(--sbi-border);
|
|
||||||
border-radius: 9px;
|
|
||||||
background: #f1f5fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-date-presets > button {
|
|
||||||
padding: 6px 12px;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 7px;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--sbi-text-2);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 640;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-date-presets > button.is-active {
|
|
||||||
background: #ffffff;
|
|
||||||
color: var(--sbi-accent);
|
|
||||||
box-shadow: 0 1px 3px rgba(16, 34, 56, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 自定义区间触发按钮:复用全局 TimeRangeFilter(@/shared/ui),这里只做与预设条的对齐微调。 */
|
|
||||||
.sbi-custom-range {
|
|
||||||
margin-left: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-granularity {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 3px;
|
|
||||||
border: 1px solid var(--sbi-border);
|
|
||||||
border-radius: 9px;
|
|
||||||
background: #f1f5fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-granularity > button {
|
|
||||||
padding: 6px 12px;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 7px;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--sbi-text-2);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 640;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-granularity > button.is-active {
|
|
||||||
background: #ffffff;
|
|
||||||
color: var(--sbi-accent);
|
|
||||||
box-shadow: 0 1px 3px rgba(16, 34, 56, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-refresh {
|
|
||||||
display: grid;
|
|
||||||
width: 34px;
|
|
||||||
height: 34px;
|
|
||||||
place-items: center;
|
|
||||||
margin-left: auto;
|
|
||||||
border: 1px solid var(--sbi-border);
|
|
||||||
border-radius: 9px;
|
|
||||||
background: #ffffff;
|
|
||||||
color: var(--sbi-text-2);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-refresh:hover {
|
|
||||||
color: var(--sbi-accent);
|
|
||||||
border-color: var(--sbi-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-app-chips {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 7px;
|
|
||||||
padding: 6px 13px;
|
|
||||||
border: 1px solid var(--sbi-border);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #ffffff;
|
|
||||||
color: var(--sbi-text-2);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 640;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 120ms ease, color 120ms ease, box-shadow 120ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-chip:hover {
|
|
||||||
border-color: var(--sbi-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-chip.is-active {
|
|
||||||
border-color: var(--sbi-accent);
|
|
||||||
color: var(--sbi-accent);
|
|
||||||
box-shadow: 0 0 0 3px rgba(37, 87, 214, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-chip-dot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-region-select {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-region-trigger {
|
|
||||||
height: 32px;
|
|
||||||
padding: 0 14px;
|
|
||||||
border: 1px solid var(--sbi-border);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #ffffff;
|
|
||||||
color: var(--sbi-text-2);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 640;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-region-trigger:hover {
|
|
||||||
border-color: var(--sbi-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-region-menu {
|
|
||||||
position: absolute;
|
|
||||||
top: calc(100% + 6px);
|
|
||||||
left: 0;
|
|
||||||
z-index: 40;
|
|
||||||
display: grid;
|
|
||||||
gap: 2px;
|
|
||||||
min-width: 260px;
|
|
||||||
max-height: 360px;
|
|
||||||
overflow: auto;
|
|
||||||
padding: 8px;
|
|
||||||
border: 1px solid var(--sbi-border);
|
|
||||||
border-radius: 10px;
|
|
||||||
background: #ffffff;
|
|
||||||
box-shadow: var(--sbi-shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-region-group {
|
|
||||||
display: grid;
|
|
||||||
gap: 2px;
|
|
||||||
padding-top: 4px;
|
|
||||||
border-top: 1px dashed var(--sbi-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-region-option {
|
|
||||||
padding: 7px 10px;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 7px;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--sbi-text);
|
|
||||||
font-size: 13px;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-region-option:hover {
|
|
||||||
background: #f1f5fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-region-option.is-group {
|
|
||||||
color: var(--sbi-text-2);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-region-option.is-active {
|
|
||||||
background: rgba(37, 87, 214, 0.08);
|
|
||||||
color: var(--sbi-accent);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-region-empty {
|
|
||||||
margin: 6px;
|
|
||||||
color: var(--sbi-text-3);
|
|
||||||
font-size: 12.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-range-label {
|
|
||||||
color: var(--sbi-text-3);
|
|
||||||
font-size: 12.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-loading-dot {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--sbi-accent);
|
|
||||||
animation: sbi-pulse 900ms ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes sbi-pulse {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
opacity: 0.25;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-error-banner {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
margin: 12px 22px 0;
|
|
||||||
padding: 10px 14px;
|
|
||||||
border: 1px solid #f3ddc0;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: var(--sbi-warn-bg);
|
|
||||||
color: var(--sbi-warn-text);
|
|
||||||
font-size: 12.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-view {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 18px 22px 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- 视图共用原语(卡片/表格/骨架/空态/徽标) ---------- */
|
|
||||||
|
|
||||||
.sbi-card {
|
|
||||||
border: 1px solid var(--sbi-border);
|
|
||||||
border-radius: var(--sbi-radius);
|
|
||||||
background: var(--sbi-card);
|
|
||||||
box-shadow: var(--sbi-shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-card-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 14px 18px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-card-header strong {
|
|
||||||
font-size: 14.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-card-header small {
|
|
||||||
color: var(--sbi-text-3);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-card-toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-seg {
|
|
||||||
display: inline-flex;
|
|
||||||
gap: 2px;
|
|
||||||
padding: 2px;
|
|
||||||
border: 1px solid var(--sbi-border);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #f1f5fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-seg > button {
|
|
||||||
padding: 4px 10px;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--sbi-text-2);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 640;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-seg > button.is-active {
|
|
||||||
background: #ffffff;
|
|
||||||
color: var(--sbi-accent);
|
|
||||||
box-shadow: 0 1px 2px rgba(16, 34, 56, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-delta {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-delta.is-up {
|
|
||||||
color: var(--sbi-up);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-delta.is-down {
|
|
||||||
color: var(--sbi-down);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-delta.is-flat {
|
|
||||||
color: var(--sbi-text-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-table-scroll {
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: separate;
|
|
||||||
border-spacing: 0;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-table th {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 2;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-bottom: 1px solid var(--sbi-border);
|
|
||||||
background: #f7fafd;
|
|
||||||
color: var(--sbi-text-2);
|
|
||||||
font-weight: 700;
|
|
||||||
text-align: right;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-table th.is-left,
|
|
||||||
.sbi-table td.is-left {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-table td {
|
|
||||||
padding: 9px 12px;
|
|
||||||
border-bottom: 1px solid #eef3f9;
|
|
||||||
text-align: right;
|
|
||||||
white-space: nowrap;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-table tbody tr:hover td {
|
|
||||||
background: #f4f8fd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-table tr.is-total td {
|
|
||||||
background: #f0f5fc;
|
|
||||||
font-weight: 720;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-empty {
|
|
||||||
display: grid;
|
|
||||||
gap: 6px;
|
|
||||||
place-items: center;
|
|
||||||
padding: 46px 20px;
|
|
||||||
color: var(--sbi-text-3);
|
|
||||||
font-size: 13px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-empty strong {
|
|
||||||
color: var(--sbi-text-2);
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-skeleton {
|
|
||||||
display: inline-block;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: linear-gradient(90deg, #edf2f8 25%, #f7fafd 50%, #edf2f8 75%);
|
|
||||||
background-size: 200% 100%;
|
|
||||||
animation: sbi-shimmer 1.1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes sbi-shimmer {
|
|
||||||
to {
|
|
||||||
background-position: -200% 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
padding: 2px 9px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(37, 87, 214, 0.08);
|
|
||||||
color: var(--sbi-accent);
|
|
||||||
font-size: 11.5px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-badge.is-warn {
|
|
||||||
background: var(--sbi-warn-bg);
|
|
||||||
color: var(--sbi-warn-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-metric-hint {
|
|
||||||
cursor: help;
|
|
||||||
border-bottom: 1px dotted var(--sbi-text-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1280px) {
|
|
||||||
.sbi-shell {
|
|
||||||
grid-template-columns: 68px minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-brand-copy,
|
|
||||||
.sbi-nav-item span,
|
|
||||||
.sbi-scope-badge {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sbi-nav-item {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.sbi-shell *,
|
|
||||||
.sbi-shell *::before,
|
|
||||||
.sbi-shell *::after {
|
|
||||||
animation-duration: 1ms !important;
|
|
||||||
transition-duration: 1ms !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -41,10 +41,7 @@ test("keeps create application dialog open when backdrop is clicked", async () =
|
|||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
await screen.findByRole("button", { name: "添加" });
|
fireEvent.click(await screen.findByRole("button", { name: "添加" }));
|
||||||
expect(document.querySelector(".finance-header")).toBeNull();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "添加" }));
|
|
||||||
expect(within(screen.getByRole("dialog")).getByRole("heading", { name: "发起申请" })).toBeInTheDocument();
|
expect(within(screen.getByRole("dialog")).getByRole("heading", { name: "发起申请" })).toBeInTheDocument();
|
||||||
|
|
||||||
const backdrop = document.querySelector(".MuiBackdrop-root");
|
const backdrop = document.querySelector(".MuiBackdrop-root");
|
||||||
|
|||||||
@ -2,16 +2,7 @@ import { getMe, refreshSession } from "@/features/auth/api";
|
|||||||
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||||
import { apiRequest, getAccessToken } from "@/shared/api/request";
|
import { apiRequest, getAccessToken } from "@/shared/api/request";
|
||||||
import { FINANCE_OPERATIONS, FINANCE_PERMISSIONS, WALLET_IDENTITIES } from "./constants.js";
|
import { FINANCE_OPERATIONS, FINANCE_PERMISSIONS, WALLET_IDENTITIES } from "./constants.js";
|
||||||
import {
|
import { normalizeApplicationPage, normalizeRechargeBillPage, normalizeWithdrawalApplicationPage } from "./format.js";
|
||||||
mergeRechargeOverviews,
|
|
||||||
mergeRechargeSummaries,
|
|
||||||
normalizeApplicationPage,
|
|
||||||
normalizeRechargeBillPage,
|
|
||||||
normalizeRechargeOverview,
|
|
||||||
normalizeRechargeRegions,
|
|
||||||
normalizeRechargeSummary,
|
|
||||||
normalizeWithdrawalApplicationPage
|
|
||||||
} from "./format.js";
|
|
||||||
|
|
||||||
const appCodeHeader = "X-App-Code";
|
const appCodeHeader = "X-App-Code";
|
||||||
|
|
||||||
@ -45,15 +36,6 @@ export async function fetchFinanceApplicationOptions() {
|
|||||||
return normalizeOptions({ apps: data });
|
return normalizeOptions({ apps: data });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 充值详情的 App 目录与其他财务页不同:包含 Yumi 这类 legacy 外部账单源,只用于账单查询。
|
|
||||||
export async function fetchFinanceRechargeApps() {
|
|
||||||
const endpoint = API_ENDPOINTS.listRechargeApps;
|
|
||||||
const data = await apiRequest(apiEndpointPath(API_OPERATIONS.listRechargeApps), {
|
|
||||||
method: endpoint.method
|
|
||||||
});
|
|
||||||
return normalizeApps(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listFinanceApplications(query = {}) {
|
export async function listFinanceApplications(query = {}) {
|
||||||
const endpoint = API_ENDPOINTS.listFinanceApplications;
|
const endpoint = API_ENDPOINTS.listFinanceApplications;
|
||||||
const data = await apiRequest(apiEndpointPath(API_OPERATIONS.listFinanceApplications), {
|
const data = await apiRequest(apiEndpointPath(API_OPERATIONS.listFinanceApplications), {
|
||||||
@ -98,10 +80,9 @@ export async function listFinanceRechargeDetails(query = {}, apps = []) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mergedPageSize = page * pageSize;
|
const mergedPageSize = page * pageSize;
|
||||||
const pageResults = await Promise.allSettled(
|
const pages = await Promise.all(
|
||||||
appCodes.map((appCode) => listRechargeBillsForApp(appCode, { ...cleanQuery, page: 1, page_size: mergedPageSize }))
|
appCodes.map((appCode) => listRechargeBillsForApp(appCode, { ...cleanQuery, page: 1, page_size: mergedPageSize }))
|
||||||
);
|
);
|
||||||
const pages = pageResults.filter((result) => result.status === "fulfilled").map((result) => result.value);
|
|
||||||
const items = pages
|
const items = pages
|
||||||
.flatMap((data) => data.items || [])
|
.flatMap((data) => data.items || [])
|
||||||
.sort((left, right) => (right.createdAtMs || 0) - (left.createdAtMs || 0) || String(right.transactionId).localeCompare(String(left.transactionId)));
|
.sort((left, right) => (right.createdAtMs || 0) - (left.createdAtMs || 0) || String(right.transactionId).localeCompare(String(left.transactionId)));
|
||||||
@ -115,86 +96,6 @@ export async function listFinanceRechargeDetails(query = {}, apps = []) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchFinanceRechargeSummary(query = {}, apps = []) {
|
|
||||||
const selectedAppCode = stringValue(query.app_code ?? query.appCode);
|
|
||||||
const cleanQuery = cleanRechargeBillQuery(query);
|
|
||||||
|
|
||||||
if (selectedAppCode) {
|
|
||||||
return getRechargeSummaryForApp(selectedAppCode, cleanQuery);
|
|
||||||
}
|
|
||||||
|
|
||||||
const appCodes = normalizeApps(apps).map((app) => app.appCode);
|
|
||||||
if (!appCodes.length) {
|
|
||||||
return mergeRechargeSummaries([]);
|
|
||||||
}
|
|
||||||
const summaries = await Promise.all(appCodes.map((appCode) => getRechargeSummaryForApp(appCode, cleanQuery)));
|
|
||||||
return mergeRechargeSummaries(summaries);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listFinanceRechargeRegions(appCode) {
|
|
||||||
const endpoint = API_ENDPOINTS.listRechargeRegions;
|
|
||||||
const data = await apiRequest(apiEndpointPath(API_OPERATIONS.listRechargeRegions), {
|
|
||||||
headers: { [appCodeHeader]: appCode },
|
|
||||||
method: endpoint.method
|
|
||||||
});
|
|
||||||
return normalizeRechargeRegions(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetchFinanceRechargeSummaries 返回逐 App 的汇总(App 对比表用)和全局合并值。
|
|
||||||
export async function fetchFinanceRechargeSummaries(query = {}, apps = []) {
|
|
||||||
const selectedAppCode = stringValue(query.app_code ?? query.appCode);
|
|
||||||
const cleanQuery = cleanRechargeBillQuery(query);
|
|
||||||
const appList = selectedAppCode
|
|
||||||
? normalizeApps(apps).filter((app) => app.appCode === selectedAppCode)
|
|
||||||
: normalizeApps(apps);
|
|
||||||
if (!appList.length && selectedAppCode) {
|
|
||||||
appList.push({ appCode: selectedAppCode, appName: selectedAppCode });
|
|
||||||
}
|
|
||||||
// 单个 App 查询失败(如后端灰度中)按空汇总降级,不拖垮其余 App 的数据。
|
|
||||||
const results = await Promise.allSettled(appList.map((app) => getRechargeSummaryForApp(app.appCode, cleanQuery)));
|
|
||||||
const summaries = results.map((result) =>
|
|
||||||
result.status === "fulfilled" ? result.value : normalizeRechargeSummary({})
|
|
||||||
);
|
|
||||||
const perApp = appList.map((app, index) => ({ ...app, summary: summaries[index] }));
|
|
||||||
return { merged: mergeRechargeSummaries(summaries), perApp };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchFinanceRechargeOverview(query = {}, apps = []) {
|
|
||||||
const selectedAppCode = stringValue(query.app_code ?? query.appCode);
|
|
||||||
const cleanQuery = { ...cleanRechargeBillQuery(query), tz_offset_minutes: 480 };
|
|
||||||
if (selectedAppCode) {
|
|
||||||
return getRechargeOverviewForApp(selectedAppCode, cleanQuery);
|
|
||||||
}
|
|
||||||
const appCodes = normalizeApps(apps).map((app) => app.appCode);
|
|
||||||
if (!appCodes.length) {
|
|
||||||
return normalizeRechargeOverview({});
|
|
||||||
}
|
|
||||||
const results = await Promise.allSettled(appCodes.map((appCode) => getRechargeOverviewForApp(appCode, cleanQuery)));
|
|
||||||
return mergeRechargeOverviews(
|
|
||||||
results.filter((result) => result.status === "fulfilled").map((result) => result.value)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function exportFinanceRechargeBills(appCode, query = {}) {
|
|
||||||
const endpoint = API_ENDPOINTS.exportRechargeBills;
|
|
||||||
return apiRequest(apiEndpointPath(API_OPERATIONS.exportRechargeBills), {
|
|
||||||
headers: { [appCodeHeader]: appCode },
|
|
||||||
method: endpoint.method,
|
|
||||||
query: cleanRechargeBillQuery(query),
|
|
||||||
raw: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function refreshFinanceGoogleRechargePaid(appCode, transactionIds) {
|
|
||||||
const endpoint = API_ENDPOINTS.refreshGoogleRechargePaid;
|
|
||||||
const data = await apiRequest(apiEndpointPath(API_OPERATIONS.refreshGoogleRechargePaid), {
|
|
||||||
body: { transactionIds },
|
|
||||||
headers: { [appCodeHeader]: appCode },
|
|
||||||
method: endpoint.method
|
|
||||||
});
|
|
||||||
return Array.isArray(data?.items) ? data.items : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createFinanceApplication(payload) {
|
export async function createFinanceApplication(payload) {
|
||||||
const endpoint = API_ENDPOINTS.createFinanceApplication;
|
const endpoint = API_ENDPOINTS.createFinanceApplication;
|
||||||
return apiRequest(apiEndpointPath(API_OPERATIONS.createFinanceApplication), {
|
return apiRequest(apiEndpointPath(API_OPERATIONS.createFinanceApplication), {
|
||||||
@ -251,26 +152,6 @@ async function currentSession() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getRechargeOverviewForApp(appCode, query = {}) {
|
|
||||||
const endpoint = API_ENDPOINTS.getRechargeBillOverview;
|
|
||||||
const data = await apiRequest(apiEndpointPath(API_OPERATIONS.getRechargeBillOverview), {
|
|
||||||
headers: { [appCodeHeader]: appCode },
|
|
||||||
method: endpoint.method,
|
|
||||||
query
|
|
||||||
});
|
|
||||||
return normalizeRechargeOverview(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getRechargeSummaryForApp(appCode, query = {}) {
|
|
||||||
const endpoint = API_ENDPOINTS.getRechargeBillSummary;
|
|
||||||
const data = await apiRequest(apiEndpointPath(API_OPERATIONS.getRechargeBillSummary), {
|
|
||||||
headers: { [appCodeHeader]: appCode },
|
|
||||||
method: endpoint.method,
|
|
||||||
query
|
|
||||||
});
|
|
||||||
return normalizeRechargeSummary(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listRechargeBillsForApp(appCode, query = {}) {
|
async function listRechargeBillsForApp(appCode, query = {}) {
|
||||||
const endpoint = API_ENDPOINTS.listRechargeBills;
|
const endpoint = API_ENDPOINTS.listRechargeBills;
|
||||||
const data = await apiRequest(apiEndpointPath(API_OPERATIONS.listRechargeBills), {
|
const data = await apiRequest(apiEndpointPath(API_OPERATIONS.listRechargeBills), {
|
||||||
|
|||||||
@ -1,432 +0,0 @@
|
|||||||
import { EMPTY_RECHARGE_OVERVIEW, EMPTY_RECHARGE_SUMMARY, formatAmount, formatUsdMinor } from "../format.js";
|
|
||||||
|
|
||||||
const KPI_CARDS = [
|
|
||||||
{ filter: "", key: "total", label: "充值总和", tone: "total" },
|
|
||||||
{ filter: "google_play_recharge", key: "googlePlay", label: "谷歌充值", tone: "google" },
|
|
||||||
{ filter: "third_party", key: "thirdParty", label: "三方充值", tone: "thirdparty" },
|
|
||||||
{ filter: "coin_seller", key: "coinSeller", label: "币商充值", tone: "coinseller" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const DAILY_FIELDS = {
|
|
||||||
coinSeller: { coin: "coinSellerCoinAmount", usd: "coinSellerUsdMinor" },
|
|
||||||
googlePlay: { coin: "googleCoinAmount", usd: "googleUsdMinor" },
|
|
||||||
thirdParty: { coin: "thirdPartyCoinAmount", usd: "thirdPartyUsdMinor" },
|
|
||||||
};
|
|
||||||
|
|
||||||
export function FinanceOverview({
|
|
||||||
currency,
|
|
||||||
filters,
|
|
||||||
loading,
|
|
||||||
onFiltersChange,
|
|
||||||
onGoUnsynced,
|
|
||||||
onOpenApp,
|
|
||||||
onOpenWithdrawals,
|
|
||||||
overview,
|
|
||||||
perAppSummaries,
|
|
||||||
prevSummary,
|
|
||||||
regions,
|
|
||||||
summary,
|
|
||||||
}) {
|
|
||||||
const data = summary || EMPTY_RECHARGE_SUMMARY;
|
|
||||||
const trend = overview || EMPTY_RECHARGE_OVERVIEW;
|
|
||||||
const showCoins = currency === "coin";
|
|
||||||
const totalUsd = Number(data.total?.usdMinorAmount) || 0;
|
|
||||||
const unsynced = trend.googlePaid.unsyncedCount;
|
|
||||||
const selectedApp = filters.appCode;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="finance-overview">
|
|
||||||
{unsynced > 0 ? (
|
|
||||||
<div className="finance-alerts">
|
|
||||||
<span className="finance-alert finance-alert--warn">
|
|
||||||
⚠ {formatAmount(unsynced)} 笔谷歌订单实付未同步,手续费口径尚不完整
|
|
||||||
<button type="button" onClick={onGoUnsynced}>
|
|
||||||
去同步
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="finance-kpi-band">
|
|
||||||
{KPI_CARDS.map(({ filter, key, label, tone }) => {
|
|
||||||
const bucket = data[key] || { billCount: 0, coinAmount: 0, usdMinorAmount: 0 };
|
|
||||||
const active = (filters.rechargeType || "") === filter;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={["finance-kpi", `finance-kpi--${tone}`, active ? "finance-kpi--active" : ""]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ")}
|
|
||||||
key={key}
|
|
||||||
title={filter ? `点击只看${label}` : "查看全部来源"}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onFiltersChange({ rechargeType: active && filter ? "" : filter })}
|
|
||||||
>
|
|
||||||
<span className="finance-kpi__label">{label}</span>
|
|
||||||
<strong className="finance-kpi__value">
|
|
||||||
{loading
|
|
||||||
? "…"
|
|
||||||
: showCoins
|
|
||||||
? `${formatAmount(bucket.coinAmount)} 金币`
|
|
||||||
: formatUsdMinor(bucket.usdMinorAmount, "USD")}
|
|
||||||
</strong>
|
|
||||||
<small className="finance-kpi__meta">
|
|
||||||
{loading ? "统计中" : kpiMeta(bucket, key, totalUsd, prevSummary)}
|
|
||||||
</small>
|
|
||||||
<Sparkline daily={trend.daily} field={key} showCoins={showCoins} tone={tone} />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<WithdrawalKpi
|
|
||||||
loading={loading}
|
|
||||||
totalUsd={totalUsd}
|
|
||||||
withdrawal={trend.withdrawal}
|
|
||||||
onOpenWithdrawals={onOpenWithdrawals}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="finance-overview__split">
|
|
||||||
<section className="finance-card">
|
|
||||||
<div className="finance-card__head">
|
|
||||||
<span className="finance-card__title">
|
|
||||||
充值趋势<small>按渠道堆叠 · 日 · {showCoins ? "金币" : "USD"}</small>
|
|
||||||
</span>
|
|
||||||
<div className="finance-card__tools finance-chartlegend">
|
|
||||||
<span>
|
|
||||||
<i style={{ background: "var(--fin-google)" }} />
|
|
||||||
谷歌
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<i style={{ background: "var(--fin-third)" }} />
|
|
||||||
三方
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<i style={{ background: "var(--fin-seller)" }} />
|
|
||||||
币商
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="finance-card__body">
|
|
||||||
<TrendChart daily={trend.daily} showCoins={showCoins} />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="finance-card">
|
|
||||||
<div className="finance-card__head">
|
|
||||||
<span className="finance-card__title">
|
|
||||||
净入账拆解<small>谷歌已同步口径</small>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="finance-card__body">
|
|
||||||
<DeductionBreakdown paid={trend.googlePaid} />
|
|
||||||
<div className="finance-card__divider" />
|
|
||||||
<div className="finance-card__title" style={{ marginBottom: 8 }}>
|
|
||||||
{selectedApp ? "区域分布 Top 5" : "区域分布"}
|
|
||||||
</div>
|
|
||||||
{selectedApp ? (
|
|
||||||
<RegionRanks regions={regions} />
|
|
||||||
) : (
|
|
||||||
<p className="finance-hint">选择单个 App 后查看区域分布</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section className="finance-card">
|
|
||||||
<div className="finance-card__head">
|
|
||||||
<span className="finance-card__title">
|
|
||||||
App 对比<small>点击行进入该 App 流水</small>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="finance-tablewrap">
|
|
||||||
<table className="finance-flat-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>App</th>
|
|
||||||
<th className="r">充值流水</th>
|
|
||||||
<th className="r">笔数</th>
|
|
||||||
<th className="r">谷歌</th>
|
|
||||||
<th className="r">三方</th>
|
|
||||||
<th className="r">币商</th>
|
|
||||||
<th style={{ width: 180 }}>占比</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{(perAppSummaries || []).map((app) => {
|
|
||||||
const appTotal = Number(app.summary?.total?.usdMinorAmount) || 0;
|
|
||||||
const maxTotal = Math.max(
|
|
||||||
1,
|
|
||||||
...(perAppSummaries || []).map(
|
|
||||||
(item) => Number(item.summary?.total?.usdMinorAmount) || 0,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
className="finance-flat-table__row"
|
|
||||||
key={app.appCode}
|
|
||||||
onClick={() => onOpenApp(app.appCode)}
|
|
||||||
>
|
|
||||||
<td>
|
|
||||||
<b>{app.appName || app.appCode}</b>
|
|
||||||
</td>
|
|
||||||
<td className="r num">{formatUsdMinor(appTotal, "USD")}</td>
|
|
||||||
<td className="r num">{formatAmount(app.summary?.total?.billCount || 0)}</td>
|
|
||||||
<td className="r num">
|
|
||||||
{formatUsdMinor(app.summary?.googlePlay?.usdMinorAmount || 0, "USD")}
|
|
||||||
</td>
|
|
||||||
<td className="r num">
|
|
||||||
{formatUsdMinor(app.summary?.thirdParty?.usdMinorAmount || 0, "USD")}
|
|
||||||
</td>
|
|
||||||
<td className="r num">
|
|
||||||
{formatUsdMinor(app.summary?.coinSeller?.usdMinorAmount || 0, "USD")}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span className="finance-rankbar">
|
|
||||||
<i style={{ width: `${(appTotal / maxTotal) * 100}%` }} />
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{!perAppSummaries?.length ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={7} style={{ textAlign: "center" }}>
|
|
||||||
暂无数据
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : null}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithdrawalKpi 是两张独立口径的资金流出卡:
|
|
||||||
// 「用户提现」只统计提现申请审核通过的 USDT(点击进入提现审核页);
|
|
||||||
// 「工资兑换」只统计用户工资转币商的 USDT(likei 平台为银行卡余额兑换金币)。
|
|
||||||
// 两者都不是充值渠道,不参与来源筛选联动,占比按“金额 / 充值总和”表达资金回流强度。
|
|
||||||
function WithdrawalKpi({ loading, onOpenWithdrawals, totalUsd, withdrawal }) {
|
|
||||||
const data = withdrawal || {
|
|
||||||
approvedCount: 0,
|
|
||||||
approvedUsdMinor: 0,
|
|
||||||
totalUsdMinor: 0,
|
|
||||||
transferCount: 0,
|
|
||||||
transferUsdMinor: 0,
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
className="finance-kpi finance-kpi--withdrawal"
|
|
||||||
title="点击进入用户提现申请"
|
|
||||||
type="button"
|
|
||||||
onClick={onOpenWithdrawals}
|
|
||||||
>
|
|
||||||
<span className="finance-kpi__label">用户提现</span>
|
|
||||||
<strong className="finance-kpi__value">{loading ? "…" : usdtText(data.approvedUsdMinor)}</strong>
|
|
||||||
<small className="finance-kpi__meta">
|
|
||||||
{loading ? "统计中" : outflowMeta(data.approvedUsdMinor, data.approvedCount, totalUsd)}
|
|
||||||
</small>
|
|
||||||
<small className="finance-kpi__meta">提现申请审核通过</small>
|
|
||||||
</button>
|
|
||||||
<div className="finance-kpi finance-kpi--salary">
|
|
||||||
<span className="finance-kpi__label">工资兑换</span>
|
|
||||||
<strong className="finance-kpi__value">{loading ? "…" : usdtText(data.transferUsdMinor)}</strong>
|
|
||||||
<small className="finance-kpi__meta">
|
|
||||||
{loading ? "统计中" : outflowMeta(data.transferUsdMinor, data.transferCount, totalUsd)}
|
|
||||||
</small>
|
|
||||||
<small className="finance-kpi__meta">用户工资转币商</small>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function usdtText(usdMinor) {
|
|
||||||
return `USDT ${(usdMinor / 100).toLocaleString("zh-CN", { maximumFractionDigits: 2, minimumFractionDigits: 2 })}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function outflowMeta(usdMinor, count, totalUsd) {
|
|
||||||
const parts = [];
|
|
||||||
if (totalUsd > 0) {
|
|
||||||
const share = (usdMinor / totalUsd) * 100;
|
|
||||||
parts.push(`占充值 ${share.toLocaleString("zh-CN", { maximumFractionDigits: 1 })}%`);
|
|
||||||
}
|
|
||||||
parts.push(`${formatAmount(count)} 笔`);
|
|
||||||
return parts.join(" · ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function kpiMeta(bucket, key, totalUsd, prevSummary) {
|
|
||||||
const parts = [`${formatAmount(bucket.billCount)} 笔`];
|
|
||||||
if (key !== "total" && totalUsd > 0) {
|
|
||||||
const share = ((Number(bucket.usdMinorAmount) || 0) / totalUsd) * 100;
|
|
||||||
parts.push(`占比 ${share.toLocaleString("zh-CN", { maximumFractionDigits: 1 })}%`);
|
|
||||||
}
|
|
||||||
const prev = prevSummary?.[key];
|
|
||||||
if (prev && Number(prev.usdMinorAmount) > 0) {
|
|
||||||
const delta =
|
|
||||||
((Number(bucket.usdMinorAmount) - Number(prev.usdMinorAmount)) / Number(prev.usdMinorAmount)) * 100;
|
|
||||||
const arrow = delta >= 0 ? "▲" : "▼";
|
|
||||||
parts.push(`${arrow} ${Math.abs(delta).toLocaleString("zh-CN", { maximumFractionDigits: 1 })}% vs 上期`);
|
|
||||||
}
|
|
||||||
return parts.join(" · ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function Sparkline({ daily, field, showCoins, tone }) {
|
|
||||||
const values = (daily || []).map((bucket) => {
|
|
||||||
if (field === "total") {
|
|
||||||
return showCoins
|
|
||||||
? bucket.googleCoinAmount + bucket.thirdPartyCoinAmount + bucket.coinSellerCoinAmount
|
|
||||||
: bucket.googleUsdMinor + bucket.thirdPartyUsdMinor + bucket.coinSellerUsdMinor;
|
|
||||||
}
|
|
||||||
const fields = DAILY_FIELDS[field];
|
|
||||||
return fields ? bucket[showCoins ? fields.coin : fields.usd] : 0;
|
|
||||||
});
|
|
||||||
if (values.length < 2) {
|
|
||||||
return <span className="finance-sparkline" />;
|
|
||||||
}
|
|
||||||
const width = 100;
|
|
||||||
const height = 28;
|
|
||||||
const max = Math.max(...values);
|
|
||||||
const min = Math.min(...values);
|
|
||||||
const points = values.map((value, index) => {
|
|
||||||
const x = (index * width) / (values.length - 1);
|
|
||||||
const y = height - 3 - ((value - min) / (max - min || 1)) * (height - 8);
|
|
||||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
|
||||||
});
|
|
||||||
const colors = {
|
|
||||||
coinseller: "#B45309",
|
|
||||||
google: "#1B873F",
|
|
||||||
thirdparty: "#0E7490",
|
|
||||||
total: "#2557D6",
|
|
||||||
};
|
|
||||||
const color = colors[tone] || colors.total;
|
|
||||||
return (
|
|
||||||
<svg className="finance-sparkline" preserveAspectRatio="none" viewBox={`0 0 ${width} ${height}`}>
|
|
||||||
<polyline fill={`${color}22`} points={`${points.join(" ")} ${width},${height} 0,${height}`} stroke="none" />
|
|
||||||
<polyline
|
|
||||||
fill="none"
|
|
||||||
points={points.join(" ")}
|
|
||||||
stroke={color}
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth="1.8"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TrendChart({ daily, showCoins }) {
|
|
||||||
const buckets = daily || [];
|
|
||||||
if (!buckets.length) {
|
|
||||||
return <p className="finance-hint">当前时间范围内没有充值数据</p>;
|
|
||||||
}
|
|
||||||
const totals = buckets.map((bucket) =>
|
|
||||||
showCoins
|
|
||||||
? bucket.googleCoinAmount + bucket.thirdPartyCoinAmount + bucket.coinSellerCoinAmount
|
|
||||||
: bucket.googleUsdMinor + bucket.thirdPartyUsdMinor + bucket.coinSellerUsdMinor,
|
|
||||||
);
|
|
||||||
const max = Math.max(1, ...totals);
|
|
||||||
const value = (bucket, channel) => {
|
|
||||||
const fields = DAILY_FIELDS[channel];
|
|
||||||
return bucket[showCoins ? fields.coin : fields.usd];
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="finance-trend" role="img" aria-label="按渠道堆叠的每日充值趋势">
|
|
||||||
{buckets.map((bucket) => (
|
|
||||||
<div
|
|
||||||
className="finance-trend__col"
|
|
||||||
key={bucket.date}
|
|
||||||
title={`${bucket.date} · 谷歌 ${trendTip(bucket, "googlePlay", showCoins)} · 三方 ${trendTip(bucket, "thirdParty", showCoins)} · 币商 ${trendTip(bucket, "coinSeller", showCoins)}`}
|
|
||||||
>
|
|
||||||
{["googlePlay", "thirdParty", "coinSeller"].map((channel) => {
|
|
||||||
const amount = value(bucket, channel);
|
|
||||||
if (amount <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={`finance-trend__seg finance-trend__seg--${channel}`}
|
|
||||||
key={channel}
|
|
||||||
style={{ height: `${(amount / max) * 100}%` }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="finance-trend__axis">
|
|
||||||
<span>{buckets[0].date.slice(5)}</span>
|
|
||||||
{buckets.length > 2 ? <span>{buckets[Math.floor(buckets.length / 2)].date.slice(5)}</span> : null}
|
|
||||||
<span>{buckets[buckets.length - 1].date.slice(5)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function trendTip(bucket, channel, showCoins) {
|
|
||||||
const fields = DAILY_FIELDS[channel];
|
|
||||||
const amount = bucket[showCoins ? fields.coin : fields.usd];
|
|
||||||
return showCoins ? formatAmount(amount) : formatUsdMinor(amount, "USD");
|
|
||||||
}
|
|
||||||
|
|
||||||
function DeductionBreakdown({ paid }) {
|
|
||||||
const covered = paid.coveredUsdMinor;
|
|
||||||
if (covered <= 0) {
|
|
||||||
return (
|
|
||||||
<p className="finance-hint">
|
|
||||||
暂无已同步的谷歌实付明细;点「查询谷歌实付」同步后这里会展示手续费与税费拆解。
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const rows = [
|
|
||||||
{ amount: paid.estNetUsdMinor, color: "var(--fin-pos)", label: "净入账(估)" },
|
|
||||||
{ amount: paid.estFeeUsdMinor, color: "#E19B3C", label: "谷歌服务费(估)" },
|
|
||||||
{ amount: paid.estTaxUsdMinor, color: "#C86A6A", label: "代缴税费(估)" },
|
|
||||||
];
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="finance-mixbar">
|
|
||||||
{rows.map((row) => (
|
|
||||||
<span key={row.label} style={{ background: row.color, flex: Math.max(row.amount, 1) }} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{rows.map((row) => (
|
|
||||||
<div className="finance-mixrow" key={row.label}>
|
|
||||||
<i style={{ background: row.color }} />
|
|
||||||
<span className="finance-mixrow__name">{row.label}</span>
|
|
||||||
<span className="finance-mixrow__amt num">{formatUsdMinor(row.amount, "USD")}</span>
|
|
||||||
<span className="finance-mixrow__pct num">
|
|
||||||
{((row.amount / covered) * 100).toLocaleString("zh-CN", { maximumFractionDigits: 1 })}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<p className="finance-hint" style={{ marginTop: 8 }}>
|
|
||||||
口径:已同步 {formatAmount(paid.syncedCount)} / {formatAmount(paid.googleBillCount)}{" "}
|
|
||||||
笔谷歌订单,覆盖流水 {formatUsdMinor(covered, "USD")};按各订单实付占比折算成 USD
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RegionRanks({ regions }) {
|
|
||||||
const top = (regions || []).slice(0, 5);
|
|
||||||
if (!top.length) {
|
|
||||||
return <p className="finance-hint">当前筛选下暂无区域数据</p>;
|
|
||||||
}
|
|
||||||
const max = Math.max(1, ...top.map((region) => region.usdMinorAmount));
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{top.map((region, index) => (
|
|
||||||
<div className="finance-rankrow" key={region.regionId || region.name || index}>
|
|
||||||
<span className="finance-rankrow__idx num">{index + 1}</span>
|
|
||||||
<span className="finance-rankrow__name">{region.name || `区域 ${region.regionId}`}</span>
|
|
||||||
<span className="finance-rankbar">
|
|
||||||
<i style={{ width: `${(region.usdMinorAmount / max) * 100}%` }} />
|
|
||||||
</span>
|
|
||||||
<span className="finance-rankrow__amt num">{formatUsdMinor(region.usdMinorAmount, "USD")}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,460 +1,147 @@
|
|||||||
import CloudSyncOutlined from "@mui/icons-material/CloudSyncOutlined";
|
|
||||||
import FileDownloadOutlined from "@mui/icons-material/FileDownloadOutlined";
|
|
||||||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||||
import Button from "@mui/material/Button";
|
import Button from "@mui/material/Button";
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import Table from "@mui/material/Table";
|
||||||
|
import TableBody from "@mui/material/TableBody";
|
||||||
|
import TableCell from "@mui/material/TableCell";
|
||||||
|
import TableContainer from "@mui/material/TableContainer";
|
||||||
|
import TableHead from "@mui/material/TableHead";
|
||||||
|
import TableRow from "@mui/material/TableRow";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import { Fragment, useState } from "react";
|
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||||
import {
|
import {
|
||||||
formatAmount,
|
formatAmount,
|
||||||
formatMicroMoney,
|
formatMicroMoney,
|
||||||
formatTime,
|
formatTime,
|
||||||
formatUsdMinor,
|
formatUsdMinor,
|
||||||
rechargeSourceTone,
|
formatPercent,
|
||||||
rechargeTypeLabel,
|
rechargeTypeLabel,
|
||||||
} from "../format.js";
|
} from "../format.js";
|
||||||
|
|
||||||
// 系统保存视图:财务高频筛选组合一键切换;与全局 rechargeType/paidState 联动。
|
export function FinanceRechargeDetailList({ bills, error, filters, loading, onFiltersChange, onReload, options }) {
|
||||||
const SAVED_VIEWS = [
|
|
||||||
{ key: "all", label: "全部", paidState: "", rechargeType: "" },
|
|
||||||
{ key: "google", label: "谷歌充值", paidState: "", rechargeType: "google_play_recharge" },
|
|
||||||
{ key: "third", label: "三方充值", paidState: "", rechargeType: "third_party" },
|
|
||||||
{ key: "seller", label: "币商充值", paidState: "", rechargeType: "coin_seller" },
|
|
||||||
{ key: "unsynced", label: "实付未同步", paidState: "unsynced", rechargeType: "" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function FinanceRechargeDetailList({
|
|
||||||
apps,
|
|
||||||
bills,
|
|
||||||
currency,
|
|
||||||
error,
|
|
||||||
exporting,
|
|
||||||
filters,
|
|
||||||
googlePaidRefreshing,
|
|
||||||
loading,
|
|
||||||
onExport,
|
|
||||||
onFiltersChange,
|
|
||||||
onRefreshGooglePaid,
|
|
||||||
onReload,
|
|
||||||
regions,
|
|
||||||
regionsByApp,
|
|
||||||
summary,
|
|
||||||
}) {
|
|
||||||
const [expandedId, setExpandedId] = useState("");
|
|
||||||
const items = bills.items || [];
|
const items = bills.items || [];
|
||||||
const page = Number(bills.page || filters.page || 1);
|
const page = Number(bills.page || filters.page || 1);
|
||||||
const pageSize = Number(bills.pageSize || 50);
|
const pageSize = Number(bills.pageSize || 50);
|
||||||
const total = Number(bills.total || 0);
|
const total = Number(bills.total || 0);
|
||||||
const hasNextPage = page * pageSize < total;
|
const hasNextPage = page * pageSize < total;
|
||||||
const showCoins = currency === "coin";
|
|
||||||
const regionNames = new Map(
|
|
||||||
(regions || []).map((region) => [String(region.regionId), region.name || region.regionCode]),
|
|
||||||
);
|
|
||||||
const activeView =
|
|
||||||
SAVED_VIEWS.find(
|
|
||||||
(view) =>
|
|
||||||
view.rechargeType === (filters.rechargeType || "") && view.paidState === (filters.paidState || ""),
|
|
||||||
)?.key || "all";
|
|
||||||
const pendingGoogleTxIds = items
|
|
||||||
.filter((item) => item.rechargeType === "google_play_recharge" && !item.paidSyncedAtMs)
|
|
||||||
.map((item) => item.transactionId)
|
|
||||||
.filter(Boolean);
|
|
||||||
const columnCount = showCoins ? 9 : 8;
|
|
||||||
|
|
||||||
const chips = [
|
|
||||||
filters.appCode
|
|
||||||
? { key: "appCode", label: `App:${appName(filters.appCode, apps)}`, reset: { appCode: "", regionId: "" } }
|
|
||||||
: null,
|
|
||||||
filters.regionId
|
|
||||||
? {
|
|
||||||
key: "regionId",
|
|
||||||
label: `区域:${regionNames.get(String(filters.regionId)) || filters.regionId}`,
|
|
||||||
reset: { regionId: "" },
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
filters.timeRange?.startMs || filters.timeRange?.endMs
|
|
||||||
? {
|
|
||||||
key: "time",
|
|
||||||
label: `时间:${rangeLabel(filters.timeRange)}`,
|
|
||||||
reset: { timeRange: { endMs: "", startMs: "" } },
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
filters.keyword ? { key: "keyword", label: `搜索:${filters.keyword}`, reset: { keyword: "" } } : null,
|
|
||||||
].filter(Boolean);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="finance-card finance-list">
|
<section className="finance-panel finance-list">
|
||||||
<div className="finance-views" role="tablist" aria-label="流水视图">
|
<div className="finance-toolbar finance-toolbar--recharge-details">
|
||||||
{SAVED_VIEWS.map((view) => (
|
|
||||||
<button
|
|
||||||
aria-selected={activeView === view.key}
|
|
||||||
className={activeView === view.key ? "is-on" : ""}
|
|
||||||
key={view.key}
|
|
||||||
role="tab"
|
|
||||||
type="button"
|
|
||||||
onClick={() => onFiltersChange({ paidState: view.paidState, rechargeType: view.rechargeType })}
|
|
||||||
>
|
|
||||||
{view.label}
|
|
||||||
{activeView === view.key && !loading ? (
|
|
||||||
<span className="finance-views__cnt num">{formatAmount(total)}</span>
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="finance-filterrow">
|
|
||||||
{chips.map((chip) => (
|
|
||||||
<button
|
|
||||||
className="finance-fchip"
|
|
||||||
key={chip.key}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onFiltersChange(chip.reset)}
|
|
||||||
>
|
|
||||||
{chip.label}
|
|
||||||
<span aria-hidden="true">×</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<TextField
|
<TextField
|
||||||
className="finance-filterrow__region"
|
label="APP"
|
||||||
disabled={!filters.appCode}
|
|
||||||
label="区域"
|
|
||||||
select
|
select
|
||||||
size="small"
|
value={filters.appCode}
|
||||||
title={filters.appCode ? "" : "先选择 App"}
|
onChange={(event) => onFiltersChange({ appCode: event.target.value })}
|
||||||
value={filters.regionId}
|
|
||||||
onChange={(event) => onFiltersChange({ regionId: event.target.value })}
|
|
||||||
>
|
>
|
||||||
<MenuItem value="">全部区域</MenuItem>
|
<MenuItem value="">全部 APP</MenuItem>
|
||||||
{(regions || []).map((region) => (
|
{options.apps.map((app) => (
|
||||||
<MenuItem key={region.regionId} value={String(region.regionId)}>
|
<MenuItem key={app.appCode} value={app.appCode}>
|
||||||
{region.name || region.regionCode || region.regionId}
|
{app.appName || app.appCode}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</TextField>
|
</TextField>
|
||||||
|
<TimeRangeFilter
|
||||||
|
label="充值时间 · 中国时区"
|
||||||
|
value={filters.timeRange}
|
||||||
|
onChange={(timeRange) => onFiltersChange({ timeRange })}
|
||||||
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
className="finance-filterrow__search"
|
|
||||||
label="订单搜索"
|
label="订单搜索"
|
||||||
placeholder="交易号 / GPA单号 / 凭证号"
|
|
||||||
size="small"
|
|
||||||
value={filters.keyword}
|
value={filters.keyword}
|
||||||
onChange={(event) => onFiltersChange({ keyword: event.target.value })}
|
onChange={(event) => onFiltersChange({ keyword: event.target.value })}
|
||||||
/>
|
/>
|
||||||
<span className="finance-filterrow__spacer" />
|
<Button startIcon={<RefreshOutlined fontSize="small" />} variant="outlined" onClick={onReload}>
|
||||||
<Button
|
刷新列表
|
||||||
disabled={googlePaidRefreshing || !filters.appCode || !pendingGoogleTxIds.length}
|
|
||||||
size="small"
|
|
||||||
startIcon={<CloudSyncOutlined fontSize="small" />}
|
|
||||||
title={filters.appCode ? "查询本页谷歌订单的用户实付币种和金额" : "先选择 App 后可查询"}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={() => onRefreshGooglePaid(pendingGoogleTxIds)}
|
|
||||||
>
|
|
||||||
{googlePaidRefreshing ? "查询中…" : "查询谷歌实付"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
disabled={exporting || !filters.appCode}
|
|
||||||
size="small"
|
|
||||||
startIcon={<FileDownloadOutlined fontSize="small" />}
|
|
||||||
title={filters.appCode ? "按当前筛选导出全部明细(CSV)" : "先选择 App 后可导出"}
|
|
||||||
variant="contained"
|
|
||||||
onClick={onExport}
|
|
||||||
>
|
|
||||||
{exporting ? "导出中…" : "导出报表"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
startIcon={<RefreshOutlined fontSize="small" />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={onReload}
|
|
||||||
>
|
|
||||||
刷新
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? <div className="finance-error">{error}</div> : null}
|
{error ? <div className="finance-error">{error}</div> : null}
|
||||||
{loading && !items.length ? <div className="finance-skeleton" /> : null}
|
{loading && !items.length ? <div className="finance-skeleton" /> : null}
|
||||||
{!loading || items.length ? (
|
{!loading || items.length ? (
|
||||||
<div className="finance-tablewrap">
|
<TableContainer className="finance-table-wrap">
|
||||||
<table className="finance-flat-table finance-flat-table--bills">
|
<Table className="finance-table finance-table--recharge-details" size="small" stickyHeader>
|
||||||
<thead>
|
<TableHead>
|
||||||
<tr>
|
<TableRow>
|
||||||
<th>充值时间</th>
|
<TableCell>APP</TableCell>
|
||||||
<th>APP</th>
|
<TableCell align="right">充值总额</TableCell>
|
||||||
<th>充值来源</th>
|
<TableCell>充值来源</TableCell>
|
||||||
<th>充值订单号</th>
|
<TableCell>充值订单号</TableCell>
|
||||||
{showCoins ? <th className="r">充值金币</th> : null}
|
<TableCell>美金换算</TableCell>
|
||||||
<th className="r">账单金额</th>
|
<TableCell>账单金额</TableCell>
|
||||||
<th className="r">用户实付</th>
|
<TableCell>用户实付</TableCell>
|
||||||
<th className="r">三方税率扣款</th>
|
<TableCell>三方税率扣款</TableCell>
|
||||||
<th>区域</th>
|
<TableCell className="finance-time-cell">充值时间</TableCell>
|
||||||
</tr>
|
</TableRow>
|
||||||
</thead>
|
</TableHead>
|
||||||
<tbody>
|
<TableBody>
|
||||||
{items.length ? (
|
{items.length ? (
|
||||||
items.map((item) => {
|
items.map((item) => (
|
||||||
const rowId = item.id || item.transactionId;
|
<TableRow key={item.id || item.transactionId}>
|
||||||
const expanded = expandedId === rowId;
|
<TableCell>{appName(item, options.apps)}</TableCell>
|
||||||
return (
|
<TableCell align="right">{coinText(item.coinAmount)}</TableCell>
|
||||||
<Fragment key={rowId}>
|
<TableCell>{rechargeTypeLabel(item.rechargeType)}</TableCell>
|
||||||
<tr
|
<TableCell>
|
||||||
className="finance-flat-table__row"
|
<Stacked
|
||||||
onClick={() => setExpandedId(expanded ? "" : rowId)}
|
primary={item.transactionId || "-"}
|
||||||
>
|
secondary={secondaryOrderNo(item)}
|
||||||
<td className="num">{formatTime(item.createdAtMs)}</td>
|
/>
|
||||||
<td>{appName(item.appCode, apps)}</td>
|
</TableCell>
|
||||||
<td>
|
<TableCell>{exchangeText(item)}</TableCell>
|
||||||
<span
|
<TableCell>{billAmountText(item)}</TableCell>
|
||||||
className={`finance-badge finance-badge--${badgeTone(item.rechargeType)}`}
|
<TableCell>{userPaidText(item)}</TableCell>
|
||||||
>
|
<TableCell>{taxDeductionText(item)}</TableCell>
|
||||||
{rechargeTypeLabel(item.rechargeType)}
|
<TableCell className="finance-time-cell">
|
||||||
</span>
|
{formatTime(item.createdAtMs)}
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="finance-order-cell">
|
</TableRow>
|
||||||
<span className="finance-stack">
|
))
|
||||||
<span>{item.transactionId || "-"}</span>
|
|
||||||
<small>{secondaryOrderNo(item)}</small>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
{showCoins ? (
|
|
||||||
<td className="r num">
|
|
||||||
<AmountText value={item.coinAmount}>
|
|
||||||
{coinText(item.coinAmount)}
|
|
||||||
</AmountText>
|
|
||||||
</td>
|
|
||||||
) : null}
|
|
||||||
<td className="r num">
|
|
||||||
<AmountText value={item.usdMinorAmount}>
|
|
||||||
{billAmountText(item)}
|
|
||||||
</AmountText>
|
|
||||||
</td>
|
|
||||||
<td className="r num">
|
|
||||||
<AmountText value={item.userPaidAmountMicro}>
|
|
||||||
{userPaidText(item)}
|
|
||||||
</AmountText>
|
|
||||||
</td>
|
|
||||||
<td className="r num">{taxDeductionText(item)}</td>
|
|
||||||
<td>{regionText(item, regionsByApp, apps, !filters.appCode)}</td>
|
|
||||||
</tr>
|
|
||||||
{expanded ? (
|
|
||||||
<tr className="finance-detailrow">
|
|
||||||
<td colSpan={columnCount}>
|
|
||||||
<BillDetail
|
|
||||||
googlePaidRefreshing={googlePaidRefreshing}
|
|
||||||
item={item}
|
|
||||||
onRefreshGooglePaid={onRefreshGooglePaid}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : null}
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
) : (
|
||||||
<tr>
|
<TableRow>
|
||||||
<td colSpan={columnCount} style={{ textAlign: "center" }}>
|
<TableCell align="center" colSpan={9}>
|
||||||
当前筛选条件下没有账单
|
当前无数据
|
||||||
</td>
|
</TableCell>
|
||||||
</tr>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</TableBody>
|
||||||
</table>
|
</Table>
|
||||||
</div>
|
</TableContainer>
|
||||||
) : null}
|
) : null}
|
||||||
|
{total > pageSize ? (
|
||||||
<div className="finance-pagination">
|
<div className="finance-pagination">
|
||||||
<span className="finance-pagination__meta num">
|
|
||||||
共 {formatAmount(total)} 笔
|
|
||||||
{summary ? ` · 筛选合计 ${formatUsdMinor(summary.total?.usdMinorAmount || 0, "USD")}` : ""} · 第{" "}
|
|
||||||
{page} / {Math.max(1, Math.ceil(total / pageSize))} 页
|
|
||||||
</span>
|
|
||||||
<div className="finance-pagination__buttons">
|
|
||||||
<Button
|
<Button
|
||||||
disabled={page <= 1 || loading}
|
disabled={page <= 1 || loading}
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={() => onFiltersChange({ page: page - 1 })}
|
onClick={() => onFiltersChange({ page: page - 1 })}
|
||||||
>
|
>
|
||||||
上一页
|
上一页
|
||||||
</Button>
|
</Button>
|
||||||
|
<span>
|
||||||
|
第 {page} 页 / 共 {total} 条
|
||||||
|
</span>
|
||||||
<Button
|
<Button
|
||||||
disabled={!hasNextPage || loading}
|
disabled={!hasNextPage || loading}
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={() => onFiltersChange({ page: page + 1 })}
|
onClick={() => onFiltersChange({ page: page + 1 })}
|
||||||
>
|
>
|
||||||
下一页
|
下一页
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// BillDetail 是行内展开的单笔明细:金额瀑布 + 单号集合 + 谷歌实付操作。
|
function Stacked({ primary, secondary }) {
|
||||||
function BillDetail({ googlePaidRefreshing, item, onRefreshGooglePaid }) {
|
|
||||||
const currencyCode = item.userPaidCurrencyCode || item.currencyCode || "USD";
|
|
||||||
const paid = Number(item.userPaidAmountMicro) || 0;
|
|
||||||
const fee = Number(item.providerFeeMicro) || 0;
|
|
||||||
const tax = Number(item.providerTaxMicro) || 0;
|
|
||||||
const net = Number(item.providerNetMicro) || 0;
|
|
||||||
const isGoogle = item.rechargeType === "google_play_recharge";
|
|
||||||
const unsynced = isGoogle && !item.paidSyncedAtMs;
|
|
||||||
return (
|
return (
|
||||||
<div className="finance-detail">
|
<span className="finance-stack">
|
||||||
<div>
|
<span>{primary || "-"}</span>
|
||||||
<div className="finance-card__title" style={{ marginBottom: 10 }}>
|
<small>{secondary || ""}</small>
|
||||||
金额拆解{paid ? ` · 实付币种 ${currencyCode}` : ""}
|
</span>
|
||||||
</div>
|
|
||||||
{paid && (fee || tax || net) ? (
|
|
||||||
<div className="finance-wfall">
|
|
||||||
<DetailWfallRow
|
|
||||||
amount={paid}
|
|
||||||
color="var(--fin-ink)"
|
|
||||||
currency={currencyCode}
|
|
||||||
label="用户实付"
|
|
||||||
max={paid}
|
|
||||||
/>
|
|
||||||
{tax ? (
|
|
||||||
<DetailWfallRow
|
|
||||||
amount={-tax}
|
|
||||||
color="#C86A6A"
|
|
||||||
currency={currencyCode}
|
|
||||||
label="代缴税费"
|
|
||||||
max={paid}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{fee ? (
|
|
||||||
<DetailWfallRow
|
|
||||||
amount={-fee}
|
|
||||||
color="#E19B3C"
|
|
||||||
currency={currencyCode}
|
|
||||||
label="渠道服务费"
|
|
||||||
max={paid}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{net ? (
|
|
||||||
<DetailWfallRow
|
|
||||||
amount={net}
|
|
||||||
color="var(--fin-pos)"
|
|
||||||
currency={currencyCode}
|
|
||||||
label="开发者净收"
|
|
||||||
max={paid}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : unsynced ? (
|
|
||||||
<p className="finance-hint">
|
|
||||||
实付明细未同步。
|
|
||||||
<Button
|
|
||||||
disabled={googlePaidRefreshing}
|
|
||||||
size="small"
|
|
||||||
style={{ marginLeft: 8 }}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
onRefreshGooglePaid([item.transactionId]);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
同步这一笔
|
|
||||||
</Button>
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p className="finance-hint">
|
|
||||||
{paid ? `用户实付 ${formatMicroMoney(paid, currencyCode)} · ` : ""}
|
|
||||||
该渠道未提供扣费明细
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<dl className="finance-kv">
|
|
||||||
<dt>交易号</dt>
|
|
||||||
<dd className="finance-order-cell">{item.transactionId || "-"}</dd>
|
|
||||||
{item.commandId ? (
|
|
||||||
<>
|
|
||||||
<dt>业务单号</dt>
|
|
||||||
<dd className="finance-order-cell">{item.commandId}</dd>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{item.externalRef ? (
|
|
||||||
<>
|
|
||||||
<dt>外部订单</dt>
|
|
||||||
<dd className="finance-order-cell">{item.externalRef}</dd>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{item.user?.username || item.user?.userId ? (
|
|
||||||
<>
|
|
||||||
<dt>充值用户</dt>
|
|
||||||
<dd>
|
|
||||||
{item.user.username || "-"}
|
|
||||||
{item.user.userId ? ` · ID ${item.user.userId}` : ""}
|
|
||||||
</dd>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{item.sellerUserId ? (
|
|
||||||
<>
|
|
||||||
<dt>币商 ID</dt>
|
|
||||||
<dd className="num">{item.sellerUserId}</dd>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
<dt>美金换算</dt>
|
|
||||||
<dd className="num">{formatUsdMinor(item.usdMinorAmount, "USD")}</dd>
|
|
||||||
{item.paidSyncedAtMs ? (
|
|
||||||
<>
|
|
||||||
<dt>实付同步于</dt>
|
|
||||||
<dd className="num">{formatTime(item.paidSyncedAtMs)}</dd>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DetailWfallRow({ amount, color, currency, label, max }) {
|
function appName(item, apps) {
|
||||||
const negative = amount < 0;
|
return item.appName || apps.find((app) => app.appCode === item.appCode)?.appName || item.appCode || "-";
|
||||||
const width = max > 0 ? (Math.abs(amount) / max) * 100 : 0;
|
|
||||||
return (
|
|
||||||
<div className="finance-wfall__row">
|
|
||||||
<span className="finance-wfall__label">{label}</span>
|
|
||||||
<span className="finance-wfall__track">
|
|
||||||
<i style={{ background: color, width: `${Math.min(100, width)}%` }} />
|
|
||||||
</span>
|
|
||||||
<span className={negative ? "finance-wfall__val num finance-amount--negative" : "finance-wfall__val num"}>
|
|
||||||
{negative ? `− ${formatMicroMoney(-amount, currency)}` : formatMicroMoney(amount, currency)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AmountText({ children, value }) {
|
|
||||||
const negative = hasNumber(value) && Number(value) < 0;
|
|
||||||
return <span className={negative ? "finance-amount finance-amount--negative" : "finance-amount"}>{children}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function badgeTone(rechargeType) {
|
|
||||||
return rechargeSourceTone(rechargeType) === "google"
|
|
||||||
? "google"
|
|
||||||
: rechargeSourceTone(rechargeType) === "coinseller"
|
|
||||||
? "coinseller"
|
|
||||||
: "thirdparty";
|
|
||||||
}
|
|
||||||
|
|
||||||
function rangeLabel(timeRange) {
|
|
||||||
const start = timeRange?.startMs ? formatTime(timeRange.startMs) : "…";
|
|
||||||
const end = timeRange?.endMs ? formatTime(timeRange.endMs) : "…";
|
|
||||||
return `${start} ~ ${end}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function appName(appCode, apps) {
|
|
||||||
return (apps || []).find((app) => app.appCode === appCode)?.appName || appCode || "-";
|
|
||||||
}
|
|
||||||
|
|
||||||
// regionText 解析区域列:全部 App 视图下带上 App 前缀(如 “Lalu-南亚区”),单 App 视图只显示区域名。
|
|
||||||
function regionText(item, regionsByApp, apps, withAppPrefix) {
|
|
||||||
const regionId = item.targetRegionId || item.sellerRegionId;
|
|
||||||
if (!regionId) {
|
|
||||||
return "—";
|
|
||||||
}
|
|
||||||
const appRegions = (regionsByApp || {})[item.appCode] || [];
|
|
||||||
const name = appRegions.find((region) => String(region.regionId) === String(regionId))?.name || `区域 ${regionId}`;
|
|
||||||
return withAppPrefix ? `${appName(item.appCode, apps)}-${name}` : name;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function secondaryOrderNo(item) {
|
function secondaryOrderNo(item) {
|
||||||
@ -466,13 +153,20 @@ function coinText(value) {
|
|||||||
return text === "-" ? "-" : `${text} 金币`;
|
return text === "-" ? "-" : `${text} 金币`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function exchangeText(item) {
|
||||||
|
if (hasNumber(item.exchangeCoinAmount) || hasNumber(item.exchangeUsdMinorAmount)) {
|
||||||
|
return `${coinText(item.exchangeCoinAmount)} = ${formatUsdMinor(item.exchangeUsdMinorAmount, "USD")}`;
|
||||||
|
}
|
||||||
|
return formatUsdMinor(item.usdMinorAmount, "USD");
|
||||||
|
}
|
||||||
|
|
||||||
function billAmountText(item) {
|
function billAmountText(item) {
|
||||||
if (item.billAmountText) {
|
if (item.billAmountText) {
|
||||||
return item.billAmountText;
|
return item.billAmountText;
|
||||||
}
|
}
|
||||||
const amount = hasNumber(item.billAmountMinor)
|
const amount = hasNumber(item.billAmountMinor)
|
||||||
? item.billAmountMinor
|
? item.billAmountMinor
|
||||||
: hasPositiveNumber(item.providerAmountMinor)
|
: hasNumber(item.providerAmountMinor)
|
||||||
? item.providerAmountMinor
|
? item.providerAmountMinor
|
||||||
: item.usdMinorAmount;
|
: item.usdMinorAmount;
|
||||||
return formatUsdMinor(amount, item.currencyCode || "USD");
|
return formatUsdMinor(amount, item.currencyCode || "USD");
|
||||||
@ -482,42 +176,27 @@ function userPaidText(item) {
|
|||||||
if (item.userPaidText) {
|
if (item.userPaidText) {
|
||||||
return item.userPaidText;
|
return item.userPaidText;
|
||||||
}
|
}
|
||||||
if (hasNonZeroNumber(item.userPaidAmountMicro) && item.userPaidCurrencyCode) {
|
if (hasNumber(item.userPaidAmountMinor)) {
|
||||||
return formatMicroMoney(item.userPaidAmountMicro, item.userPaidCurrencyCode);
|
return formatUsdMinor(item.userPaidAmountMinor, item.currencyCode || "USD");
|
||||||
}
|
}
|
||||||
if (isGoogleBill(item) && !item.paidSyncedAtMs) {
|
if (hasNumber(item.providerAmountMinor)) {
|
||||||
return "未同步";
|
return formatUsdMinor(item.providerAmountMinor, item.currencyCode || "USD");
|
||||||
}
|
}
|
||||||
if (hasPositiveNumber(item.userPaidAmountMinor)) {
|
if (hasNumber(item.userPaidAmountMicro)) {
|
||||||
return formatUsdMinor(item.userPaidAmountMinor, item.userPaidCurrencyCode || item.currencyCode || "USD");
|
return formatMicroMoney(item.userPaidAmountMicro, item.currencyCode || "USD");
|
||||||
}
|
}
|
||||||
return "-";
|
return "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
function taxDeductionText(item) {
|
function taxDeductionText(item) {
|
||||||
const currency = item.userPaidCurrencyCode || item.currencyCode || "USD";
|
const rate = formatPercent(item.taxRate);
|
||||||
const fee = hasPositiveNumber(item.providerFeeMicro) ? item.providerFeeMicro : 0;
|
const amount =
|
||||||
const tax = hasPositiveNumber(item.providerTaxMicro) ? item.providerTaxMicro : 0;
|
item.taxDeductionText ||
|
||||||
const deduction = fee + tax;
|
(hasNumber(item.taxDeductionMinor) ? formatUsdMinor(item.taxDeductionMinor, item.currencyCode || "USD") : "");
|
||||||
if (deduction > 0) {
|
if (amount && rate) {
|
||||||
const parts = [formatMicroMoney(deduction, currency)];
|
return `${amount} / ${rate}`;
|
||||||
if (hasPositiveNumber(item.userPaidAmountMicro)) {
|
|
||||||
const percent = (deduction / item.userPaidAmountMicro) * 100;
|
|
||||||
parts.push(`${percent.toLocaleString("zh-CN", { maximumFractionDigits: 2 })}%`);
|
|
||||||
}
|
|
||||||
return parts.join(" / ");
|
|
||||||
}
|
}
|
||||||
if (item.taxDeductionText) {
|
return amount || rate || "-";
|
||||||
return item.taxDeductionText;
|
|
||||||
}
|
|
||||||
if (isGoogleBill(item) && !item.paidSyncedAtMs) {
|
|
||||||
return "未同步";
|
|
||||||
}
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isGoogleBill(item) {
|
|
||||||
return item.rechargeType === "google_play_recharge" || item.providerCode === "google_play";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasNumber(value) {
|
function hasNumber(value) {
|
||||||
@ -526,11 +205,3 @@ function hasNumber(value) {
|
|||||||
}
|
}
|
||||||
return Number.isFinite(Number(value));
|
return Number.isFinite(Number(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasPositiveNumber(value) {
|
|
||||||
return hasNumber(value) && Number(value) > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasNonZeroNumber(value) {
|
|
||||||
return hasNumber(value) && Number(value) !== 0;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import { expect, test, vi } from "vitest";
|
import { expect, test, vi } from "vitest";
|
||||||
import { FinanceRechargeDetailList } from "./FinanceRechargeDetailList.jsx";
|
import { FinanceRechargeDetailList } from "./FinanceRechargeDetailList.jsx";
|
||||||
|
|
||||||
@ -14,7 +14,6 @@ const baseProps = {
|
|||||||
appCode: "",
|
appCode: "",
|
||||||
keyword: "",
|
keyword: "",
|
||||||
page: 1,
|
page: 1,
|
||||||
regionId: "",
|
|
||||||
timeRange: { endMs: "", startMs: "" },
|
timeRange: { endMs: "", startMs: "" },
|
||||||
},
|
},
|
||||||
loading: false,
|
loading: false,
|
||||||
@ -26,63 +25,49 @@ const baseProps = {
|
|||||||
{ appCode: "aslan", appName: "Aslan" },
|
{ appCode: "aslan", appName: "Aslan" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
summary: {
|
|
||||||
googlePlay: { billCount: 1, coinAmount: 90000, usdMinorAmount: 999 },
|
|
||||||
thirdParty: { billCount: 0, coinAmount: 0, usdMinorAmount: 0 },
|
|
||||||
total: { billCount: 1, coinAmount: 90000, usdMinorAmount: 999 },
|
|
||||||
},
|
|
||||||
summaryLoading: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
test("hides coin recharge columns by default", () => {
|
test("renders app recharge detail columns and values", () => {
|
||||||
render(
|
render(
|
||||||
<FinanceRechargeDetailList
|
<FinanceRechargeDetailList
|
||||||
{...baseProps}
|
{...baseProps}
|
||||||
bills={{
|
bills={{
|
||||||
...baseProps.bills,
|
...baseProps.bills,
|
||||||
items: [rechargeBillFixture()],
|
items: [
|
||||||
|
{
|
||||||
|
appCode: "aslan",
|
||||||
|
coinAmount: 90000,
|
||||||
|
commandId: "cmd-1",
|
||||||
|
createdAtMs: 1760000000000,
|
||||||
|
currencyCode: "SAR",
|
||||||
|
exchangeCoinAmount: 90000,
|
||||||
|
exchangeUsdMinorAmount: 999,
|
||||||
|
externalRef: "GPA.1",
|
||||||
|
id: "tx-google",
|
||||||
|
providerAmountMinor: 1299,
|
||||||
|
rechargeType: "google_play_recharge",
|
||||||
|
taxDeductionMinor: 195,
|
||||||
|
taxRate: 0.15,
|
||||||
|
transactionId: "tx-google",
|
||||||
|
usdMinorAmount: 999,
|
||||||
|
},
|
||||||
|
],
|
||||||
total: 1,
|
total: 1,
|
||||||
}}
|
}}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByRole("switch", { name: "金币显示" })).not.toBeChecked();
|
|
||||||
expect(screen.queryByRole("columnheader", { name: "充值金币" })).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByRole("columnheader", { name: "金币换算" })).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByText("90,000 金币")).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByText("90,000 金币 = USD 9.99")).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByText("1 笔 · 90,000 金币")).not.toBeInTheDocument();
|
|
||||||
expect(screen.getAllByText("1 笔")).toHaveLength(2);
|
|
||||||
expect(screen.getByRole("columnheader", { name: "账单金额" })).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole("columnheader", { name: "用户实付" })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders app recharge coin columns and values when switch is enabled", () => {
|
|
||||||
render(
|
|
||||||
<FinanceRechargeDetailList
|
|
||||||
{...baseProps}
|
|
||||||
bills={{
|
|
||||||
...baseProps.bills,
|
|
||||||
items: [rechargeBillFixture()],
|
|
||||||
total: 1,
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("switch", { name: "金币显示" }));
|
|
||||||
|
|
||||||
expect(screen.getByRole("columnheader", { name: "APP" })).toBeInTheDocument();
|
expect(screen.getByRole("columnheader", { name: "APP" })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("columnheader", { name: "充值金币" })).toBeInTheDocument();
|
expect(screen.getByRole("columnheader", { name: "充值总额" })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("columnheader", { name: "充值来源" })).toBeInTheDocument();
|
expect(screen.getByRole("columnheader", { name: "充值来源" })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("columnheader", { name: "充值订单号" })).toBeInTheDocument();
|
expect(screen.getByRole("columnheader", { name: "充值订单号" })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("columnheader", { name: "金币换算" })).toBeInTheDocument();
|
expect(screen.getByRole("columnheader", { name: "美金换算" })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("columnheader", { name: "账单金额" })).toBeInTheDocument();
|
expect(screen.getByRole("columnheader", { name: "账单金额" })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("columnheader", { name: "用户实付" })).toBeInTheDocument();
|
expect(screen.getByRole("columnheader", { name: "用户实付" })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("columnheader", { name: "三方税率扣款" })).toBeInTheDocument();
|
expect(screen.getByRole("columnheader", { name: "三方税率扣款" })).toBeInTheDocument();
|
||||||
expect(screen.getByText("Aslan")).toBeInTheDocument();
|
expect(screen.getByText("Aslan")).toBeInTheDocument();
|
||||||
expect(screen.getByText("90,000 金币")).toBeInTheDocument();
|
expect(screen.getByText("90,000 金币")).toBeInTheDocument();
|
||||||
expect(screen.getAllByText("1 笔 · 90,000 金币")).toHaveLength(2);
|
expect(screen.getByText("谷歌充值")).toBeInTheDocument();
|
||||||
expect(screen.getAllByText("谷歌充值").length).toBeGreaterThanOrEqual(1);
|
|
||||||
expect(screen.getByText("tx-google")).toBeInTheDocument();
|
expect(screen.getByText("tx-google")).toBeInTheDocument();
|
||||||
expect(screen.getByText("cmd-1 / GPA.1")).toBeInTheDocument();
|
expect(screen.getByText("cmd-1 / GPA.1")).toBeInTheDocument();
|
||||||
expect(screen.getByText("90,000 金币 = USD 9.99")).toBeInTheDocument();
|
expect(screen.getByText("90,000 金币 = USD 9.99")).toBeInTheDocument();
|
||||||
@ -94,32 +79,6 @@ test("renders app recharge coin columns and values when switch is enabled", () =
|
|||||||
test("keeps empty recharge detail table colspan aligned with visible columns", () => {
|
test("keeps empty recharge detail table colspan aligned with visible columns", () => {
|
||||||
const { container } = render(<FinanceRechargeDetailList {...baseProps} />);
|
const { container } = render(<FinanceRechargeDetailList {...baseProps} />);
|
||||||
|
|
||||||
expect(container.querySelector("tbody td")?.colSpan).toBe(7);
|
|
||||||
expect(screen.getByText("当前无数据")).toBeInTheDocument();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("switch", { name: "金币显示" }));
|
|
||||||
expect(container.querySelector("tbody td")?.colSpan).toBe(9);
|
expect(container.querySelector("tbody td")?.colSpan).toBe(9);
|
||||||
|
expect(screen.getByText("当前无数据")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
function rechargeBillFixture() {
|
|
||||||
return {
|
|
||||||
appCode: "aslan",
|
|
||||||
coinAmount: 90000,
|
|
||||||
commandId: "cmd-1",
|
|
||||||
createdAtMs: 1760000000000,
|
|
||||||
currencyCode: "SAR",
|
|
||||||
exchangeCoinAmount: 90000,
|
|
||||||
exchangeUsdMinorAmount: 999,
|
|
||||||
externalRef: "GPA.1",
|
|
||||||
id: "tx-google",
|
|
||||||
paidSyncedAtMs: 1760000100000,
|
|
||||||
providerAmountMinor: 1299,
|
|
||||||
providerTaxMicro: 1950000,
|
|
||||||
rechargeType: "google_play_recharge",
|
|
||||||
transactionId: "tx-google",
|
|
||||||
usdMinorAmount: 999,
|
|
||||||
userPaidAmountMicro: 13000000,
|
|
||||||
userPaidCurrencyCode: "SAR",
|
|
||||||
userPaidText: "SAR 12.99",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,152 +0,0 @@
|
|||||||
import Button from "@mui/material/Button";
|
|
||||||
import { EMPTY_RECHARGE_OVERVIEW, EMPTY_RECHARGE_SUMMARY, formatAmount, formatUsdMinor } from "../format.js";
|
|
||||||
|
|
||||||
// 渠道对账页:按结算月核对各渠道的流水与扣费。谷歌口径来自 Orders API 同步的实付明细;
|
|
||||||
// 三方与币商没有渠道侧扣费数据时如实标注,不做拍脑袋估算。
|
|
||||||
export function FinanceReconciliation({ loading, month, onMonthChange, overview, summary }) {
|
|
||||||
const data = summary || EMPTY_RECHARGE_SUMMARY;
|
|
||||||
const paid = (overview || EMPTY_RECHARGE_OVERVIEW).googlePaid;
|
|
||||||
const totalUsd = Number(data.total?.usdMinorAmount) || 0;
|
|
||||||
const feeUsd = paid.estFeeUsdMinor;
|
|
||||||
const taxUsd = paid.estTaxUsdMinor;
|
|
||||||
const netUsd = totalUsd - feeUsd - taxUsd;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="finance-overview">
|
|
||||||
<div className="finance-recon-toolbar">
|
|
||||||
<Button size="small" variant="outlined" onClick={() => onMonthChange(-1)}>
|
|
||||||
‹ 上月
|
|
||||||
</Button>
|
|
||||||
<span className="finance-recon-toolbar__month">{month.label} 结算期</span>
|
|
||||||
<Button disabled={month.isCurrent} size="small" variant="outlined" onClick={() => onMonthChange(1)}>
|
|
||||||
下月 ›
|
|
||||||
</Button>
|
|
||||||
<span className="finance-hint" style={{ marginLeft: "auto" }}>
|
|
||||||
对账口径:平台流水 × 谷歌 Orders API 实付 · 中国时区
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section className="finance-card">
|
|
||||||
<div className="finance-card__head">
|
|
||||||
<span className="finance-card__title">
|
|
||||||
结算瀑布<small>全渠道 · USD{paid.unsyncedCount > 0 ? " · 谷歌扣费为已同步部分估算" : ""}</small>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="finance-card__body">
|
|
||||||
{loading ? (
|
|
||||||
<p className="finance-hint">统计中…</p>
|
|
||||||
) : (
|
|
||||||
<div className="finance-wfall">
|
|
||||||
<WaterfallRow amount={totalUsd} color="var(--fin-ink)" label="充值流水" max={totalUsd} />
|
|
||||||
<WaterfallRow amount={-feeUsd} color="#E19B3C" label="渠道手续费(估)" max={totalUsd} />
|
|
||||||
<WaterfallRow amount={-taxUsd} color="#C86A6A" label="代缴税费(估)" max={totalUsd} />
|
|
||||||
<WaterfallRow amount={netUsd} color="var(--fin-pos)" label="净入账(估)" max={totalUsd} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="finance-recon-grid">
|
|
||||||
<section className="finance-card">
|
|
||||||
<div className="finance-card__head">
|
|
||||||
<span className="finance-badge finance-badge--google">谷歌</span>
|
|
||||||
{paid.unsyncedCount > 0 ? (
|
|
||||||
<span className="finance-pill finance-pill--warn">
|
|
||||||
<i />
|
|
||||||
{formatAmount(paid.unsyncedCount)} 笔待同步
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="finance-pill finance-pill--ok">
|
|
||||||
<i />
|
|
||||||
实付已全量同步
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="finance-card__body">
|
|
||||||
<ReconLine
|
|
||||||
label="平台流水"
|
|
||||||
value={formatUsdMinor(data.googlePlay?.usdMinorAmount || 0, "USD")}
|
|
||||||
/>
|
|
||||||
<ReconLine
|
|
||||||
label={`已同步实付覆盖(${formatAmount(paid.syncedCount)}/${formatAmount(paid.googleBillCount)} 笔)`}
|
|
||||||
value={formatUsdMinor(paid.coveredUsdMinor, "USD")}
|
|
||||||
/>
|
|
||||||
<ReconLine
|
|
||||||
label="谷歌服务费(估)"
|
|
||||||
negative
|
|
||||||
value={`− ${formatUsdMinor(paid.estFeeUsdMinor, "USD")}`}
|
|
||||||
/>
|
|
||||||
<ReconLine
|
|
||||||
label="代缴税费(估)"
|
|
||||||
negative
|
|
||||||
value={`− ${formatUsdMinor(paid.estTaxUsdMinor, "USD")}`}
|
|
||||||
/>
|
|
||||||
<ReconLine label="预计净入账" total value={formatUsdMinor(paid.estNetUsdMinor, "USD")} />
|
|
||||||
<p className="finance-hint" style={{ marginTop: 10 }}>
|
|
||||||
与 Play Console 财务报告按月核对;未同步订单可在流水页批量补数
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="finance-card">
|
|
||||||
<div className="finance-card__head">
|
|
||||||
<span className="finance-badge finance-badge--thirdparty">三方</span>
|
|
||||||
</div>
|
|
||||||
<div className="finance-card__body">
|
|
||||||
<ReconLine
|
|
||||||
label="三方流水"
|
|
||||||
value={formatUsdMinor(data.thirdParty?.usdMinorAmount || 0, "USD")}
|
|
||||||
/>
|
|
||||||
<ReconLine label="笔数" value={`${formatAmount(data.thirdParty?.billCount || 0)} 笔`} />
|
|
||||||
<ReconLine label="渠道扣费" value="V5Pay 逐笔回传 / MiFaPay 未提供" />
|
|
||||||
<p className="finance-hint" style={{ marginTop: 10 }}>
|
|
||||||
V5Pay 的 fee/tax 已逐笔展示在流水明细;MiFaPay 仅提供毛额,需用渠道后台月账单人工核对
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="finance-card">
|
|
||||||
<div className="finance-card__head">
|
|
||||||
<span className="finance-badge finance-badge--coinseller">币商</span>
|
|
||||||
</div>
|
|
||||||
<div className="finance-card__body">
|
|
||||||
<ReconLine
|
|
||||||
label="净进货(进货 − 冲回)"
|
|
||||||
value={formatUsdMinor(data.coinSeller?.usdMinorAmount || 0, "USD")}
|
|
||||||
/>
|
|
||||||
<ReconLine label="记录数" value={`${formatAmount(data.coinSeller?.billCount || 0)} 笔`} />
|
|
||||||
<ReconLine label="金币入库" value={`${formatAmount(data.coinSeller?.coinAmount || 0)} 金币`} />
|
|
||||||
<p className="finance-hint" style={{ marginTop: 10 }}>
|
|
||||||
打款凭证与 TRC20 交易号逐笔登记在流水明细,可按凭证号搜索核对
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function WaterfallRow({ amount, color, label, max }) {
|
|
||||||
const width = max > 0 ? (Math.abs(amount) / max) * 100 : 0;
|
|
||||||
const negative = amount < 0;
|
|
||||||
return (
|
|
||||||
<div className="finance-wfall__row">
|
|
||||||
<span className="finance-wfall__label">{label}</span>
|
|
||||||
<span className="finance-wfall__track">
|
|
||||||
<i style={{ background: color, width: `${Math.min(100, width)}%` }} />
|
|
||||||
</span>
|
|
||||||
<span className={negative ? "finance-wfall__val num finance-amount--negative" : "finance-wfall__val num"}>
|
|
||||||
{negative ? `− ${formatUsdMinor(-amount, "USD")}` : formatUsdMinor(amount, "USD")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReconLine({ label, negative, total, value }) {
|
|
||||||
return (
|
|
||||||
<div className={["finance-recon-line", total ? "finance-recon-line--total" : ""].filter(Boolean).join(" ")}>
|
|
||||||
<span>{label}</span>
|
|
||||||
<b className={negative ? "num finance-amount--negative" : "num"}>{value}</b>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,120 +1,77 @@
|
|||||||
import AssignmentOutlined from "@mui/icons-material/AssignmentOutlined";
|
import AssignmentOutlined from "@mui/icons-material/AssignmentOutlined";
|
||||||
import FactCheckOutlined from "@mui/icons-material/FactCheckOutlined";
|
import FactCheckOutlined from "@mui/icons-material/FactCheckOutlined";
|
||||||
import InsightsOutlined from "@mui/icons-material/InsightsOutlined";
|
|
||||||
import PaymentsOutlined from "@mui/icons-material/PaymentsOutlined";
|
import PaymentsOutlined from "@mui/icons-material/PaymentsOutlined";
|
||||||
import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined";
|
import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined";
|
||||||
import RuleFolderOutlined from "@mui/icons-material/RuleFolderOutlined";
|
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||||
|
import Button from "@mui/material/Button";
|
||||||
import LinearProgress from "@mui/material/LinearProgress";
|
import LinearProgress from "@mui/material/LinearProgress";
|
||||||
|
|
||||||
const NAV_GROUPS = [
|
const views = [
|
||||||
{
|
{ icon: ReceiptLongOutlined, id: "rechargeDetails", label: "APP充值详情" },
|
||||||
label: "经营",
|
{ icon: AssignmentOutlined, id: "create", label: "发起申请" },
|
||||||
views: [
|
{ icon: FactCheckOutlined, id: "applications", label: "申请审核" },
|
||||||
{ icon: InsightsOutlined, id: "overview", label: "财务概览" },
|
{ icon: PaymentsOutlined, id: "withdrawals", label: "用户提现申请" }
|
||||||
{ icon: ReceiptLongOutlined, id: "rechargeDetails", label: "充值流水" },
|
|
||||||
{ icon: RuleFolderOutlined, id: "recon", label: "渠道对账" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "审批",
|
|
||||||
views: [
|
|
||||||
{ icon: AssignmentOutlined, id: "create", label: "发起申请" },
|
|
||||||
{ icon: FactCheckOutlined, id: "applications", label: "申请审核" },
|
|
||||||
{ icon: PaymentsOutlined, id: "withdrawals", label: "用户提现申请" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const VIEW_TITLES = {
|
export function FinanceShell({ activeView, canAudit, canCreate, canViewRechargeDetails, canViewWithdrawals, children, loading, onRefresh, onViewChange, session }) {
|
||||||
applications: "申请审核",
|
const visibleViews = views.filter((view) => {
|
||||||
create: "发起申请",
|
if (view.id === "rechargeDetails") {
|
||||||
overview: "财务概览",
|
return canViewRechargeDetails;
|
||||||
recon: "渠道对账",
|
}
|
||||||
rechargeDetails: "充值流水",
|
if (view.id === "applications") {
|
||||||
withdrawals: "用户提现申请",
|
return canAudit;
|
||||||
};
|
}
|
||||||
|
if (view.id === "withdrawals") {
|
||||||
|
return canViewWithdrawals;
|
||||||
|
}
|
||||||
|
return canCreate;
|
||||||
|
});
|
||||||
|
const activeLabel = visibleViews.find((view) => view.id === activeView)?.label || visibleViews[0]?.label || "财务系统";
|
||||||
|
|
||||||
export function FinanceShell({
|
return (
|
||||||
activeView,
|
<main className="finance-shell">
|
||||||
appBar,
|
<aside className="finance-sidebar">
|
||||||
canAudit,
|
<div className="finance-brand">
|
||||||
canCreate,
|
<span>HY</span>
|
||||||
canViewRechargeDetails,
|
<strong>财务系统</strong>
|
||||||
canViewWithdrawals,
|
</div>
|
||||||
children,
|
<nav className="finance-nav" aria-label="财务系统菜单">
|
||||||
loading,
|
{visibleViews.map((view) => {
|
||||||
onViewChange,
|
const Icon = view.icon;
|
||||||
session,
|
return (
|
||||||
subtitle,
|
<button className={activeView === view.id ? "is-active" : ""} key={view.id} type="button" onClick={() => onViewChange(view.id)}>
|
||||||
toolbar,
|
<Icon fontSize="small" />
|
||||||
}) {
|
<span>{view.label}</span>
|
||||||
const allows = (viewId) => {
|
</button>
|
||||||
if (viewId === "overview" || viewId === "rechargeDetails" || viewId === "recon") {
|
);
|
||||||
return canViewRechargeDetails;
|
})}
|
||||||
}
|
</nav>
|
||||||
if (viewId === "applications") {
|
<div className="finance-user">
|
||||||
return canAudit;
|
<span>{session?.user?.name || session?.user?.account || "当前用户"}</span>
|
||||||
}
|
<small>
|
||||||
if (viewId === "withdrawals") {
|
{canViewRechargeDetails
|
||||||
return canViewWithdrawals;
|
? "payment-bill:view"
|
||||||
}
|
: canAudit
|
||||||
return canCreate;
|
? "finance-application:audit"
|
||||||
};
|
: canViewWithdrawals
|
||||||
return (
|
? "finance-withdrawal:view"
|
||||||
<main className="finance-shell">
|
: "finance-application:create"}
|
||||||
<aside className="finance-sidebar">
|
</small>
|
||||||
<div className="finance-brand">
|
</div>
|
||||||
<span>HY</span>
|
</aside>
|
||||||
<div>
|
<section className="finance-main">
|
||||||
<strong>财务工作台</strong>
|
<header className="finance-header">
|
||||||
<small>Finance Console</small>
|
<div>
|
||||||
</div>
|
<span>HYApp</span>
|
||||||
</div>
|
<h1>{activeLabel}</h1>
|
||||||
<nav className="finance-nav" aria-label="财务系统菜单">
|
</div>
|
||||||
{NAV_GROUPS.map((group) => {
|
<Button startIcon={<RefreshOutlined fontSize="small" />} variant="outlined" onClick={onRefresh}>
|
||||||
const visible = group.views.filter((view) => allows(view.id));
|
刷新
|
||||||
if (!visible.length) {
|
</Button>
|
||||||
return null;
|
</header>
|
||||||
}
|
{loading ? <LinearProgress className="finance-progress" /> : null}
|
||||||
return (
|
<div className="finance-content">{children}</div>
|
||||||
<div className="finance-nav__group" key={group.label}>
|
</section>
|
||||||
<span className="finance-nav__label">{group.label}</span>
|
</main>
|
||||||
{visible.map((view) => {
|
);
|
||||||
const Icon = view.icon;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={activeView === view.id ? "is-active" : ""}
|
|
||||||
key={view.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onViewChange(view.id)}
|
|
||||||
>
|
|
||||||
<Icon fontSize="small" />
|
|
||||||
<span>{view.label}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
<div className="finance-user">
|
|
||||||
<span>{session?.user?.name || session?.user?.account || "当前用户"}</span>
|
|
||||||
<small>财务系统</small>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
<section className="finance-main">
|
|
||||||
{loading ? <LinearProgress className="finance-progress" /> : null}
|
|
||||||
<header className="finance-topbar">
|
|
||||||
{appBar || (
|
|
||||||
<div className="finance-topbar__title">
|
|
||||||
<h1>{VIEW_TITLES[activeView] || "财务系统"}</h1>
|
|
||||||
{subtitle ? <small>{subtitle}</small> : null}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{toolbar ? <div className="finance-topbar__tools">{toolbar}</div> : null}
|
|
||||||
</header>
|
|
||||||
<div className="finance-content">{children}</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -138,17 +138,9 @@ export function normalizeRechargeBill(item = {}) {
|
|||||||
exchangeUsdMinorAmount: numberValue(item.exchangeUsdMinorAmount ?? item.exchange_usd_minor_amount),
|
exchangeUsdMinorAmount: numberValue(item.exchangeUsdMinorAmount ?? item.exchange_usd_minor_amount),
|
||||||
externalRef: stringValue(item.externalRef ?? item.external_ref),
|
externalRef: stringValue(item.externalRef ?? item.external_ref),
|
||||||
id: stringValue(item.id ?? item.transactionId ?? item.transaction_id),
|
id: stringValue(item.id ?? item.transactionId ?? item.transaction_id),
|
||||||
paidSyncedAtMs: numberOrNull(item.paidSyncedAtMs ?? item.paid_synced_at_ms),
|
|
||||||
providerAmountMinor: numberOrNull(item.providerAmountMinor ?? item.provider_amount_minor),
|
providerAmountMinor: numberOrNull(item.providerAmountMinor ?? item.provider_amount_minor),
|
||||||
providerCode: stringValue(item.providerCode ?? item.provider_code),
|
|
||||||
providerFeeMicro: numberOrNull(item.providerFeeMicro ?? item.provider_fee_micro),
|
|
||||||
providerNetMicro: numberOrNull(item.providerNetMicro ?? item.provider_net_micro),
|
|
||||||
providerTaxMicro: numberOrNull(item.providerTaxMicro ?? item.provider_tax_micro),
|
|
||||||
rechargeType: stringValue(item.rechargeType ?? item.recharge_type),
|
rechargeType: stringValue(item.rechargeType ?? item.recharge_type),
|
||||||
sellerRegionId: numberOrNull(item.sellerRegionId ?? item.seller_region_id),
|
|
||||||
sellerUserId: numberOrNull(item.sellerUserId ?? item.seller_user_id),
|
|
||||||
status: stringValue(item.status),
|
status: stringValue(item.status),
|
||||||
targetRegionId: numberOrNull(item.targetRegionId ?? item.target_region_id),
|
|
||||||
taxDeductionMinor: numberOrNull(
|
taxDeductionMinor: numberOrNull(
|
||||||
item.taxDeductionMinor ??
|
item.taxDeductionMinor ??
|
||||||
item.tax_deduction_minor ??
|
item.tax_deduction_minor ??
|
||||||
@ -164,11 +156,6 @@ export function normalizeRechargeBill(item = {}) {
|
|||||||
taxRate: numberOrNull(item.taxRate ?? item.tax_rate ?? item.thirdPartyTaxRate ?? item.third_party_tax_rate),
|
taxRate: numberOrNull(item.taxRate ?? item.tax_rate ?? item.thirdPartyTaxRate ?? item.third_party_tax_rate),
|
||||||
transactionId: stringValue(item.transactionId ?? item.transaction_id),
|
transactionId: stringValue(item.transactionId ?? item.transaction_id),
|
||||||
usdMinorAmount: numberValue(item.usdMinorAmount ?? item.usd_minor_amount),
|
usdMinorAmount: numberValue(item.usdMinorAmount ?? item.usd_minor_amount),
|
||||||
user: {
|
|
||||||
displayUserId: stringValue(item.user?.displayUserId ?? item.user?.display_user_id),
|
|
||||||
userId: stringValue(item.user?.userId ?? item.user?.user_id) || stringValue(item.userId ?? item.user_id),
|
|
||||||
username: stringValue(item.user?.username),
|
|
||||||
},
|
|
||||||
userPaidAmountMicro: numberOrNull(
|
userPaidAmountMicro: numberOrNull(
|
||||||
item.userPaidAmountMicro ?? item.user_paid_amount_micro ?? item.paidAmountMicro ?? item.paid_amount_micro,
|
item.userPaidAmountMicro ?? item.user_paid_amount_micro ?? item.paidAmountMicro ?? item.paid_amount_micro,
|
||||||
),
|
),
|
||||||
@ -180,7 +167,6 @@ export function normalizeRechargeBill(item = {}) {
|
|||||||
item.providerAmountMinor ??
|
item.providerAmountMinor ??
|
||||||
item.provider_amount_minor,
|
item.provider_amount_minor,
|
||||||
),
|
),
|
||||||
userPaidCurrencyCode: stringValue(item.userPaidCurrencyCode ?? item.user_paid_currency_code),
|
|
||||||
userPaidText: stringValue(
|
userPaidText: stringValue(
|
||||||
item.userPaidText ??
|
item.userPaidText ??
|
||||||
item.user_paid_text ??
|
item.user_paid_text ??
|
||||||
@ -202,158 +188,6 @@ export function normalizeRechargeBillPage(data = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_RECHARGE_SUMMARY_BUCKET = { billCount: 0, coinAmount: 0, usdMinorAmount: 0 };
|
|
||||||
|
|
||||||
export const EMPTY_RECHARGE_SUMMARY = {
|
|
||||||
coinSeller: EMPTY_RECHARGE_SUMMARY_BUCKET,
|
|
||||||
googlePlay: EMPTY_RECHARGE_SUMMARY_BUCKET,
|
|
||||||
thirdParty: EMPTY_RECHARGE_SUMMARY_BUCKET,
|
|
||||||
total: EMPTY_RECHARGE_SUMMARY_BUCKET,
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizeRechargeSummaryBucket(bucket = {}) {
|
|
||||||
return {
|
|
||||||
billCount: numberValue(bucket.billCount ?? bucket.bill_count) || 0,
|
|
||||||
coinAmount: numberValue(bucket.coinAmount ?? bucket.coin_amount) || 0,
|
|
||||||
usdMinorAmount: numberValue(bucket.usdMinorAmount ?? bucket.usd_minor_amount) || 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeRechargeSummary(data = {}) {
|
|
||||||
return {
|
|
||||||
coinSeller: normalizeRechargeSummaryBucket(data.coinSeller ?? data.coin_seller),
|
|
||||||
googlePlay: normalizeRechargeSummaryBucket(data.googlePlay ?? data.google_play),
|
|
||||||
thirdParty: normalizeRechargeSummaryBucket(data.thirdParty ?? data.third_party),
|
|
||||||
total: normalizeRechargeSummaryBucket(data.total),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mergeRechargeSummaries(summaries = []) {
|
|
||||||
const merged = normalizeRechargeSummary({});
|
|
||||||
for (const summary of summaries) {
|
|
||||||
for (const key of ["coinSeller", "googlePlay", "thirdParty", "total"]) {
|
|
||||||
merged[key] = {
|
|
||||||
billCount: merged[key].billCount + summary[key].billCount,
|
|
||||||
coinAmount: merged[key].coinAmount + summary[key].coinAmount,
|
|
||||||
usdMinorAmount: merged[key].usdMinorAmount + summary[key].usdMinorAmount,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return merged;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const EMPTY_RECHARGE_OVERVIEW = {
|
|
||||||
daily: [],
|
|
||||||
googlePaid: {
|
|
||||||
coveredUsdMinor: 0,
|
|
||||||
estFeeUsdMinor: 0,
|
|
||||||
estNetUsdMinor: 0,
|
|
||||||
estTaxUsdMinor: 0,
|
|
||||||
googleBillCount: 0,
|
|
||||||
syncedCount: 0,
|
|
||||||
unsyncedCount: 0,
|
|
||||||
},
|
|
||||||
regions: [],
|
|
||||||
withdrawal: {
|
|
||||||
approvedCount: 0,
|
|
||||||
approvedUsdMinor: 0,
|
|
||||||
totalUsdMinor: 0,
|
|
||||||
transferCount: 0,
|
|
||||||
transferUsdMinor: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export function normalizeRechargeOverview(data = {}) {
|
|
||||||
const daily = Array.isArray(data.daily) ? data.daily : [];
|
|
||||||
const regions = Array.isArray(data.regions) ? data.regions : [];
|
|
||||||
const paid = data.googlePaid ?? data.google_paid ?? {};
|
|
||||||
const withdrawal = data.withdrawal ?? {};
|
|
||||||
return {
|
|
||||||
daily: daily.map((item) => ({
|
|
||||||
coinSellerCoinAmount: numberValue(item.coinSellerCoinAmount ?? item.coin_seller_coin_amount) || 0,
|
|
||||||
coinSellerUsdMinor: numberValue(item.coinSellerUsdMinor ?? item.coin_seller_usd_minor) || 0,
|
|
||||||
date: stringValue(item.date),
|
|
||||||
googleCoinAmount: numberValue(item.googleCoinAmount ?? item.google_coin_amount) || 0,
|
|
||||||
googleUsdMinor: numberValue(item.googleUsdMinor ?? item.google_usd_minor) || 0,
|
|
||||||
thirdPartyCoinAmount: numberValue(item.thirdPartyCoinAmount ?? item.third_party_coin_amount) || 0,
|
|
||||||
thirdPartyUsdMinor: numberValue(item.thirdPartyUsdMinor ?? item.third_party_usd_minor) || 0,
|
|
||||||
})),
|
|
||||||
googlePaid: {
|
|
||||||
coveredUsdMinor: numberValue(paid.coveredUsdMinor ?? paid.covered_usd_minor) || 0,
|
|
||||||
estFeeUsdMinor: numberValue(paid.estFeeUsdMinor ?? paid.est_fee_usd_minor) || 0,
|
|
||||||
estNetUsdMinor: numberValue(paid.estNetUsdMinor ?? paid.est_net_usd_minor) || 0,
|
|
||||||
estTaxUsdMinor: numberValue(paid.estTaxUsdMinor ?? paid.est_tax_usd_minor) || 0,
|
|
||||||
googleBillCount: numberValue(paid.googleBillCount ?? paid.google_bill_count) || 0,
|
|
||||||
syncedCount: numberValue(paid.syncedCount ?? paid.synced_count) || 0,
|
|
||||||
unsyncedCount: numberValue(paid.unsyncedCount ?? paid.unsynced_count) || 0,
|
|
||||||
},
|
|
||||||
regions: regions.map((item) => ({
|
|
||||||
billCount: numberValue(item.billCount ?? item.bill_count) || 0,
|
|
||||||
name: stringValue(item.name),
|
|
||||||
regionId: numberOrNull(item.regionId ?? item.region_id),
|
|
||||||
usdMinorAmount: numberValue(item.usdMinorAmount ?? item.usd_minor_amount) || 0,
|
|
||||||
})),
|
|
||||||
withdrawal: {
|
|
||||||
approvedCount: numberValue(withdrawal.approvedCount ?? withdrawal.approved_count) || 0,
|
|
||||||
approvedUsdMinor: numberValue(withdrawal.approvedUsdMinor ?? withdrawal.approved_usd_minor) || 0,
|
|
||||||
totalUsdMinor: numberValue(withdrawal.totalUsdMinor ?? withdrawal.total_usd_minor) || 0,
|
|
||||||
transferCount: numberValue(withdrawal.transferCount ?? withdrawal.transfer_count) || 0,
|
|
||||||
transferUsdMinor: numberValue(withdrawal.transferUsdMinor ?? withdrawal.transfer_usd_minor) || 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// mergeRechargeOverviews 把多 App 概览合并成全局口径:daily 按日期求和,googlePaid 求和;
|
|
||||||
// 区域 ID 属于单 App 语义,跨 App 合并时丢弃区域分布。
|
|
||||||
export function mergeRechargeOverviews(overviews = []) {
|
|
||||||
const dailyByDate = new Map();
|
|
||||||
const merged = normalizeRechargeOverview({});
|
|
||||||
for (const overview of overviews) {
|
|
||||||
for (const bucket of overview.daily) {
|
|
||||||
const existing = dailyByDate.get(bucket.date);
|
|
||||||
if (!existing) {
|
|
||||||
dailyByDate.set(bucket.date, { ...bucket });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for (const key of [
|
|
||||||
"coinSellerCoinAmount",
|
|
||||||
"coinSellerUsdMinor",
|
|
||||||
"googleCoinAmount",
|
|
||||||
"googleUsdMinor",
|
|
||||||
"thirdPartyCoinAmount",
|
|
||||||
"thirdPartyUsdMinor",
|
|
||||||
]) {
|
|
||||||
existing[key] += bucket[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const key of Object.keys(merged.googlePaid)) {
|
|
||||||
merged.googlePaid[key] += overview.googlePaid[key];
|
|
||||||
}
|
|
||||||
for (const key of Object.keys(merged.withdrawal)) {
|
|
||||||
merged.withdrawal[key] += overview.withdrawal[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
merged.daily = [...dailyByDate.values()].sort((left, right) => left.date.localeCompare(right.date));
|
|
||||||
return merged;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeRechargeRegions(data = {}) {
|
|
||||||
const items = Array.isArray(data.items) ? data.items : Array.isArray(data) ? data : [];
|
|
||||||
return items
|
|
||||||
.map((item) => {
|
|
||||||
const regionId = numberOrNull(item.regionId ?? item.region_id);
|
|
||||||
if (!regionId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
name: stringValue(item.name),
|
|
||||||
regionCode: stringValue(item.regionCode ?? item.region_code),
|
|
||||||
regionId,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function operationLabel(value, operations = FINANCE_OPERATIONS) {
|
export function operationLabel(value, operations = FINANCE_OPERATIONS) {
|
||||||
return operations.find((item) => item.value === value)?.label || value || "-";
|
return operations.find((item) => item.value === value)?.label || value || "-";
|
||||||
}
|
}
|
||||||
@ -362,54 +196,12 @@ export function rechargeTypeLabel(value) {
|
|||||||
if (value === "coin_seller_transfer") {
|
if (value === "coin_seller_transfer") {
|
||||||
return "币商充值";
|
return "币商充值";
|
||||||
}
|
}
|
||||||
if (value === "coin_seller_stock_purchase") {
|
|
||||||
return "币商进货";
|
|
||||||
}
|
|
||||||
if (value === "coin_seller_stock_deduction") {
|
|
||||||
return "币商冲回";
|
|
||||||
}
|
|
||||||
if (value === "google_play_recharge") {
|
if (value === "google_play_recharge") {
|
||||||
return "谷歌充值";
|
return "谷歌充值";
|
||||||
}
|
}
|
||||||
if (value === "mifapay") {
|
|
||||||
return "三方充值 · MiFaPay";
|
|
||||||
}
|
|
||||||
if (value === "v5pay") {
|
|
||||||
return "三方充值 · V5Pay";
|
|
||||||
}
|
|
||||||
if (value === "usdt_trc20") {
|
|
||||||
return "三方充值 · USDT";
|
|
||||||
}
|
|
||||||
if (value === "apple_recharge") {
|
|
||||||
return "苹果充值";
|
|
||||||
}
|
|
||||||
if (value === "huawei_recharge") {
|
|
||||||
return "华为充值";
|
|
||||||
}
|
|
||||||
if (value === "telegram_recharge") {
|
|
||||||
return "Telegram 充值";
|
|
||||||
}
|
|
||||||
if (value === "web_recharge") {
|
|
||||||
return "三方充值 · Web";
|
|
||||||
}
|
|
||||||
return value || "-";
|
return value || "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
// rechargeSourceTone 决定来源徽章与 KPI 卡片的配色分组:google/thirdparty/coinseller。
|
|
||||||
export function rechargeSourceTone(rechargeType) {
|
|
||||||
if (rechargeType === "google_play_recharge") {
|
|
||||||
return "google";
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
rechargeType === "coin_seller_stock_purchase" ||
|
|
||||||
rechargeType === "coin_seller_stock_deduction" ||
|
|
||||||
rechargeType === "coin_seller_transfer"
|
|
||||||
) {
|
|
||||||
return "coinseller";
|
|
||||||
}
|
|
||||||
return "thirdparty";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function statusLabel(value) {
|
export function statusLabel(value) {
|
||||||
return APPLICATION_STATUS.find(([status]) => status === value)?.[1] || value || "-";
|
return APPLICATION_STATUS.find(([status]) => status === value)?.[1] || value || "-";
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -162,7 +162,6 @@ export const fallbackNavigation = [
|
|||||||
routeNavItem("app-config-popups", { icon: ImageOutlined }),
|
routeNavItem("app-config-popups", { icon: ImageOutlined }),
|
||||||
routeNavItem("app-config-explore", { icon: MapOutlined }),
|
routeNavItem("app-config-explore", { icon: MapOutlined }),
|
||||||
routeNavItem("app-config-versions", { icon: SettingsApplicationsOutlined }),
|
routeNavItem("app-config-versions", { icon: SettingsApplicationsOutlined }),
|
||||||
routeNavItem("operation-full-server-notice", { icon: CampaignOutlined }),
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -191,6 +190,7 @@ export const fallbackNavigation = [
|
|||||||
routeNavItem("lucky-gift", { icon: RedeemOutlined }),
|
routeNavItem("lucky-gift", { icon: RedeemOutlined }),
|
||||||
routeNavItem("operation-reports", { icon: FlagOutlined }),
|
routeNavItem("operation-reports", { icon: FlagOutlined }),
|
||||||
routeNavItem("operation-gift-diamond", { icon: DiamondOutlined }),
|
routeNavItem("operation-gift-diamond", { icon: DiamondOutlined }),
|
||||||
|
routeNavItem("operation-full-server-notice", { icon: CampaignOutlined }),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -268,16 +268,15 @@ export const fallbackNavigation = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function mapBackendMenus(menus = [], options = { relocate: true }) {
|
export function mapBackendMenus(menus = []) {
|
||||||
const sourceMenus = options.relocate ? orderTopLevelMenus(relocateBackendMenus(menus)) : menus;
|
return orderTopLevelMenus(relocateBackendMenus(menus))
|
||||||
return sourceMenus
|
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
if (deprecatedMenuCodes.has(item.code)) {
|
if (deprecatedMenuCodes.has(item.code)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const route = item.code ? getRouteByMenuCode(item.code) : undefined;
|
const route = item.code ? getRouteByMenuCode(item.code) : undefined;
|
||||||
const children = item.children?.length ? mapBackendMenus(item.children, { relocate: false }) : undefined;
|
const children = item.children?.length ? mapBackendMenus(item.children) : undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
children: children?.length ? children : undefined,
|
children: children?.length ? children : undefined,
|
||||||
@ -293,49 +292,7 @@ export function mapBackendMenus(menus = [], options = { relocate: true }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function relocateBackendMenus(menus = []) {
|
function relocateBackendMenus(menus = []) {
|
||||||
return relocateFullServerNoticeToAppConfig(relocateLogMenusToSystem(menus));
|
return relocateLogMenusToSystem(menus);
|
||||||
}
|
|
||||||
|
|
||||||
function relocateFullServerNoticeToAppConfig(menus = []) {
|
|
||||||
let movedItem = null;
|
|
||||||
|
|
||||||
function strip(items = []) {
|
|
||||||
return items
|
|
||||||
.map((item) => {
|
|
||||||
if (item.code === "operation-full-server-notice") {
|
|
||||||
if (!movedItem) {
|
|
||||||
movedItem = item;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const children = item.children?.length ? strip(item.children) : item.children;
|
|
||||||
return children === item.children ? item : { ...item, children };
|
|
||||||
})
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
const strippedMenus = strip(menus);
|
|
||||||
if (!movedItem) {
|
|
||||||
return strippedMenus;
|
|
||||||
}
|
|
||||||
|
|
||||||
return strippedMenus.map((item) =>
|
|
||||||
item.code === "app-config"
|
|
||||||
? {
|
|
||||||
...item,
|
|
||||||
children: [
|
|
||||||
...(item.children || []),
|
|
||||||
{
|
|
||||||
...movedItem,
|
|
||||||
icon: movedItem.icon || "campaign",
|
|
||||||
label: "系统消息推送",
|
|
||||||
path: "/app-config/system-message-push",
|
|
||||||
permissionCode: movedItem.permissionCode || "full-server-notice:view",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: item,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function orderTopLevelMenus(menus = []) {
|
function orderTopLevelMenus(menus = []) {
|
||||||
|
|||||||
@ -162,10 +162,7 @@ describe("navigation menu helpers", () => {
|
|||||||
label: "APP配置",
|
label: "APP配置",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const fallbackMenus = filterNavigationByPermission(
|
const fallbackMenus = filterNavigationByPermission(fallbackNavigation, (code) => code === "app-config:view");
|
||||||
fallbackNavigation,
|
|
||||||
(code) => code === "app-config:view" || code === "full-server-notice:view",
|
|
||||||
);
|
|
||||||
const merged = mergeNavigationItems(mapBackendMenus(backendMenus), fallbackMenus);
|
const merged = mergeNavigationItems(mapBackendMenus(backendMenus), fallbackMenus);
|
||||||
const appConfig = merged.find((item) => item.code === "app-config");
|
const appConfig = merged.find((item) => item.code === "app-config");
|
||||||
|
|
||||||
@ -176,46 +173,9 @@ describe("navigation menu helpers", () => {
|
|||||||
"app-config-popups",
|
"app-config-popups",
|
||||||
"app-config-explore",
|
"app-config-explore",
|
||||||
"app-config-versions",
|
"app-config-versions",
|
||||||
"operation-full-server-notice",
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("moves stale full server notice backend menu under app config", () => {
|
|
||||||
const mapped = mapBackendMenus([
|
|
||||||
{
|
|
||||||
children: [{ code: "app-config-h5", id: "app-config-h5", label: "H5配置", path: "/app-config/h5" }],
|
|
||||||
code: "app-config",
|
|
||||||
icon: "settings",
|
|
||||||
id: "app-config",
|
|
||||||
label: "APP配置",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
code: "operation-full-server-notice",
|
|
||||||
icon: "campaign",
|
|
||||||
id: "operation-full-server-notice",
|
|
||||||
label: "全服通知",
|
|
||||||
path: "/operations/full-server-notices",
|
|
||||||
permissionCode: "full-server-notice:view",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
code: "operations",
|
|
||||||
icon: "receipt",
|
|
||||||
id: "operations",
|
|
||||||
label: "运营管理",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
const appConfig = mapped.find((item) => item.code === "app-config");
|
|
||||||
const operations = mapped.find((item) => item.code === "operations");
|
|
||||||
|
|
||||||
expect(appConfig.children.map((item) => item.code)).toEqual(["app-config-h5", "operation-full-server-notice"]);
|
|
||||||
expect(appConfig.children.find((item) => item.code === "operation-full-server-notice").path).toBe(
|
|
||||||
"/app-config/system-message-push",
|
|
||||||
);
|
|
||||||
expect((operations.children || []).map((item) => item.code)).not.toContain("operation-full-server-notice");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("adds team settings under system menu when backend menu is stale", () => {
|
test("adds team settings under system menu when backend menu is stale", () => {
|
||||||
const backendMenus = [
|
const backendMenus = [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -132,7 +132,6 @@ export const PERMISSIONS = {
|
|||||||
resourceView: "resource:view",
|
resourceView: "resource:view",
|
||||||
resourceCreate: "resource:create",
|
resourceCreate: "resource:create",
|
||||||
resourceUpdate: "resource:update",
|
resourceUpdate: "resource:update",
|
||||||
resourceDelete: "resource:delete",
|
|
||||||
resourceShopView: "resource-shop:view",
|
resourceShopView: "resource-shop:view",
|
||||||
resourceShopUpdate: "resource-shop:update",
|
resourceShopUpdate: "resource-shop:update",
|
||||||
resourceGroupView: "resource-group:view",
|
resourceGroupView: "resource-group:view",
|
||||||
|
|||||||
@ -20,7 +20,6 @@ const emptyForm = (achievement = {}) => ({
|
|||||||
achievementType: achievement.achievementType || "ordinary",
|
achievementType: achievement.achievementType || "ordinary",
|
||||||
description: achievement.description || "",
|
description: achievement.description || "",
|
||||||
primaryBadgeResourceId: optionalId(achievement.primaryBadgeResourceId),
|
primaryBadgeResourceId: optionalId(achievement.primaryBadgeResourceId),
|
||||||
sortOrder: String(achievement.sortOrder || 0),
|
|
||||||
title: achievement.title || "",
|
title: achievement.title || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -197,7 +196,7 @@ function buildAchievementPayload(form) {
|
|||||||
honor_visibility: true,
|
honor_visibility: true,
|
||||||
primary_badge_resource_id: Number(form.primaryBadgeResourceId || 0),
|
primary_badge_resource_id: Number(form.primaryBadgeResourceId || 0),
|
||||||
reward_resource_group_id: 0,
|
reward_resource_group_id: 0,
|
||||||
sort_order: Number(form.sortOrder || 0),
|
sort_order: 0,
|
||||||
status: "active",
|
status: "active",
|
||||||
title: String(form.title || "").trim(),
|
title: String(form.title || "").trim(),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -46,12 +46,6 @@ const baseColumns = [
|
|||||||
<span className={styles.typeBadge}>{achievementTypeLabel(achievement.achievementType)}</span>
|
<span className={styles.typeBadge}>{achievementTypeLabel(achievement.achievementType)}</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: "sortOrder",
|
|
||||||
label: "排序",
|
|
||||||
width: "96px",
|
|
||||||
render: (achievement) => <span className={styles.meta}>{achievement.sortOrder || 0}</span>,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "description",
|
key: "description",
|
||||||
label: "成就描述",
|
label: "成就描述",
|
||||||
@ -207,13 +201,6 @@ function AchievementFormDialog({ disabled, form, loading, mode, onClose, onSubmi
|
|||||||
value={form.title}
|
value={form.title}
|
||||||
onChange={changeField(page.setForm, "title")}
|
onChange={changeField(page.setForm, "title")}
|
||||||
/>
|
/>
|
||||||
<TextField
|
|
||||||
disabled={disabled}
|
|
||||||
label="排序"
|
|
||||||
type="number"
|
|
||||||
value={form.sortOrder}
|
|
||||||
onChange={changeField(page.setForm, "sortOrder")}
|
|
||||||
/>
|
|
||||||
<TextField
|
<TextField
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
label="描述"
|
label="描述"
|
||||||
|
|||||||
@ -9,7 +9,6 @@ export const achievementFormSchema = z
|
|||||||
achievementType: achievementTypeSchema,
|
achievementType: achievementTypeSchema,
|
||||||
description: z.string().trim().max(240, "描述不能超过 240 个字符").optional(),
|
description: z.string().trim().max(240, "描述不能超过 240 个字符").optional(),
|
||||||
primaryBadgeResourceId: optionalNumberInput,
|
primaryBadgeResourceId: optionalNumberInput,
|
||||||
sortOrder: z.coerce.number().int("排序必须是整数").min(0, "排序不能小于 0").default(0),
|
|
||||||
title: z.string().trim().min(1, "请输入成就名称").max(64, "成就名称不能超过 64 个字符"),
|
title: z.string().trim().min(1, "请输入成就名称").max(64, "成就名称不能超过 64 个字符"),
|
||||||
})
|
})
|
||||||
.superRefine((value, context) => {
|
.superRefine((value, context) => {
|
||||||
|
|||||||
@ -1,91 +0,0 @@
|
|||||||
import { afterEach, expect, test, vi } from "vitest";
|
|
||||||
import { setAccessToken } from "@/shared/api/request";
|
|
||||||
import { createSystemMessagePushFanout } from "./api";
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
setAccessToken("");
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("system message push fanout API keeps existing operations path", async () => {
|
|
||||||
vi.stubGlobal(
|
|
||||||
"fetch",
|
|
||||||
vi.fn(
|
|
||||||
async () =>
|
|
||||||
new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
code: 0,
|
|
||||||
data: {
|
|
||||||
command_id: "admin_notice_test",
|
|
||||||
created: true,
|
|
||||||
job_id: "mfan_test",
|
|
||||||
message_type: "system",
|
|
||||||
status: "pending",
|
|
||||||
target_scope: "all_registered_users",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
await createSystemMessagePushFanout({
|
|
||||||
action_param: "https://example.com",
|
|
||||||
action_type: "app_h5",
|
|
||||||
batch_size: 500,
|
|
||||||
message_type: "system",
|
|
||||||
producer_event_type: "admin_full_server_notice",
|
|
||||||
summary: "今晚 23:00 维护",
|
|
||||||
target_scope: "all_registered_users",
|
|
||||||
title: "维护通知",
|
|
||||||
});
|
|
||||||
|
|
||||||
const [url, init] = vi.mocked(fetch).mock.calls[0];
|
|
||||||
|
|
||||||
expect(String(url)).toContain("/api/v1/admin/operations/full-server-notices/fanout");
|
|
||||||
expect(init?.method).toBe("POST");
|
|
||||||
expect(JSON.parse(String(init?.body))).toMatchObject({
|
|
||||||
action_param: "https://example.com",
|
|
||||||
action_type: "app_h5",
|
|
||||||
message_type: "system",
|
|
||||||
producer_event_type: "admin_full_server_notice",
|
|
||||||
target_scope: "all_registered_users",
|
|
||||||
title: "维护通知",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("system message push fanout API preserves string user IDs", async () => {
|
|
||||||
vi.stubGlobal(
|
|
||||||
"fetch",
|
|
||||||
vi.fn(
|
|
||||||
async () =>
|
|
||||||
new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
code: 0,
|
|
||||||
data: {
|
|
||||||
command_id: "admin_notice_users",
|
|
||||||
created: true,
|
|
||||||
job_id: "mfan_users",
|
|
||||||
message_type: "system",
|
|
||||||
status: "pending",
|
|
||||||
target_scope: "user_ids",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
await createSystemMessagePushFanout({
|
|
||||||
message_type: "system",
|
|
||||||
summary: "指定用户维护",
|
|
||||||
target_scope: "user_ids",
|
|
||||||
title: "维护通知",
|
|
||||||
user_ids: ["325379237278126080", "42"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const [, init] = vi.mocked(fetch).mock.calls[0];
|
|
||||||
|
|
||||||
expect(JSON.parse(String(init?.body))).toMatchObject({
|
|
||||||
target_scope: "user_ids",
|
|
||||||
user_ids: ["325379237278126080", "42"],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -19,40 +19,6 @@ import type {
|
|||||||
PageQuery,
|
PageQuery,
|
||||||
} from "@/shared/api/types";
|
} from "@/shared/api/types";
|
||||||
|
|
||||||
export interface SystemMessagePushPayload {
|
|
||||||
action_param?: string;
|
|
||||||
action_type?: string;
|
|
||||||
aggregate_type?: string;
|
|
||||||
batch_size?: number;
|
|
||||||
body?: string;
|
|
||||||
message_type: "system";
|
|
||||||
producer_event_type?: string;
|
|
||||||
summary: string;
|
|
||||||
target_scope: "all_registered_users" | "user_ids";
|
|
||||||
title: string;
|
|
||||||
user_ids?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SystemMessagePushResponse {
|
|
||||||
command_id: string;
|
|
||||||
created: boolean;
|
|
||||||
job_id: string;
|
|
||||||
message_type: string;
|
|
||||||
status: string;
|
|
||||||
target_scope: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createSystemMessagePushFanout(payload: SystemMessagePushPayload): Promise<SystemMessagePushResponse> {
|
|
||||||
const endpoint = API_ENDPOINTS.createFullServerNoticeFanout;
|
|
||||||
return apiRequest<SystemMessagePushResponse, SystemMessagePushPayload>(
|
|
||||||
apiEndpointPath(API_OPERATIONS.createFullServerNoticeFanout),
|
|
||||||
{
|
|
||||||
body: payload,
|
|
||||||
method: endpoint.method,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listH5Links(): Promise<ApiList<H5LinkConfigDto>> {
|
export function listH5Links(): Promise<ApiList<H5LinkConfigDto>> {
|
||||||
const endpoint = API_ENDPOINTS.listH5Links;
|
const endpoint = API_ENDPOINTS.listH5Links;
|
||||||
return apiRequest<ApiList<H5LinkConfigDto>>(apiEndpointPath(API_OPERATIONS.listH5Links), {
|
return apiRequest<ApiList<H5LinkConfigDto>>(apiEndpointPath(API_OPERATIONS.listH5Links), {
|
||||||
|
|||||||
@ -1,273 +1,176 @@
|
|||||||
.linkText {
|
.linkText {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bannerCover,
|
.bannerCover,
|
||||||
.bannerCoverEmpty {
|
.bannerCoverEmpty {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
width: 88px;
|
width: 88px;
|
||||||
height: 50px;
|
height: 50px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--bg-card-strong);
|
background: var(--bg-card-strong);
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bannerCover {
|
.bannerCover {
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.popupCover,
|
.popupCover,
|
||||||
.popupCoverEmpty {
|
.popupCoverEmpty {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
width: 72px;
|
width: 72px;
|
||||||
height: 72px;
|
height: 72px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--bg-card-strong);
|
background: var(--bg-card-strong);
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.popupCover {
|
.popupCover {
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.splashCover,
|
.splashCover,
|
||||||
.splashCoverEmpty {
|
.splashCoverEmpty {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
width: 54px;
|
width: 54px;
|
||||||
height: 96px;
|
height: 96px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--bg-card-strong);
|
background: var(--bg-card-strong);
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.splashCover {
|
.splashCover {
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.formWideField {
|
.formWideField {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
|
||||||
|
|
||||||
.noticeBody {
|
|
||||||
display: grid;
|
|
||||||
min-height: 0;
|
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
|
|
||||||
gap: var(--space-5);
|
|
||||||
padding: var(--space-5);
|
|
||||||
background: var(--bg-card);
|
|
||||||
}
|
|
||||||
|
|
||||||
.noticePanel {
|
|
||||||
display: grid;
|
|
||||||
min-width: 0;
|
|
||||||
gap: var(--space-5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.noticeHeader {
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.noticeHeader h2,
|
|
||||||
.noticePreview h3 {
|
|
||||||
min-width: 0;
|
|
||||||
margin: 0;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 800;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.noticeGrid,
|
|
||||||
.noticeContentGrid {
|
|
||||||
display: grid;
|
|
||||||
min-width: 0;
|
|
||||||
grid-template-columns: repeat(2, minmax(220px, 1fr));
|
|
||||||
gap: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.noticeContentGrid {
|
|
||||||
grid-template-columns: repeat(2, minmax(260px, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.noticeWideField {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.noticePreview {
|
|
||||||
display: grid;
|
|
||||||
align-content: start;
|
|
||||||
min-width: 0;
|
|
||||||
gap: var(--space-4);
|
|
||||||
padding: var(--space-4);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-card);
|
|
||||||
background: var(--bg-card-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
.noticePreview p {
|
|
||||||
min-width: 0;
|
|
||||||
margin: 0;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.noticePreviewMeta {
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.noticePreviewMeta span {
|
|
||||||
display: inline-flex;
|
|
||||||
max-width: 100%;
|
|
||||||
min-height: 28px;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 var(--space-3);
|
|
||||||
border: 1px solid var(--border-soft);
|
|
||||||
border-radius: var(--radius-control);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.splashFormLayout {
|
.splashFormLayout {
|
||||||
display: grid;
|
display: grid;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
grid-template-columns: minmax(220px, 260px) minmax(0, 1fr);
|
grid-template-columns: minmax(220px, 260px) minmax(0, 1fr);
|
||||||
align-items: start;
|
align-items: start;
|
||||||
gap: var(--space-5);
|
gap: var(--space-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.splashAssetPane,
|
.splashAssetPane,
|
||||||
.splashFieldPane {
|
.splashFieldPane {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.splashAssetPane {
|
.splashAssetPane {
|
||||||
padding-right: var(--space-1);
|
padding-right: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.splashFieldPane {
|
.splashFieldPane {
|
||||||
padding-left: var(--space-1);
|
padding-left: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.splashUploadField {
|
.splashUploadField {
|
||||||
width: min(100%, 260px);
|
width: min(100%, 260px);
|
||||||
justify-self: center;
|
justify-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.splashUploadField .splashUploadPreview {
|
.splashUploadField .splashUploadPreview {
|
||||||
height: auto;
|
height: auto;
|
||||||
aspect-ratio: 9 / 16;
|
aspect-ratio: 9 / 16;
|
||||||
max-height: min(52vh, 420px);
|
max-height: min(52vh, 420px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.splashStatusField {
|
.splashStatusField {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.popupFormLayout {
|
.popupFormLayout {
|
||||||
display: grid;
|
display: grid;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
|
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
|
||||||
align-items: start;
|
align-items: start;
|
||||||
gap: var(--space-5);
|
gap: var(--space-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.popupAssetPane,
|
.popupAssetPane,
|
||||||
.popupFieldPane {
|
.popupFieldPane {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.popupAssetPane {
|
.popupAssetPane {
|
||||||
padding-right: var(--space-1);
|
padding-right: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.popupFieldPane {
|
.popupFieldPane {
|
||||||
padding-left: var(--space-1);
|
padding-left: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.popupUploadField {
|
.popupUploadField {
|
||||||
width: min(100%, 280px);
|
width: min(100%, 280px);
|
||||||
justify-self: center;
|
justify-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.popupUploadField .popupUploadPreview {
|
.popupUploadField .popupUploadPreview {
|
||||||
aspect-ratio: 1 / 1;
|
aspect-ratio: 1 / 1;
|
||||||
height: auto;
|
height: auto;
|
||||||
max-height: min(42vh, 300px);
|
max-height: min(42vh, 300px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.statusCell {
|
.statusCell {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.splashFormLayout {
|
.splashFormLayout {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.splashAssetPane,
|
.splashAssetPane,
|
||||||
.splashFieldPane {
|
.splashFieldPane {
|
||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.splashUploadField {
|
.splashUploadField {
|
||||||
justify-self: stretch;
|
justify-self: stretch;
|
||||||
width: min(100%, 260px);
|
width: min(100%, 260px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.popupFormLayout {
|
.popupFormLayout {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.popupAssetPane,
|
.popupAssetPane,
|
||||||
.popupFieldPane {
|
.popupFieldPane {
|
||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.popupUploadField {
|
.popupUploadField {
|
||||||
justify-self: stretch;
|
justify-self: stretch;
|
||||||
width: min(100%, 280px);
|
width: min(100%, 280px);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
|
||||||
.noticeBody,
|
|
||||||
.noticeGrid,
|
|
||||||
.noticeContentGrid {
|
|
||||||
grid-template-columns: minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,220 +0,0 @@
|
|||||||
import CampaignOutlined from "@mui/icons-material/CampaignOutlined";
|
|
||||||
import SendOutlined from "@mui/icons-material/SendOutlined";
|
|
||||||
import Alert from "@mui/material/Alert";
|
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
|
||||||
import TextField from "@mui/material/TextField";
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { createSystemMessagePushFanout } from "@/features/app-config/api";
|
|
||||||
import { useSystemMessagePushAbilities } from "@/features/app-config/permissions.js";
|
|
||||||
import { parseSystemMessagePushUserIds, systemMessagePushSchema } from "@/features/app-config/schema";
|
|
||||||
import styles from "@/features/app-config/app-config.module.css";
|
|
||||||
import { FormValidationError, parseForm } from "@/shared/forms/validation";
|
|
||||||
import { AdminListPage, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx";
|
|
||||||
import { Button } from "@/shared/ui/Button.jsx";
|
|
||||||
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
|
||||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
|
||||||
|
|
||||||
const targetScopes = [
|
|
||||||
{ label: "所有注册用户", value: "all_registered_users" },
|
|
||||||
{ label: "指定用户", value: "user_ids" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const actionTypes = [
|
|
||||||
{ label: "无跳转", value: "" },
|
|
||||||
{ label: "活动详情", value: "activity_detail" },
|
|
||||||
{ label: "H5 页面", value: "app_h5" },
|
|
||||||
{ label: "房间详情", value: "room_detail" },
|
|
||||||
{ label: "用户主页", value: "user_profile" },
|
|
||||||
{ label: "提现详情", value: "wallet_withdraw_detail" },
|
|
||||||
{ label: "主播申请", value: "host_application_detail" },
|
|
||||||
{ label: "币商转账", value: "coin_seller_transfer_detail" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const initialForm = {
|
|
||||||
actionParam: "",
|
|
||||||
actionType: "",
|
|
||||||
body: "",
|
|
||||||
summary: "",
|
|
||||||
targetScope: "all_registered_users",
|
|
||||||
title: "",
|
|
||||||
userIdsText: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function SystemMessagePushPage() {
|
|
||||||
const abilities = useSystemMessagePushAbilities();
|
|
||||||
const confirm = useConfirm();
|
|
||||||
const { showToast } = useToast();
|
|
||||||
const [form, setForm] = useState(initialForm);
|
|
||||||
const [result, setResult] = useState(null);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
|
|
||||||
const targetLabel = useMemo(
|
|
||||||
() => targetScopes.find((item) => item.value === form.targetScope)?.label || form.targetScope,
|
|
||||||
[form.targetScope],
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateField = (field) => (event) => {
|
|
||||||
setForm((current) => ({ ...current, [field]: event.target.value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
let parsed;
|
|
||||||
try {
|
|
||||||
parsed = parseForm(systemMessagePushSchema, form);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof FormValidationError) {
|
|
||||||
showToast(error.message, "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userIds = parsed.targetScope === "user_ids" ? parseSystemMessagePushUserIds(parsed.userIdsText) : [];
|
|
||||||
const targetText =
|
|
||||||
parsed.targetScope === "all_registered_users" ? "所有注册用户" : `${userIds.length} 个指定用户`;
|
|
||||||
const accepted = await confirm({
|
|
||||||
confirmText: "发送",
|
|
||||||
message: `将向${targetText}发送「${parsed.title}」。`,
|
|
||||||
title: "发送系统消息",
|
|
||||||
});
|
|
||||||
if (!accepted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
const next = await createSystemMessagePushFanout(buildPayload(parsed, userIds));
|
|
||||||
setResult(next);
|
|
||||||
showToast(next.created ? "系统消息推送任务已创建" : "已返回现有系统消息推送任务", "success");
|
|
||||||
} catch (error) {
|
|
||||||
showToast(error.message || "创建系统消息推送失败", "error");
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AdminListPage>
|
|
||||||
<AdminListToolbar
|
|
||||||
actions={
|
|
||||||
abilities.canSend ? (
|
|
||||||
<Button disabled={saving} startIcon={<SendOutlined />} variant="primary" onClick={submit}>
|
|
||||||
发送
|
|
||||||
</Button>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
filters={null}
|
|
||||||
/>
|
|
||||||
<div className={styles.noticeBody}>
|
|
||||||
<section className={styles.noticePanel}>
|
|
||||||
<div className={styles.noticeHeader}>
|
|
||||||
<CampaignOutlined fontSize="small" />
|
|
||||||
<h2>系统消息推送</h2>
|
|
||||||
</div>
|
|
||||||
<div className={styles.noticeGrid}>
|
|
||||||
<TextField label="消息类型" size="small" value="系统消息" disabled />
|
|
||||||
<TextField
|
|
||||||
select
|
|
||||||
label="发送范围"
|
|
||||||
size="small"
|
|
||||||
value={form.targetScope}
|
|
||||||
onChange={updateField("targetScope")}
|
|
||||||
>
|
|
||||||
{targetScopes.map((item) => (
|
|
||||||
<MenuItem key={item.value} value={item.value}>
|
|
||||||
{item.label}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
|
||||||
{form.targetScope === "user_ids" ? (
|
|
||||||
<TextField
|
|
||||||
className={styles.noticeWideField}
|
|
||||||
label="用户 ID 列表"
|
|
||||||
minRows={3}
|
|
||||||
multiline
|
|
||||||
placeholder="1001,1002"
|
|
||||||
value={form.userIdsText}
|
|
||||||
onChange={updateField("userIdsText")}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className={styles.noticeContentGrid}>
|
|
||||||
<TextField
|
|
||||||
required
|
|
||||||
label="标题"
|
|
||||||
size="small"
|
|
||||||
value={form.title}
|
|
||||||
onChange={updateField("title")}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
required
|
|
||||||
label="摘要"
|
|
||||||
size="small"
|
|
||||||
value={form.summary}
|
|
||||||
onChange={updateField("summary")}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
className={styles.noticeWideField}
|
|
||||||
label="正文"
|
|
||||||
minRows={4}
|
|
||||||
multiline
|
|
||||||
value={form.body}
|
|
||||||
onChange={updateField("body")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={styles.noticeGrid}>
|
|
||||||
<TextField
|
|
||||||
select
|
|
||||||
label="跳转类型"
|
|
||||||
size="small"
|
|
||||||
value={form.actionType}
|
|
||||||
onChange={updateField("actionType")}
|
|
||||||
>
|
|
||||||
{actionTypes.map((item) => (
|
|
||||||
<MenuItem key={item.value || "none"} value={item.value}>
|
|
||||||
{item.label}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
|
||||||
<TextField
|
|
||||||
label="跳转参数"
|
|
||||||
size="small"
|
|
||||||
value={form.actionParam}
|
|
||||||
onChange={updateField("actionParam")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section className={styles.noticePreview}>
|
|
||||||
<h3>{form.title || "标题"}</h3>
|
|
||||||
<p>{form.summary || "摘要"}</p>
|
|
||||||
<div className={styles.noticePreviewMeta}>
|
|
||||||
<span>系统消息</span>
|
|
||||||
<span>{targetLabel}</span>
|
|
||||||
<span>{form.actionType || "无跳转"}</span>
|
|
||||||
</div>
|
|
||||||
{result ? (
|
|
||||||
<Alert severity={result.created ? "success" : "info"}>
|
|
||||||
{result.status} · {result.job_id} · {result.command_id}
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</AdminListPage>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPayload(form, userIds) {
|
|
||||||
return {
|
|
||||||
action_param: form.actionParam || undefined,
|
|
||||||
action_type: form.actionType || undefined,
|
|
||||||
aggregate_type: "admin_notice",
|
|
||||||
batch_size: 500,
|
|
||||||
body: form.body || undefined,
|
|
||||||
message_type: "system",
|
|
||||||
producer_event_type: "admin_full_server_notice",
|
|
||||||
summary: form.summary,
|
|
||||||
target_scope: form.targetScope,
|
|
||||||
title: form.title,
|
|
||||||
user_ids: form.targetScope === "user_ids" ? userIds : undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,120 +0,0 @@
|
|||||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { MemoryRouter } from "react-router-dom";
|
|
||||||
import { afterEach, expect, test, vi } from "vitest";
|
|
||||||
import { AppProviders } from "@/app/providers.jsx";
|
|
||||||
import { SystemMessagePushPage } from "./SystemMessagePushPage.jsx";
|
|
||||||
|
|
||||||
vi.mock("@/app/auth/AuthProvider.jsx", () => ({
|
|
||||||
AuthProvider: ({ children }) => children,
|
|
||||||
useAuth: () => ({ can: () => true }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("submits all registered users payload with fixed system message type", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
vi.stubGlobal("fetch", createFanoutFetch({ target_scope: "all_registered_users" }));
|
|
||||||
|
|
||||||
renderPage();
|
|
||||||
await user.type(screen.getByRole("textbox", { name: "标题" }), "维护通知");
|
|
||||||
await user.type(screen.getByRole("textbox", { name: "摘要" }), "今晚 23:00 维护");
|
|
||||||
await user.click(screen.getByRole("button", { name: "发送" }));
|
|
||||||
await confirmSend(user);
|
|
||||||
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(fanoutPostBodies()).toEqual([
|
|
||||||
expect.objectContaining({
|
|
||||||
message_type: "system",
|
|
||||||
producer_event_type: "admin_full_server_notice",
|
|
||||||
target_scope: "all_registered_users",
|
|
||||||
title: "维护通知",
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("submits specified users after parsing and deduping separators", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
vi.stubGlobal("fetch", createFanoutFetch({ target_scope: "user_ids" }));
|
|
||||||
|
|
||||||
renderPage();
|
|
||||||
await user.click(screen.getByRole("combobox", { name: "发送范围" }));
|
|
||||||
await user.click(screen.getByRole("option", { name: "指定用户" }));
|
|
||||||
await user.type(screen.getByRole("textbox", { name: "用户 ID 列表" }), "325379237278126080, 42,42");
|
|
||||||
await user.type(screen.getByRole("textbox", { name: "标题" }), "指定用户通知");
|
|
||||||
await user.type(screen.getByRole("textbox", { name: "摘要" }), "只发给指定用户");
|
|
||||||
await user.click(screen.getByRole("button", { name: "发送" }));
|
|
||||||
await confirmSend(user);
|
|
||||||
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(fanoutPostBodies()).toEqual([
|
|
||||||
expect.objectContaining({
|
|
||||||
message_type: "system",
|
|
||||||
target_scope: "user_ids",
|
|
||||||
user_ids: ["325379237278126080", "42"],
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("blocks specified users submit when user ID list is empty", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
vi.stubGlobal("fetch", createFanoutFetch({ target_scope: "user_ids" }));
|
|
||||||
|
|
||||||
renderPage();
|
|
||||||
await user.click(screen.getByRole("combobox", { name: "发送范围" }));
|
|
||||||
await user.click(screen.getByRole("option", { name: "指定用户" }));
|
|
||||||
await user.type(screen.getByRole("textbox", { name: "标题" }), "指定用户通知");
|
|
||||||
await user.type(screen.getByRole("textbox", { name: "摘要" }), "只发给指定用户");
|
|
||||||
await user.click(screen.getByRole("button", { name: "发送" }));
|
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText("用户 ID 列表不能为空")).toBeInTheDocument());
|
|
||||||
expect(fanoutPostBodies()).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
function renderPage() {
|
|
||||||
return render(
|
|
||||||
<AppProviders>
|
|
||||||
<MemoryRouter>
|
|
||||||
<SystemMessagePushPage />
|
|
||||||
</MemoryRouter>
|
|
||||||
</AppProviders>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmSend(user) {
|
|
||||||
const dialog = await screen.findByRole("dialog", { name: "发送系统消息" });
|
|
||||||
await user.click(within(dialog).getByRole("button", { name: "发送" }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function fanoutPostBodies() {
|
|
||||||
return vi
|
|
||||||
.mocked(fetch)
|
|
||||||
.mock.calls.filter(
|
|
||||||
([url, init]) =>
|
|
||||||
String(url).includes("/api/v1/admin/operations/full-server-notices/fanout") && init?.method === "POST",
|
|
||||||
)
|
|
||||||
.map(([, init]) => JSON.parse(String(init?.body)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function createFanoutFetch({ target_scope }) {
|
|
||||||
return vi.fn(
|
|
||||||
async () =>
|
|
||||||
new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
code: 0,
|
|
||||||
data: {
|
|
||||||
command_id: "admin_notice_test",
|
|
||||||
created: true,
|
|
||||||
job_id: "mfan_test",
|
|
||||||
message_type: "system",
|
|
||||||
status: "pending",
|
|
||||||
target_scope,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -2,24 +2,15 @@ import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
|||||||
import { PERMISSIONS } from "@/app/permissions";
|
import { PERMISSIONS } from "@/app/permissions";
|
||||||
|
|
||||||
export function useAppConfigAbilities() {
|
export function useAppConfigAbilities() {
|
||||||
const { can } = useAuth();
|
const { can } = useAuth();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
canCreateVersion: can(PERMISSIONS.appVersionCreate),
|
canCreateVersion: can(PERMISSIONS.appVersionCreate),
|
||||||
canDeleteVersion: can(PERMISSIONS.appVersionDelete),
|
canDeleteVersion: can(PERMISSIONS.appVersionDelete),
|
||||||
canUpdate: can(PERMISSIONS.appConfigUpdate),
|
canUpdate: can(PERMISSIONS.appConfigUpdate),
|
||||||
canUpdateVersion: can(PERMISSIONS.appVersionUpdate),
|
canUpdateVersion: can(PERMISSIONS.appVersionUpdate),
|
||||||
canUpload: can(PERMISSIONS.uploadCreate),
|
canUpload: can(PERMISSIONS.uploadCreate),
|
||||||
canView: can(PERMISSIONS.appConfigView),
|
canView: can(PERMISSIONS.appConfigView),
|
||||||
canViewVersion: can(PERMISSIONS.appVersionView),
|
canViewVersion: can(PERMISSIONS.appVersionView)
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
export function useSystemMessagePushAbilities() {
|
|
||||||
const { can } = useAuth();
|
|
||||||
|
|
||||||
return {
|
|
||||||
canSend: can(PERMISSIONS.fullServerNoticeSend),
|
|
||||||
canView: can(PERMISSIONS.fullServerNoticeView),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -49,12 +49,4 @@ export const appConfigRoutes = [
|
|||||||
path: "/app-config/versions",
|
path: "/app-config/versions",
|
||||||
permission: PERMISSIONS.appVersionView,
|
permission: PERMISSIONS.appVersionView,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: "系统消息推送",
|
|
||||||
loader: () => import("./pages/SystemMessagePushPage.jsx").then((module) => module.SystemMessagePushPage),
|
|
||||||
menuCode: MENU_CODES.operationFullServerNotice,
|
|
||||||
pageKey: "app-config-system-message-push",
|
|
||||||
path: "/app-config/system-message-push",
|
|
||||||
permission: PERMISSIONS.fullServerNoticeView,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|||||||
@ -5,8 +5,6 @@ import {
|
|||||||
appSplashScreenSchema,
|
appSplashScreenSchema,
|
||||||
exploreTabSchema,
|
exploreTabSchema,
|
||||||
h5LinkUpdateSchema,
|
h5LinkUpdateSchema,
|
||||||
parseSystemMessagePushUserIds,
|
|
||||||
systemMessagePushSchema,
|
|
||||||
} from "@/features/app-config/schema";
|
} from "@/features/app-config/schema";
|
||||||
import { FormValidationError, parseForm } from "@/shared/forms/validation";
|
import { FormValidationError, parseForm } from "@/shared/forms/validation";
|
||||||
|
|
||||||
@ -77,42 +75,6 @@ describe("app config form schema", () => {
|
|||||||
).toThrow(FormValidationError);
|
).toThrow(FormValidationError);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parses and dedupes system message push user IDs as strings", () => {
|
|
||||||
expect(parseSystemMessagePushUserIds("325379237278126080, 42,42 1001")).toEqual([
|
|
||||||
"325379237278126080",
|
|
||||||
"42",
|
|
||||||
"1001",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("keeps system message push payload constrained to supported scopes", () => {
|
|
||||||
const payload = parseForm(systemMessagePushSchema, {
|
|
||||||
actionParam: "",
|
|
||||||
actionType: "",
|
|
||||||
body: "正文",
|
|
||||||
summary: "摘要",
|
|
||||||
targetScope: "all_registered_users",
|
|
||||||
title: "标题",
|
|
||||||
userIdsText: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(payload.targetScope).toBe("all_registered_users");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("requires system message push user IDs for specified users", () => {
|
|
||||||
expect(() =>
|
|
||||||
parseForm(systemMessagePushSchema, {
|
|
||||||
actionParam: "",
|
|
||||||
actionType: "",
|
|
||||||
body: "",
|
|
||||||
summary: "摘要",
|
|
||||||
targetScope: "user_ids",
|
|
||||||
title: "标题",
|
|
||||||
userIdsText: "",
|
|
||||||
}),
|
|
||||||
).toThrow(FormValidationError);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("keeps banner display scope and delivery time fields", () => {
|
test("keeps banner display scope and delivery time fields", () => {
|
||||||
const payload = parseForm(appBannerSchema, validBannerForm);
|
const payload = parseForm(appBannerSchema, validBannerForm);
|
||||||
|
|
||||||
|
|||||||
@ -2,17 +2,6 @@ import { z } from "zod";
|
|||||||
import { validatePublicAppJumpParam } from "@/shared/app-jump/appJumpConfig.js";
|
import { validatePublicAppJumpParam } from "@/shared/app-jump/appJumpConfig.js";
|
||||||
|
|
||||||
const appBannerDisplayScopes = ["home", "room", "recharge", "me"] as const;
|
const appBannerDisplayScopes = ["home", "room", "recharge", "me"] as const;
|
||||||
const systemMessagePushTargetScopes = ["all_registered_users", "user_ids"] as const;
|
|
||||||
const systemMessagePushActionTypes = [
|
|
||||||
"",
|
|
||||||
"activity_detail",
|
|
||||||
"app_h5",
|
|
||||||
"room_detail",
|
|
||||||
"user_profile",
|
|
||||||
"wallet_withdraw_detail",
|
|
||||||
"host_application_detail",
|
|
||||||
"coin_seller_transfer_detail",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const requiredH5LinkURLSchema = z
|
const requiredH5LinkURLSchema = z
|
||||||
.string()
|
.string()
|
||||||
@ -45,61 +34,6 @@ export const exploreTabSchema = z.object({
|
|||||||
|
|
||||||
export type ExploreTabForm = z.infer<typeof exploreTabSchema>;
|
export type ExploreTabForm = z.infer<typeof exploreTabSchema>;
|
||||||
|
|
||||||
export function parseSystemMessagePushUserIds(value: string): string[] {
|
|
||||||
const tokens = String(value || "")
|
|
||||||
.split(/[\s,,]+/)
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
const invalid = tokens.find((item) => !/^[1-9]\d*$/.test(item));
|
|
||||||
if (invalid) {
|
|
||||||
throw new Error("用户 ID 必须是正整数");
|
|
||||||
}
|
|
||||||
return Array.from(new Set(tokens));
|
|
||||||
}
|
|
||||||
|
|
||||||
export const systemMessagePushSchema = z
|
|
||||||
.object({
|
|
||||||
actionParam: z.string().trim().max(2048, "跳转参数不能超过 2048 个字符").default(""),
|
|
||||||
actionType: z.enum(systemMessagePushActionTypes).default(""),
|
|
||||||
body: z.string().trim().max(4000, "正文不能超过 4000 个字符").default(""),
|
|
||||||
summary: z.string().trim().min(1, "请填写摘要").max(240, "摘要不能超过 240 个字符"),
|
|
||||||
targetScope: z.enum(systemMessagePushTargetScopes),
|
|
||||||
title: z.string().trim().min(1, "请填写标题").max(80, "标题不能超过 80 个字符"),
|
|
||||||
userIdsText: z.string().trim().default(""),
|
|
||||||
})
|
|
||||||
.superRefine((value, ctx) => {
|
|
||||||
if (value.targetScope === "user_ids") {
|
|
||||||
let userIds: string[];
|
|
||||||
try {
|
|
||||||
userIds = parseSystemMessagePushUserIds(value.userIdsText);
|
|
||||||
} catch (error) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: error instanceof Error ? error.message : "用户 ID 必须是正整数",
|
|
||||||
path: ["userIdsText"],
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!userIds.length) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: "用户 ID 列表不能为空",
|
|
||||||
path: ["userIdsText"],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value.actionParam && !value.actionType) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: "请选择跳转类型",
|
|
||||||
path: ["actionType"],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export type SystemMessagePushForm = z.infer<typeof systemMessagePushSchema>;
|
|
||||||
|
|
||||||
export const appBannerSchema = z
|
export const appBannerSchema = z
|
||||||
.object({
|
.object({
|
||||||
bannerType: z.enum(["h5", "app"]),
|
bannerType: z.enum(["h5", "app"]),
|
||||||
|
|||||||
@ -62,11 +62,10 @@ test("host org list APIs use generated admin paths and filters", async () => {
|
|||||||
region_id: 7,
|
region_id: 7,
|
||||||
status: "active",
|
status: "active",
|
||||||
});
|
});
|
||||||
await createManager({ canBlockUser: false, canGrantBadge: false, canGrantVip: true, contact: "+63", targetUserId: "1003" });
|
await createManager({ canBlockUser: false, canGrantBadge: false, contact: "+63", targetUserId: "1003" });
|
||||||
await updateManager("1003", {
|
await updateManager("1003", {
|
||||||
canBlockUser: false,
|
canBlockUser: false,
|
||||||
canGrantVehicle: true,
|
canGrantVehicle: true,
|
||||||
canGrantVip: false,
|
|
||||||
canTransferUserCountry: false,
|
canTransferUserCountry: false,
|
||||||
contact: "+63",
|
contact: "+63",
|
||||||
status: "disabled",
|
status: "disabled",
|
||||||
@ -148,7 +147,6 @@ test("host org list APIs use generated admin paths and filters", async () => {
|
|||||||
expect(JSON.parse(String(managerCreateInit?.body))).toEqual({
|
expect(JSON.parse(String(managerCreateInit?.body))).toEqual({
|
||||||
canBlockUser: false,
|
canBlockUser: false,
|
||||||
canGrantBadge: false,
|
canGrantBadge: false,
|
||||||
canGrantVip: true,
|
|
||||||
contact: "+63",
|
contact: "+63",
|
||||||
targetUserId: "1003",
|
targetUserId: "1003",
|
||||||
});
|
});
|
||||||
@ -157,7 +155,6 @@ test("host org list APIs use generated admin paths and filters", async () => {
|
|||||||
expect(JSON.parse(String(managerUpdateInit?.body))).toEqual({
|
expect(JSON.parse(String(managerUpdateInit?.body))).toEqual({
|
||||||
canBlockUser: false,
|
canBlockUser: false,
|
||||||
canGrantVehicle: true,
|
canGrantVehicle: true,
|
||||||
canGrantVip: false,
|
|
||||||
canTransferUserCountry: false,
|
canTransferUserCountry: false,
|
||||||
contact: "+63",
|
contact: "+63",
|
||||||
status: "disabled",
|
status: "disabled",
|
||||||
|
|||||||
@ -191,10 +191,9 @@ export function createManager(payload: CreateManagerPayload): Promise<ManagerDto
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function updateManager(userId: EntityId, payload: UpdateManagerPayload): Promise<ManagerDto> {
|
export function updateManager(userId: EntityId, payload: UpdateManagerPayload): Promise<ManagerDto> {
|
||||||
const endpoint = API_ENDPOINTS.updateManager;
|
return apiRequest<ManagerDto, UpdateManagerPayload>(`/v1/admin/managers/${encodeURIComponent(String(userId))}`, {
|
||||||
return apiRequest<ManagerDto, UpdateManagerPayload>(apiEndpointPath(API_OPERATIONS.updateManager, { user_id: userId }), {
|
|
||||||
body: payload,
|
body: payload,
|
||||||
method: endpoint.method,
|
method: "PUT",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -14,7 +14,6 @@ export const managerPermissionFields = [
|
|||||||
"canGrantAvatarFrame",
|
"canGrantAvatarFrame",
|
||||||
"canGrantVehicle",
|
"canGrantVehicle",
|
||||||
"canGrantBadge",
|
"canGrantBadge",
|
||||||
"canGrantVip",
|
|
||||||
"canUpdateUserLevel",
|
"canUpdateUserLevel",
|
||||||
"canAddBdLeader",
|
"canAddBdLeader",
|
||||||
"canAddAdmin",
|
"canAddAdmin",
|
||||||
|
|||||||
@ -19,7 +19,6 @@ const managerPermissionOptions = [
|
|||||||
{ key: "canGrantAvatarFrame", label: "头像框" },
|
{ key: "canGrantAvatarFrame", label: "头像框" },
|
||||||
{ key: "canGrantVehicle", label: "Car" },
|
{ key: "canGrantVehicle", label: "Car" },
|
||||||
{ key: "canGrantBadge", label: "徽章" },
|
{ key: "canGrantBadge", label: "徽章" },
|
||||||
{ key: "canGrantVip", label: "VIP" },
|
|
||||||
{ key: "canUpdateUserLevel", label: "升级等级" },
|
{ key: "canUpdateUserLevel", label: "升级等级" },
|
||||||
{ key: "canAddBdLeader", label: "BD Leader" },
|
{ key: "canAddBdLeader", label: "BD Leader" },
|
||||||
{ key: "canAddAdmin", label: "Admin" },
|
{ key: "canAddAdmin", label: "Admin" },
|
||||||
|
|||||||
@ -21,8 +21,6 @@ test("manager status column uses a switch and toggles through the page hook", ()
|
|||||||
|
|
||||||
render(<HostManagersPage />);
|
render(<HostManagersPage />);
|
||||||
|
|
||||||
expect(screen.getByText("VIP")).toBeInTheDocument();
|
|
||||||
|
|
||||||
const statusSwitch = screen.getByRole("switch", { name: "停用经理" });
|
const statusSwitch = screen.getByRole("switch", { name: "停用经理" });
|
||||||
expect(statusSwitch).toBeChecked();
|
expect(statusSwitch).toBeChecked();
|
||||||
|
|
||||||
@ -57,7 +55,6 @@ function pageFixture(patch = {}) {
|
|||||||
canGrantAvatarFrame: true,
|
canGrantAvatarFrame: true,
|
||||||
canGrantBadge: true,
|
canGrantBadge: true,
|
||||||
canGrantVehicle: true,
|
canGrantVehicle: true,
|
||||||
canGrantVip: true,
|
|
||||||
canTransferUserCountry: true,
|
canTransferUserCountry: true,
|
||||||
canUpdateUserLevel: true,
|
canUpdateUserLevel: true,
|
||||||
contact: "",
|
contact: "",
|
||||||
@ -91,7 +88,6 @@ function managerFixture() {
|
|||||||
canGrantAvatarFrame: true,
|
canGrantAvatarFrame: true,
|
||||||
canGrantBadge: true,
|
canGrantBadge: true,
|
||||||
canGrantVehicle: true,
|
canGrantVehicle: true,
|
||||||
canGrantVip: true,
|
|
||||||
canTransferUserCountry: true,
|
canTransferUserCountry: true,
|
||||||
canUpdateUserLevel: true,
|
canUpdateUserLevel: true,
|
||||||
contact: "+63 910 323 3670",
|
contact: "+63 910 323 3670",
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { agencyCloseSchema, agencyJoinEnabledSchema, coinSellerStatusSchema, createManagerSchema } from "./schema";
|
import { agencyCloseSchema, agencyJoinEnabledSchema, coinSellerStatusSchema } from "./schema";
|
||||||
|
|
||||||
test("coin seller contact accepts plus sign with large internal user id", () => {
|
test("coin seller contact accepts plus sign with large internal user id", () => {
|
||||||
const result = coinSellerStatusSchema.safeParse({
|
const result = coinSellerStatusSchema.safeParse({
|
||||||
@ -40,17 +40,3 @@ test("agency action schemas preserve large agency ids as strings", () => {
|
|||||||
expect(joinResult.data.agencyId).toBe(agencyId);
|
expect(joinResult.data.agencyId).toBe(agencyId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("manager schema defaults vip grant permission on and preserves explicit false", () => {
|
|
||||||
const defaultResult = createManagerSchema.safeParse({ targetUserId: "1003" });
|
|
||||||
const disabledResult = createManagerSchema.safeParse({ canGrantVip: false, targetUserId: "1003" });
|
|
||||||
|
|
||||||
expect(defaultResult.success).toBe(true);
|
|
||||||
expect(disabledResult.success).toBe(true);
|
|
||||||
if (defaultResult.success) {
|
|
||||||
expect(defaultResult.data.canGrantVip).toBe(true);
|
|
||||||
}
|
|
||||||
if (disabledResult.success) {
|
|
||||||
expect(disabledResult.data.canGrantVip).toBe(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
@ -116,7 +116,6 @@ export const createManagerSchema = z.object({
|
|||||||
canGrantAvatarFrame: z.boolean().optional().default(true),
|
canGrantAvatarFrame: z.boolean().optional().default(true),
|
||||||
canGrantBadge: z.boolean().optional().default(true),
|
canGrantBadge: z.boolean().optional().default(true),
|
||||||
canGrantVehicle: z.boolean().optional().default(true),
|
canGrantVehicle: z.boolean().optional().default(true),
|
||||||
canGrantVip: z.boolean().optional().default(true),
|
|
||||||
canTransferUserCountry: z.boolean().optional().default(true),
|
canTransferUserCountry: z.boolean().optional().default(true),
|
||||||
canUpdateUserLevel: z.boolean().optional().default(true),
|
canUpdateUserLevel: z.boolean().optional().default(true),
|
||||||
contact: z.string().trim().max(128, "联系方式不能超过 128 个字符").optional().default(""),
|
contact: z.string().trim().max(128, "联系方式不能超过 128 个字符").optional().default(""),
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { afterEach, expect, test, vi } from "vitest";
|
|||||||
import { setAccessToken } from "@/shared/api/request";
|
import { setAccessToken } from "@/shared/api/request";
|
||||||
import {
|
import {
|
||||||
createCoinAdjustment,
|
createCoinAdjustment,
|
||||||
|
createFullServerNoticeFanout,
|
||||||
exportCoinSellerLedger,
|
exportCoinSellerLedger,
|
||||||
getGiftDiamondRatios,
|
getGiftDiamondRatios,
|
||||||
getWheelConfig,
|
getWheelConfig,
|
||||||
@ -48,6 +49,52 @@ test("gift diamond ratio APIs use operations paths", async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("full server notice fanout API uses operations path", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
command_id: "admin_notice_test",
|
||||||
|
created: true,
|
||||||
|
job_id: "mfan_test",
|
||||||
|
message_type: "system",
|
||||||
|
status: "pending",
|
||||||
|
target_scope: "all_active_users",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await createFullServerNoticeFanout({
|
||||||
|
action_type: "app_h5",
|
||||||
|
action_param: "https://example.com",
|
||||||
|
batch_size: 500,
|
||||||
|
command_id: "admin_notice_test",
|
||||||
|
message_type: "system",
|
||||||
|
summary: "今晚 23:00 维护",
|
||||||
|
target_scope: "all_active_users",
|
||||||
|
title: "维护通知",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [url, init] = vi.mocked(fetch).mock.calls[0];
|
||||||
|
|
||||||
|
expect(String(url)).toContain("/api/v1/admin/operations/full-server-notices/fanout");
|
||||||
|
expect(init?.method).toBe("POST");
|
||||||
|
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||||||
|
action_param: "https://example.com",
|
||||||
|
action_type: "app_h5",
|
||||||
|
command_id: "admin_notice_test",
|
||||||
|
message_type: "system",
|
||||||
|
target_scope: "all_active_users",
|
||||||
|
title: "维护通知",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("coin adjustment APIs use generated admin paths", async () => {
|
test("coin adjustment APIs use generated admin paths", async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
@ -62,7 +109,6 @@ test("coin adjustment APIs use generated admin paths", async () => {
|
|||||||
amount: 500,
|
amount: 500,
|
||||||
commandId: "coin-adjustment-test",
|
commandId: "coin-adjustment-test",
|
||||||
reason: "运营发放",
|
reason: "运营发放",
|
||||||
remark: "客服复核后补发",
|
|
||||||
targetUserId: "1001",
|
targetUserId: "1001",
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -78,13 +124,6 @@ test("coin adjustment APIs use generated admin paths", async () => {
|
|||||||
expect(lookupInit?.method).toBe("GET");
|
expect(lookupInit?.method).toBe("GET");
|
||||||
expect(String(createUrl)).toContain("/api/v1/admin/operations/coin-adjustments");
|
expect(String(createUrl)).toContain("/api/v1/admin/operations/coin-adjustments");
|
||||||
expect(createInit?.method).toBe("POST");
|
expect(createInit?.method).toBe("POST");
|
||||||
expect(JSON.parse(String(createInit?.body))).toMatchObject({
|
|
||||||
amount: 500,
|
|
||||||
commandId: "coin-adjustment-test",
|
|
||||||
reason: "运营发放",
|
|
||||||
remark: "客服复核后补发",
|
|
||||||
targetUserId: "1001",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("coin ledger API sends type filter", async () => {
|
test("coin ledger API sends type filter", async () => {
|
||||||
|
|||||||
@ -27,6 +27,40 @@ export interface GiftDiamondRatioResponse {
|
|||||||
items: GiftDiamondRatioItem[];
|
items: GiftDiamondRatioItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FullServerNoticeFanoutPayload {
|
||||||
|
action_param?: string;
|
||||||
|
action_type?: string;
|
||||||
|
aggregate_id?: string;
|
||||||
|
aggregate_type?: string;
|
||||||
|
batch_size?: number;
|
||||||
|
body?: string;
|
||||||
|
command_id?: string;
|
||||||
|
country?: string;
|
||||||
|
expire_at_ms?: number;
|
||||||
|
icon_url?: string;
|
||||||
|
image_url?: string;
|
||||||
|
message_type: "system" | "activity";
|
||||||
|
metadata_json?: string;
|
||||||
|
priority?: number;
|
||||||
|
producer_event_type?: string;
|
||||||
|
region_id?: number;
|
||||||
|
sent_at_ms?: number;
|
||||||
|
summary: string;
|
||||||
|
target_scope: "all_active_users" | "single_user" | "user_ids" | "region" | "country";
|
||||||
|
target_user_id?: number;
|
||||||
|
title: string;
|
||||||
|
user_ids?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FullServerNoticeFanoutResponse {
|
||||||
|
command_id: string;
|
||||||
|
created: boolean;
|
||||||
|
job_id: string;
|
||||||
|
message_type: string;
|
||||||
|
status: string;
|
||||||
|
target_scope: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type WheelRewardType = "coin" | "dress" | "gift" | "prop";
|
export type WheelRewardType = "coin" | "dress" | "gift" | "prop";
|
||||||
|
|
||||||
export interface WheelTierDto {
|
export interface WheelTierDto {
|
||||||
@ -201,6 +235,19 @@ export function updateGiftDiamondRatios(payload: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createFullServerNoticeFanout(
|
||||||
|
payload: FullServerNoticeFanoutPayload,
|
||||||
|
): Promise<FullServerNoticeFanoutResponse> {
|
||||||
|
const endpoint = API_ENDPOINTS.createFullServerNoticeFanout;
|
||||||
|
return apiRequest<FullServerNoticeFanoutResponse, FullServerNoticeFanoutPayload>(
|
||||||
|
apiEndpointPath(API_OPERATIONS.createFullServerNoticeFanout),
|
||||||
|
{
|
||||||
|
body: payload,
|
||||||
|
method: endpoint.method,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function getWheelConfig(wheelId = "default"): Promise<WheelConfigDto> {
|
export function getWheelConfig(wheelId = "default"): Promise<WheelConfigDto> {
|
||||||
const endpoint = API_ENDPOINTS.getWheelConfig;
|
const endpoint = API_ENDPOINTS.getWheelConfig;
|
||||||
return apiRequest<RawWheelConfig>(apiEndpointPath(API_OPERATIONS.getWheelConfig), {
|
return apiRequest<RawWheelConfig>(apiEndpointPath(API_OPERATIONS.getWheelConfig), {
|
||||||
@ -271,10 +318,7 @@ function normalizeWheelTier(raw: unknown): WheelTierDto {
|
|||||||
rewardCount: numberValue(item.rewardCount ?? item.reward_count),
|
rewardCount: numberValue(item.rewardCount ?? item.reward_count),
|
||||||
rewardId: stringValue(item.rewardId ?? item.reward_id),
|
rewardId: stringValue(item.rewardId ?? item.reward_id),
|
||||||
rewardType,
|
rewardType,
|
||||||
rtpValueCoins:
|
rtpValueCoins: rewardType === "prop" || rewardType === "dress" ? 0 : numberValue(item.rtpValueCoins ?? item.rtp_value_coins),
|
||||||
rewardType === "prop" || rewardType === "dress"
|
|
||||||
? 0
|
|
||||||
: numberValue(item.rtpValueCoins ?? item.rtp_value_coins),
|
|
||||||
tierId: stringValue(item.tierId ?? item.tier_id) || rewardType,
|
tierId: stringValue(item.tierId ?? item.tier_id) || rewardType,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -559,6 +559,95 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.noticeBody {
|
||||||
|
display: grid;
|
||||||
|
min-height: 0;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
|
||||||
|
gap: var(--space-5);
|
||||||
|
padding: var(--space-5);
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.noticePanel {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.noticeHeader {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.noticeHeader h2,
|
||||||
|
.noticePreview h3 {
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noticeGrid,
|
||||||
|
.noticeContentGrid {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
grid-template-columns: repeat(2, minmax(220px, 1fr));
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.noticeContentGrid {
|
||||||
|
grid-template-columns: repeat(2, minmax(260px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.noticeWideField {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noticePreview {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
min-width: 0;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: var(--space-4);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--bg-card-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.noticePreview p {
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noticePreviewMeta {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.noticePreviewMeta span {
|
||||||
|
display: inline-flex;
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: 28px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.ratioGrid,
|
.ratioGrid,
|
||||||
.ratioTable {
|
.ratioTable {
|
||||||
@ -578,7 +667,10 @@
|
|||||||
|
|
||||||
.wheelBody,
|
.wheelBody,
|
||||||
.wheelConfigGrid,
|
.wheelConfigGrid,
|
||||||
.wheelSummaryGrid {
|
.wheelSummaryGrid,
|
||||||
|
.noticeBody,
|
||||||
|
.noticeGrid,
|
||||||
|
.noticeContentGrid {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,7 +38,6 @@ const emptyForm = () => ({
|
|||||||
amount: "",
|
amount: "",
|
||||||
mode: "increase",
|
mode: "increase",
|
||||||
reason: adjustmentReasons[0],
|
reason: adjustmentReasons[0],
|
||||||
remark: "",
|
|
||||||
userKeyword: "",
|
userKeyword: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -61,12 +60,6 @@ const columns = [
|
|||||||
render: (item) => item.reason || "-",
|
render: (item) => item.reason || "-",
|
||||||
width: "minmax(170px, 0.85fr)",
|
width: "minmax(170px, 0.85fr)",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: "remark",
|
|
||||||
label: "备注",
|
|
||||||
render: (item) => item.remark || "-",
|
|
||||||
width: "minmax(220px, 1fr)",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "operator",
|
key: "operator",
|
||||||
label: "发放人",
|
label: "发放人",
|
||||||
@ -228,7 +221,7 @@ export function CoinAdjustmentPage() {
|
|||||||
<DataTable
|
<DataTable
|
||||||
columns={tableColumns}
|
columns={tableColumns}
|
||||||
items={items}
|
items={items}
|
||||||
minWidth="1230px"
|
minWidth="1010px"
|
||||||
pagination={
|
pagination={
|
||||||
total > 0
|
total > 0
|
||||||
? {
|
? {
|
||||||
@ -282,19 +275,6 @@ export function CoinAdjustmentPage() {
|
|||||||
</div>
|
</div>
|
||||||
{targetUser ? <TargetUserPreview user={targetUser} /> : null}
|
{targetUser ? <TargetUserPreview user={targetUser} /> : null}
|
||||||
</AdminFormSection>
|
</AdminFormSection>
|
||||||
<AdminFormSection title="备注">
|
|
||||||
<TextField
|
|
||||||
disabled={!abilities.canCreate || submitting}
|
|
||||||
fullWidth
|
|
||||||
label="备注"
|
|
||||||
multiline
|
|
||||||
required
|
|
||||||
rows={3}
|
|
||||||
slotProps={{ htmlInput: { maxLength: 512 } }}
|
|
||||||
value={form.remark}
|
|
||||||
onChange={(event) => changeForm({ remark: event.target.value })}
|
|
||||||
/>
|
|
||||||
</AdminFormSection>
|
|
||||||
<AdminFormSection title="金币信息">
|
<AdminFormSection title="金币信息">
|
||||||
<AdminFormFieldGrid columns="repeat(3, minmax(0, 1fr))">
|
<AdminFormFieldGrid columns="repeat(3, minmax(0, 1fr))">
|
||||||
<TextField
|
<TextField
|
||||||
@ -378,18 +358,10 @@ function buildAdjustmentPayload(form, targetUser) {
|
|||||||
if (!reason) {
|
if (!reason) {
|
||||||
throw new Error("请选择发放理由");
|
throw new Error("请选择发放理由");
|
||||||
}
|
}
|
||||||
const remark = String(form.remark || "").trim();
|
|
||||||
if (!remark) {
|
|
||||||
throw new Error("请输入备注");
|
|
||||||
}
|
|
||||||
if (remark.length > 512) {
|
|
||||||
throw new Error("备注不能超过 512 个字符");
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
amount: signedAmount,
|
amount: signedAmount,
|
||||||
commandId: `coin-adjustment-${Date.now()}`,
|
commandId: `coin-adjustment-${Date.now()}`,
|
||||||
reason,
|
reason,
|
||||||
remark,
|
|
||||||
targetUserId: String(targetUser.userId),
|
targetUserId: String(targetUser.userId),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,178 +0,0 @@
|
|||||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { MemoryRouter } from "react-router-dom";
|
|
||||||
import { afterEach, expect, test, vi } from "vitest";
|
|
||||||
import { AppProviders } from "@/app/providers.jsx";
|
|
||||||
import { queryClient } from "@/shared/query/client";
|
|
||||||
import { CoinAdjustmentPage } from "./CoinAdjustmentPage.jsx";
|
|
||||||
|
|
||||||
vi.mock("@/app/auth/AuthProvider.jsx", () => ({
|
|
||||||
AuthProvider: ({ children }) => children,
|
|
||||||
useAuth: () => ({ can: () => true }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
queryClient.clear();
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("coin adjustment does not submit when remark is blank", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
vi.stubGlobal("fetch", createCoinAdjustmentFetch());
|
|
||||||
|
|
||||||
renderPage();
|
|
||||||
await openReadyDialog(user);
|
|
||||||
await user.type(screen.getByRole("textbox", { name: "用户 ID" }), "1001");
|
|
||||||
await user.click(screen.getByRole("button", { name: "搜索" }));
|
|
||||||
await waitFor(() => expect(screen.getByText("测试用户")).toBeInTheDocument());
|
|
||||||
await user.type(screen.getByRole("textbox", { name: "备注" }), " ");
|
|
||||||
await user.type(screen.getByRole("textbox", { name: "发放数量" }), "500");
|
|
||||||
await user.click(screen.getByRole("button", { name: "确认" }));
|
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText("请输入备注")).toBeInTheDocument());
|
|
||||||
expect(coinAdjustmentPostBodies()).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("coin adjustment request body includes trimmed remark", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
vi.stubGlobal("fetch", createCoinAdjustmentFetch());
|
|
||||||
|
|
||||||
renderPage();
|
|
||||||
await openReadyDialog(user);
|
|
||||||
await user.type(screen.getByRole("textbox", { name: "用户 ID" }), "1001");
|
|
||||||
await user.click(screen.getByRole("button", { name: "搜索" }));
|
|
||||||
await waitFor(() => expect(screen.getByText("测试用户")).toBeInTheDocument());
|
|
||||||
await user.type(screen.getByRole("textbox", { name: "备注" }), " 客服复核后补发 ");
|
|
||||||
await user.type(screen.getByRole("textbox", { name: "发放数量" }), "500");
|
|
||||||
await user.click(screen.getByRole("button", { name: "确认" }));
|
|
||||||
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(coinAdjustmentPostBodies()).toEqual([
|
|
||||||
expect.objectContaining({
|
|
||||||
amount: 500,
|
|
||||||
reason: "运营发放",
|
|
||||||
remark: "客服复核后补发",
|
|
||||||
targetUserId: "1001",
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("coin adjustment does not submit when remark exceeds 512 characters", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
vi.stubGlobal("fetch", createCoinAdjustmentFetch());
|
|
||||||
|
|
||||||
renderPage();
|
|
||||||
await openReadyDialog(user);
|
|
||||||
await user.type(screen.getByRole("textbox", { name: "用户 ID" }), "1001");
|
|
||||||
await user.click(screen.getByRole("button", { name: "搜索" }));
|
|
||||||
await waitFor(() => expect(screen.getByText("测试用户")).toBeInTheDocument());
|
|
||||||
fireEvent.change(screen.getByRole("textbox", { name: "备注" }), { target: { value: "补".repeat(513) } });
|
|
||||||
await user.type(screen.getByRole("textbox", { name: "发放数量" }), "500");
|
|
||||||
await user.click(screen.getByRole("button", { name: "确认" }));
|
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText("备注不能超过 512 个字符")).toBeInTheDocument());
|
|
||||||
expect(coinAdjustmentPostBodies()).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("coin adjustment list displays remark and falls back for empty remark", async () => {
|
|
||||||
vi.stubGlobal(
|
|
||||||
"fetch",
|
|
||||||
createCoinAdjustmentFetch({
|
|
||||||
items: [
|
|
||||||
coinAdjustmentFixture({ entryId: 1, remark: "客服复核后补发", transactionId: "tx-remark" }),
|
|
||||||
coinAdjustmentFixture({ entryId: 2, remark: "", transactionId: "tx-empty" }),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
renderPage();
|
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText("客服复核后补发")).toBeInTheDocument());
|
|
||||||
expect(screen.getByText("备注")).toBeInTheDocument();
|
|
||||||
const rows = document.querySelectorAll(".admin-row:not(.admin-row--head)");
|
|
||||||
expect(within(rows[1]).getByText("-")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
function renderPage() {
|
|
||||||
return render(
|
|
||||||
<AppProviders>
|
|
||||||
<MemoryRouter>
|
|
||||||
<CoinAdjustmentPage />
|
|
||||||
</MemoryRouter>
|
|
||||||
</AppProviders>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openReadyDialog(user) {
|
|
||||||
await waitFor(() => expect(screen.getByText("当前无数据")).toBeInTheDocument());
|
|
||||||
await user.click(screen.getByRole("button", { name: "金币增减" }));
|
|
||||||
await waitFor(() => expect(screen.getByRole("dialog", { name: "金币增减" })).toBeInTheDocument());
|
|
||||||
}
|
|
||||||
|
|
||||||
function coinAdjustmentPostBodies() {
|
|
||||||
return vi
|
|
||||||
.mocked(fetch)
|
|
||||||
.mock.calls.filter(([url, init]) => String(url).includes("/api/v1/admin/operations/coin-adjustments") && init?.method === "POST")
|
|
||||||
.map(([, init]) => JSON.parse(String(init?.body)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function createCoinAdjustmentFetch({ items = [] } = {}) {
|
|
||||||
return vi.fn(async (input, init = {}) => {
|
|
||||||
const url = String(input);
|
|
||||||
const method = init.method || "GET";
|
|
||||||
|
|
||||||
if (url.includes("/api/v1/admin/operations/coin-adjustments/target")) {
|
|
||||||
return jsonResponse({
|
|
||||||
code: 0,
|
|
||||||
data: {
|
|
||||||
displayUserId: "1001",
|
|
||||||
userId: "1001",
|
|
||||||
username: "测试用户",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (url.includes("/api/v1/admin/operations/coin-adjustments") && method === "POST") {
|
|
||||||
return jsonResponse({
|
|
||||||
code: 0,
|
|
||||||
data: {
|
|
||||||
amount: 500,
|
|
||||||
availableDelta: 500,
|
|
||||||
balanceAfter: 1500,
|
|
||||||
transactionId: "tx-created",
|
|
||||||
user: { displayUserId: "1001", userId: "1001", username: "测试用户" },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (url.includes("/api/v1/admin/operations/coin-adjustments")) {
|
|
||||||
return jsonResponse({ code: 0, data: { items, page: 1, pageSize: 50, total: items.length } });
|
|
||||||
}
|
|
||||||
|
|
||||||
return jsonResponse({ code: 0, data: {} });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function coinAdjustmentFixture(patch = {}) {
|
|
||||||
return {
|
|
||||||
amount: 500,
|
|
||||||
availableAfter: 1500,
|
|
||||||
availableDelta: 500,
|
|
||||||
createdAtMs: 1762300800000,
|
|
||||||
direction: "income",
|
|
||||||
entryId: 1,
|
|
||||||
operator: { name: "运营" },
|
|
||||||
reason: "运营发放",
|
|
||||||
remark: "",
|
|
||||||
transactionId: "tx-1",
|
|
||||||
user: { displayUserId: "1001", userId: "1001", username: "测试用户" },
|
|
||||||
userId: "1001",
|
|
||||||
...patch,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function jsonResponse(body) {
|
|
||||||
return new Response(JSON.stringify(body), {
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
status: 200,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
398
src/features/operations/pages/FullServerNoticePage.jsx
Normal file
398
src/features/operations/pages/FullServerNoticePage.jsx
Normal file
@ -0,0 +1,398 @@
|
|||||||
|
import CampaignOutlined from "@mui/icons-material/CampaignOutlined";
|
||||||
|
import SendOutlined from "@mui/icons-material/SendOutlined";
|
||||||
|
import Alert from "@mui/material/Alert";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { createFullServerNoticeFanout } from "@/features/operations/api";
|
||||||
|
import { useFullServerNoticeAbilities } from "@/features/operations/permissions.js";
|
||||||
|
import styles from "@/features/operations/operations.module.css";
|
||||||
|
import { AdminListPage, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx";
|
||||||
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
|
|
||||||
|
const messageTypes = [
|
||||||
|
{ label: "系统消息", value: "system" },
|
||||||
|
{ label: "活动消息", value: "activity" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const targetScopes = [
|
||||||
|
{ label: "全量活跃用户", value: "all_active_users" },
|
||||||
|
{ label: "指定用户", value: "single_user" },
|
||||||
|
{ label: "用户列表", value: "user_ids" },
|
||||||
|
{ label: "指定区域", value: "region" },
|
||||||
|
{ label: "指定国家", value: "country" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const actionTypes = [
|
||||||
|
{ label: "无跳转", value: "" },
|
||||||
|
{ label: "活动详情", value: "activity_detail" },
|
||||||
|
{ label: "H5 页面", value: "app_h5" },
|
||||||
|
{ label: "房间详情", value: "room_detail" },
|
||||||
|
{ label: "用户主页", value: "user_profile" },
|
||||||
|
{ label: "提现详情", value: "wallet_withdraw_detail" },
|
||||||
|
{ label: "主播申请", value: "host_application_detail" },
|
||||||
|
{ label: "币商转账", value: "coin_seller_transfer_detail" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const initialForm = {
|
||||||
|
actionParam: "",
|
||||||
|
actionType: "",
|
||||||
|
aggregateId: "",
|
||||||
|
aggregateType: "admin_notice",
|
||||||
|
batchSize: "500",
|
||||||
|
body: "",
|
||||||
|
commandId: "",
|
||||||
|
country: "",
|
||||||
|
expireAt: "",
|
||||||
|
messageType: "system",
|
||||||
|
metadataJson: "",
|
||||||
|
priority: "0",
|
||||||
|
producerEventType: "admin_full_server_notice",
|
||||||
|
regionId: "",
|
||||||
|
summary: "",
|
||||||
|
targetScope: "all_active_users",
|
||||||
|
targetUserId: "",
|
||||||
|
title: "",
|
||||||
|
userIds: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FullServerNoticePage() {
|
||||||
|
const abilities = useFullServerNoticeAbilities();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const [form, setForm] = useState(initialForm);
|
||||||
|
const [result, setResult] = useState(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const targetLabel = useMemo(
|
||||||
|
() => targetScopes.find((item) => item.value === form.targetScope)?.label || form.targetScope,
|
||||||
|
[form.targetScope],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateField = (field) => (event) => {
|
||||||
|
setForm((current) => ({ ...current, [field]: event.target.value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
const payload = buildPayload(form);
|
||||||
|
if (!payload.ok) {
|
||||||
|
showToast(payload.error, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const next = await createFullServerNoticeFanout(payload.value);
|
||||||
|
setResult(next);
|
||||||
|
showToast(next.created ? "全服通知任务已创建" : "已返回现有通知任务", "success");
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "创建全服通知失败", "error");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminListPage>
|
||||||
|
<AdminListToolbar
|
||||||
|
actions={
|
||||||
|
abilities.canSend ? (
|
||||||
|
<Button disabled={saving} startIcon={<SendOutlined />} variant="primary" onClick={submit}>
|
||||||
|
发送
|
||||||
|
</Button>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
filters={null}
|
||||||
|
/>
|
||||||
|
<div className={styles.noticeBody}>
|
||||||
|
<section className={styles.noticePanel}>
|
||||||
|
<div className={styles.noticeHeader}>
|
||||||
|
<CampaignOutlined fontSize="small" />
|
||||||
|
<h2>全服通知</h2>
|
||||||
|
</div>
|
||||||
|
<div className={styles.noticeGrid}>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="消息类型"
|
||||||
|
size="small"
|
||||||
|
value={form.messageType}
|
||||||
|
onChange={updateField("messageType")}
|
||||||
|
>
|
||||||
|
{messageTypes.map((item) => (
|
||||||
|
<MenuItem key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="发送范围"
|
||||||
|
size="small"
|
||||||
|
value={form.targetScope}
|
||||||
|
onChange={updateField("targetScope")}
|
||||||
|
>
|
||||||
|
{targetScopes.map((item) => (
|
||||||
|
<MenuItem key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
{form.targetScope === "single_user" ? (
|
||||||
|
<TextField
|
||||||
|
label="用户 ID"
|
||||||
|
size="small"
|
||||||
|
value={form.targetUserId}
|
||||||
|
onChange={updateField("targetUserId")}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{form.targetScope === "user_ids" ? (
|
||||||
|
<TextField
|
||||||
|
label="用户 ID 列表"
|
||||||
|
size="small"
|
||||||
|
value={form.userIds}
|
||||||
|
onChange={updateField("userIds")}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{form.targetScope === "region" ? (
|
||||||
|
<TextField
|
||||||
|
label="区域 ID"
|
||||||
|
size="small"
|
||||||
|
value={form.regionId}
|
||||||
|
onChange={updateField("regionId")}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{form.targetScope === "country" ? (
|
||||||
|
<TextField
|
||||||
|
label="国家"
|
||||||
|
size="small"
|
||||||
|
value={form.country}
|
||||||
|
onChange={updateField("country")}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<TextField
|
||||||
|
label="幂等命令 ID"
|
||||||
|
size="small"
|
||||||
|
value={form.commandId}
|
||||||
|
onChange={updateField("commandId")}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="批量大小"
|
||||||
|
size="small"
|
||||||
|
type="number"
|
||||||
|
value={form.batchSize}
|
||||||
|
onChange={updateField("batchSize")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.noticeContentGrid}>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
label="标题"
|
||||||
|
size="small"
|
||||||
|
value={form.title}
|
||||||
|
onChange={updateField("title")}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
label="摘要"
|
||||||
|
size="small"
|
||||||
|
value={form.summary}
|
||||||
|
onChange={updateField("summary")}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
minRows={4}
|
||||||
|
className={styles.noticeWideField}
|
||||||
|
label="正文"
|
||||||
|
value={form.body}
|
||||||
|
onChange={updateField("body")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.noticeGrid}>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="跳转类型"
|
||||||
|
size="small"
|
||||||
|
value={form.actionType}
|
||||||
|
onChange={updateField("actionType")}
|
||||||
|
>
|
||||||
|
{actionTypes.map((item) => (
|
||||||
|
<MenuItem key={item.value || "none"} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
label="跳转参数"
|
||||||
|
size="small"
|
||||||
|
value={form.actionParam}
|
||||||
|
onChange={updateField("actionParam")}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="关联类型"
|
||||||
|
size="small"
|
||||||
|
value={form.aggregateType}
|
||||||
|
onChange={updateField("aggregateType")}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="关联 ID"
|
||||||
|
size="small"
|
||||||
|
value={form.aggregateId}
|
||||||
|
onChange={updateField("aggregateId")}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="事件类型"
|
||||||
|
size="small"
|
||||||
|
value={form.producerEventType}
|
||||||
|
onChange={updateField("producerEventType")}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="优先级"
|
||||||
|
size="small"
|
||||||
|
type="number"
|
||||||
|
value={form.priority}
|
||||||
|
onChange={updateField("priority")}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="过期时间"
|
||||||
|
size="small"
|
||||||
|
type="datetime-local"
|
||||||
|
value={form.expireAt}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
onChange={updateField("expireAt")}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
minRows={3}
|
||||||
|
label="Metadata JSON"
|
||||||
|
value={form.metadataJson}
|
||||||
|
onChange={updateField("metadataJson")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className={styles.noticePreview}>
|
||||||
|
<h3>{form.title || "标题"}</h3>
|
||||||
|
<p>{form.summary || "摘要"}</p>
|
||||||
|
<div className={styles.noticePreviewMeta}>
|
||||||
|
<span>{messageTypes.find((item) => item.value === form.messageType)?.label}</span>
|
||||||
|
<span>{targetLabel}</span>
|
||||||
|
<span>{form.actionType || "无跳转"}</span>
|
||||||
|
</div>
|
||||||
|
{result ? (
|
||||||
|
<Alert severity={result.created ? "success" : "info"}>
|
||||||
|
{result.status} · {result.job_id} · {result.command_id}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</AdminListPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPayload(form) {
|
||||||
|
const title = form.title.trim();
|
||||||
|
const summary = form.summary.trim();
|
||||||
|
if (!title || !summary) {
|
||||||
|
return { error: "标题和摘要不能为空", ok: false };
|
||||||
|
}
|
||||||
|
const batchSize = parsePositiveNumber(form.batchSize, "批量大小");
|
||||||
|
if (!batchSize.ok) {
|
||||||
|
return batchSize;
|
||||||
|
}
|
||||||
|
const priority = parseOptionalNumber(form.priority, "优先级");
|
||||||
|
if (!priority.ok) {
|
||||||
|
return priority;
|
||||||
|
}
|
||||||
|
const expireAtMS = datetimeLocalToMS(form.expireAt);
|
||||||
|
if (!expireAtMS.ok) {
|
||||||
|
return expireAtMS;
|
||||||
|
}
|
||||||
|
const target = buildTargetPayload(form);
|
||||||
|
if (!target.ok) {
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
const metadataJson = form.metadataJson.trim();
|
||||||
|
if (metadataJson) {
|
||||||
|
try {
|
||||||
|
JSON.parse(metadataJson);
|
||||||
|
} catch {
|
||||||
|
return { error: "Metadata JSON 不正确", ok: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
value: {
|
||||||
|
...target.value,
|
||||||
|
action_param: form.actionParam.trim() || undefined,
|
||||||
|
action_type: form.actionType,
|
||||||
|
aggregate_id: form.aggregateId.trim() || undefined,
|
||||||
|
aggregate_type: form.aggregateType.trim() || undefined,
|
||||||
|
batch_size: batchSize.value,
|
||||||
|
body: form.body.trim() || undefined,
|
||||||
|
command_id: form.commandId.trim() || undefined,
|
||||||
|
expire_at_ms: expireAtMS.value || undefined,
|
||||||
|
message_type: form.messageType,
|
||||||
|
metadata_json: metadataJson || undefined,
|
||||||
|
priority: priority.value,
|
||||||
|
producer_event_type: form.producerEventType.trim() || undefined,
|
||||||
|
summary,
|
||||||
|
target_scope: form.targetScope,
|
||||||
|
title,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTargetPayload(form) {
|
||||||
|
if (form.targetScope === "single_user") {
|
||||||
|
const targetUserID = parsePositiveNumber(form.targetUserId, "用户 ID");
|
||||||
|
return targetUserID.ok ? { ok: true, value: { target_user_id: targetUserID.value } } : targetUserID;
|
||||||
|
}
|
||||||
|
if (form.targetScope === "user_ids") {
|
||||||
|
const ids = form.userIds
|
||||||
|
.split(/[\s,,]+/)
|
||||||
|
.map((item) => Number(item.trim()))
|
||||||
|
.filter((value) => Number.isInteger(value) && value > 0);
|
||||||
|
if (!ids.length) {
|
||||||
|
return { error: "用户 ID 列表不能为空", ok: false };
|
||||||
|
}
|
||||||
|
return { ok: true, value: { user_ids: Array.from(new Set(ids)) } };
|
||||||
|
}
|
||||||
|
if (form.targetScope === "region") {
|
||||||
|
const regionID = parsePositiveNumber(form.regionId, "区域 ID");
|
||||||
|
return regionID.ok ? { ok: true, value: { region_id: regionID.value } } : regionID;
|
||||||
|
}
|
||||||
|
if (form.targetScope === "country") {
|
||||||
|
const country = form.country.trim();
|
||||||
|
return country ? { ok: true, value: { country } } : { error: "国家不能为空", ok: false };
|
||||||
|
}
|
||||||
|
return { ok: true, value: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePositiveNumber(value, label) {
|
||||||
|
const number = Number(String(value || "").trim());
|
||||||
|
if (!Number.isInteger(number) || number <= 0) {
|
||||||
|
return { error: `${label} 必须是正整数`, ok: false };
|
||||||
|
}
|
||||||
|
return { ok: true, value: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionalNumber(value, label) {
|
||||||
|
const raw = String(value ?? "").trim();
|
||||||
|
if (!raw) {
|
||||||
|
return { ok: true, value: 0 };
|
||||||
|
}
|
||||||
|
const number = Number(raw);
|
||||||
|
if (!Number.isInteger(number)) {
|
||||||
|
return { error: `${label} 必须是整数`, ok: false };
|
||||||
|
}
|
||||||
|
return { ok: true, value: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
function datetimeLocalToMS(value) {
|
||||||
|
if (!value) {
|
||||||
|
return { ok: true, value: 0 };
|
||||||
|
}
|
||||||
|
const ms = new Date(value).getTime();
|
||||||
|
if (!Number.isFinite(ms)) {
|
||||||
|
return { error: "过期时间不正确", ok: false };
|
||||||
|
}
|
||||||
|
return { ok: true, value: ms };
|
||||||
|
}
|
||||||
@ -19,6 +19,15 @@ export function useGiftDiamondAbilities() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useFullServerNoticeAbilities() {
|
||||||
|
const { can } = useAuth();
|
||||||
|
|
||||||
|
return {
|
||||||
|
canSend: can(PERMISSIONS.fullServerNoticeSend),
|
||||||
|
canView: can(PERMISSIONS.fullServerNoticeView),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function useWheelAbilities() {
|
export function useWheelAbilities() {
|
||||||
const { can } = useAuth();
|
const { can } = useAuth();
|
||||||
|
|
||||||
|
|||||||
@ -49,4 +49,12 @@ export const operationsRoutes = [
|
|||||||
path: "/operations/gift-diamonds",
|
path: "/operations/gift-diamonds",
|
||||||
permission: PERMISSIONS.giftDiamondView,
|
permission: PERMISSIONS.giftDiamondView,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "全服通知",
|
||||||
|
loader: () => import("./pages/FullServerNoticePage.jsx").then((module) => module.FullServerNoticePage),
|
||||||
|
menuCode: MENU_CODES.operationFullServerNotice,
|
||||||
|
pageKey: "operation-full-server-notice",
|
||||||
|
path: "/operations/full-server-notices",
|
||||||
|
permission: PERMISSIONS.fullServerNoticeView,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -153,7 +153,7 @@ export function useThirdPartyPaymentPage() {
|
|||||||
|
|
||||||
const submitSyncRates = async (event) => {
|
const submitSyncRates = async (event) => {
|
||||||
event?.preventDefault();
|
event?.preventDefault();
|
||||||
// 上浮比例只表达运营策略,不在前端计算最终汇率;服务端会拉实时源、套用比例并按高汇率整数规则向上取整,避免旧页面数据参与写库。
|
// 上浮比例只表达运营策略,不在前端计算最终汇率;服务端会拉实时源、套用比例并按 1 位小数向上取整,避免旧页面数据参与写库。
|
||||||
if (!abilities.canUpdateThirdParty) {
|
if (!abilities.canUpdateThirdParty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
import { afterEach, expect, test, vi } from "vitest";
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
import { setAccessToken } from "@/shared/api/request";
|
import { setAccessToken } from "@/shared/api/request";
|
||||||
import {
|
import {
|
||||||
batchCreateGifts,
|
|
||||||
batchDeleteResources,
|
|
||||||
createEmojiPack,
|
createEmojiPack,
|
||||||
createGift,
|
createGift,
|
||||||
createResource,
|
createResource,
|
||||||
@ -12,7 +10,6 @@ import {
|
|||||||
disableResource,
|
disableResource,
|
||||||
disableResourceGroup,
|
disableResourceGroup,
|
||||||
deleteGift,
|
deleteGift,
|
||||||
deleteResource,
|
|
||||||
enableResourceShopItem,
|
enableResourceShopItem,
|
||||||
enableGift,
|
enableGift,
|
||||||
enableResource,
|
enableResource,
|
||||||
@ -86,8 +83,6 @@ test("resource APIs use generated admin paths", async () => {
|
|||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
status: "active",
|
status: "active",
|
||||||
});
|
});
|
||||||
await deleteResource(11);
|
|
||||||
await batchDeleteResources([11, 12]);
|
|
||||||
await listGifts({ page: 1, page_size: 10, region_id: 1001 });
|
await listGifts({ page: 1, page_size: 10, region_id: 1001 });
|
||||||
await createGift({
|
await createGift({
|
||||||
chargeAssetType: "COIN",
|
chargeAssetType: "COIN",
|
||||||
@ -107,26 +102,6 @@ test("resource APIs use generated admin paths", async () => {
|
|||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
status: "active",
|
status: "active",
|
||||||
});
|
});
|
||||||
await batchCreateGifts([
|
|
||||||
{
|
|
||||||
chargeAssetType: "COIN",
|
|
||||||
coinPrice: 20,
|
|
||||||
cpRelationType: "",
|
|
||||||
effectiveAtMs: 1777766400000,
|
|
||||||
effectiveFromMs: 0,
|
|
||||||
effectiveToMs: 0,
|
|
||||||
effectTypes: ["animation"],
|
|
||||||
giftId: "ring",
|
|
||||||
giftTypeCode: "normal",
|
|
||||||
name: "Ring",
|
|
||||||
presentationJson: "{}",
|
|
||||||
priceVersion: "default",
|
|
||||||
regionIds: [0],
|
|
||||||
resourceId: 12,
|
|
||||||
sortOrder: 1,
|
|
||||||
status: "active",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
await enableResource(11);
|
await enableResource(11);
|
||||||
await disableResource(11);
|
await disableResource(11);
|
||||||
await createResourceGroup({
|
await createResourceGroup({
|
||||||
@ -202,26 +177,23 @@ test("resource APIs use generated admin paths", async () => {
|
|||||||
const [listUrl, listInit] = vi.mocked(fetch).mock.calls[0];
|
const [listUrl, listInit] = vi.mocked(fetch).mock.calls[0];
|
||||||
const [createUrl, createInit] = vi.mocked(fetch).mock.calls[1];
|
const [createUrl, createInit] = vi.mocked(fetch).mock.calls[1];
|
||||||
const [updateUrl, updateInit] = vi.mocked(fetch).mock.calls[2];
|
const [updateUrl, updateInit] = vi.mocked(fetch).mock.calls[2];
|
||||||
const [deleteResourceUrl, deleteResourceInit] = vi.mocked(fetch).mock.calls[3];
|
const [giftListUrl, giftListInit] = vi.mocked(fetch).mock.calls[3];
|
||||||
const [batchDeleteResourceUrl, batchDeleteResourceInit] = vi.mocked(fetch).mock.calls[4];
|
const [createGiftUrl, createGiftInit] = vi.mocked(fetch).mock.calls[4];
|
||||||
const [giftListUrl, giftListInit] = vi.mocked(fetch).mock.calls[5];
|
const [enableUrl, enableInit] = vi.mocked(fetch).mock.calls[5];
|
||||||
const [createGiftUrl, createGiftInit] = vi.mocked(fetch).mock.calls[6];
|
const [disableUrl, disableInit] = vi.mocked(fetch).mock.calls[6];
|
||||||
const [batchCreateGiftUrl, batchCreateGiftInit] = vi.mocked(fetch).mock.calls[7];
|
const [createGroupUrl, createGroupInit] = vi.mocked(fetch).mock.calls[7];
|
||||||
const [enableUrl, enableInit] = vi.mocked(fetch).mock.calls[8];
|
const [updateGroupUrl, updateGroupInit] = vi.mocked(fetch).mock.calls[8];
|
||||||
const [disableUrl, disableInit] = vi.mocked(fetch).mock.calls[9];
|
const [enableGroupUrl, enableGroupInit] = vi.mocked(fetch).mock.calls[9];
|
||||||
const [createGroupUrl, createGroupInit] = vi.mocked(fetch).mock.calls[10];
|
const [disableGroupUrl, disableGroupInit] = vi.mocked(fetch).mock.calls[10];
|
||||||
const [updateGroupUrl, updateGroupInit] = vi.mocked(fetch).mock.calls[11];
|
const [updateGiftUrl, updateGiftInit] = vi.mocked(fetch).mock.calls[11];
|
||||||
const [enableGroupUrl, enableGroupInit] = vi.mocked(fetch).mock.calls[12];
|
const [enableGiftUrl, enableGiftInit] = vi.mocked(fetch).mock.calls[12];
|
||||||
const [disableGroupUrl, disableGroupInit] = vi.mocked(fetch).mock.calls[13];
|
const [disableGiftUrl, disableGiftInit] = vi.mocked(fetch).mock.calls[13];
|
||||||
const [updateGiftUrl, updateGiftInit] = vi.mocked(fetch).mock.calls[14];
|
const [deleteGiftUrl, deleteGiftInit] = vi.mocked(fetch).mock.calls[14];
|
||||||
const [enableGiftUrl, enableGiftInit] = vi.mocked(fetch).mock.calls[15];
|
const [grantListUrl, grantListInit] = vi.mocked(fetch).mock.calls[15];
|
||||||
const [disableGiftUrl, disableGiftInit] = vi.mocked(fetch).mock.calls[16];
|
const [grantTargetUrl, grantTargetInit] = vi.mocked(fetch).mock.calls[16];
|
||||||
const [deleteGiftUrl, deleteGiftInit] = vi.mocked(fetch).mock.calls[17];
|
const [grantResourceUrl, grantResourceInit] = vi.mocked(fetch).mock.calls[17];
|
||||||
const [grantListUrl, grantListInit] = vi.mocked(fetch).mock.calls[18];
|
const [grantGroupUrl, grantGroupInit] = vi.mocked(fetch).mock.calls[18];
|
||||||
const [grantTargetUrl, grantTargetInit] = vi.mocked(fetch).mock.calls[19];
|
const [revokeGrantUrl, revokeGrantInit] = vi.mocked(fetch).mock.calls[19];
|
||||||
const [grantResourceUrl, grantResourceInit] = vi.mocked(fetch).mock.calls[20];
|
|
||||||
const [grantGroupUrl, grantGroupInit] = vi.mocked(fetch).mock.calls[21];
|
|
||||||
const [revokeGrantUrl, revokeGrantInit] = vi.mocked(fetch).mock.calls[22];
|
|
||||||
|
|
||||||
expect(String(listUrl)).toContain("/api/v1/admin/resources?");
|
expect(String(listUrl)).toContain("/api/v1/admin/resources?");
|
||||||
expect(String(listUrl)).toContain("resource_type=gift");
|
expect(String(listUrl)).toContain("resource_type=gift");
|
||||||
@ -245,20 +217,12 @@ test("resource APIs use generated admin paths", async () => {
|
|||||||
badgeKind: "normal",
|
badgeKind: "normal",
|
||||||
metadataJson: '{"profile_card_layout":{"content_top":120,"content_height":1500}}',
|
metadataJson: '{"profile_card_layout":{"content_top":120,"content_height":1500}}',
|
||||||
});
|
});
|
||||||
expect(String(deleteResourceUrl)).toContain("/api/v1/admin/resources/11");
|
|
||||||
expect(deleteResourceInit?.method).toBe("DELETE");
|
|
||||||
expect(String(batchDeleteResourceUrl)).toContain("/api/v1/admin/resources/batch-delete");
|
|
||||||
expect(batchDeleteResourceInit?.method).toBe("POST");
|
|
||||||
expect(JSON.parse(String(batchDeleteResourceInit?.body))).toEqual({ resourceIds: [11, 12] });
|
|
||||||
expect(String(giftListUrl)).toContain("/api/v1/admin/gifts?");
|
expect(String(giftListUrl)).toContain("/api/v1/admin/gifts?");
|
||||||
expect(String(giftListUrl)).toContain("region_id=1001");
|
expect(String(giftListUrl)).toContain("region_id=1001");
|
||||||
expect(giftListInit?.method).toBe("GET");
|
expect(giftListInit?.method).toBe("GET");
|
||||||
expect(String(createGiftUrl)).toContain("/api/v1/admin/gifts");
|
expect(String(createGiftUrl)).toContain("/api/v1/admin/gifts");
|
||||||
expect(createGiftInit?.method).toBe("POST");
|
expect(createGiftInit?.method).toBe("POST");
|
||||||
expect(JSON.parse(String(createGiftInit?.body))).toMatchObject({ giftId: "rose", resourceId: 11 });
|
expect(JSON.parse(String(createGiftInit?.body))).toMatchObject({ giftId: "rose", resourceId: 11 });
|
||||||
expect(String(batchCreateGiftUrl)).toContain("/api/v1/admin/gifts/batch");
|
|
||||||
expect(batchCreateGiftInit?.method).toBe("POST");
|
|
||||||
expect(JSON.parse(String(batchCreateGiftInit?.body))).toMatchObject({ items: [{ giftId: "ring", resourceId: 12 }] });
|
|
||||||
expect(String(enableUrl)).toContain("/api/v1/admin/resources/11/enable");
|
expect(String(enableUrl)).toContain("/api/v1/admin/resources/11/enable");
|
||||||
expect(enableInit?.method).toBe("POST");
|
expect(enableInit?.method).toBe("POST");
|
||||||
expect(String(disableUrl)).toContain("/api/v1/admin/resources/11/disable");
|
expect(String(disableUrl)).toContain("/api/v1/admin/resources/11/disable");
|
||||||
|
|||||||
@ -224,10 +224,6 @@ export interface GiftPayload {
|
|||||||
regionIds: number[];
|
regionIds: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BatchCreateGiftsPayload {
|
|
||||||
items: GiftPayload[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GiftTypePayload {
|
export interface GiftTypePayload {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
tabName: string;
|
tabName: string;
|
||||||
@ -341,10 +337,6 @@ export interface ResourceShopItemsPayload {
|
|||||||
items: ResourceShopItemPayload[];
|
items: ResourceShopItemPayload[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BatchDeleteResourcesPayload {
|
|
||||||
resourceIds: EntityId[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listResources(query: PageQuery = {}): Promise<ApiPage<ResourceDto>> {
|
export function listResources(query: PageQuery = {}): Promise<ApiPage<ResourceDto>> {
|
||||||
const endpoint = API_ENDPOINTS.listResources;
|
const endpoint = API_ENDPOINTS.listResources;
|
||||||
return apiRequest<ApiPage<ResourceDto>>(apiEndpointPath(API_OPERATIONS.listResources), {
|
return apiRequest<ApiPage<ResourceDto>>(apiEndpointPath(API_OPERATIONS.listResources), {
|
||||||
@ -372,24 +364,6 @@ export function updateResource(resourceId: EntityId, payload: ResourcePayload):
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteResource(resourceId: EntityId): Promise<ResourceDto> {
|
|
||||||
const endpoint = API_ENDPOINTS.deleteResource;
|
|
||||||
return apiRequest<ResourceDto>(apiEndpointPath(API_OPERATIONS.deleteResource, { resource_id: resourceId }), {
|
|
||||||
method: endpoint.method,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function batchDeleteResources(resourceIds: EntityId[]): Promise<ResourceDto[]> {
|
|
||||||
const endpoint = API_ENDPOINTS.batchDeleteResources;
|
|
||||||
return apiRequest<ResourceDto[], BatchDeleteResourcesPayload>(
|
|
||||||
apiEndpointPath(API_OPERATIONS.batchDeleteResources),
|
|
||||||
{
|
|
||||||
body: { resourceIds },
|
|
||||||
method: endpoint.method,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateResourceMp4Layouts(items: ResourceMp4LayoutUpdatePayload[]): Promise<ResourceDto[]> {
|
export function updateResourceMp4Layouts(items: ResourceMp4LayoutUpdatePayload[]): Promise<ResourceDto[]> {
|
||||||
return apiRequest<ResourceDto[], ResourceMp4LayoutsPayload>("/v1/admin/resources/mp4-layouts/batch", {
|
return apiRequest<ResourceDto[], ResourceMp4LayoutsPayload>("/v1/admin/resources/mp4-layouts/batch", {
|
||||||
body: { items },
|
body: { items },
|
||||||
@ -567,14 +541,6 @@ export function createGift(payload: GiftPayload): Promise<GiftDto> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function batchCreateGifts(payloads: GiftPayload[]): Promise<GiftDto[]> {
|
|
||||||
const endpoint = API_ENDPOINTS.batchCreateGifts;
|
|
||||||
return apiRequest<GiftDto[], BatchCreateGiftsPayload>(apiEndpointPath(API_OPERATIONS.batchCreateGifts), {
|
|
||||||
body: { items: payloads },
|
|
||||||
method: endpoint.method,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateGift(giftId: string, payload: GiftPayload): Promise<GiftDto> {
|
export function updateGift(giftId: string, payload: GiftPayload): Promise<GiftDto> {
|
||||||
const endpoint = API_ENDPOINTS.updateGift;
|
const endpoint = API_ENDPOINTS.updateGift;
|
||||||
return apiRequest<GiftDto, GiftPayload>(apiEndpointPath(API_OPERATIONS.updateGift, { gift_id: giftId }), {
|
return apiRequest<GiftDto, GiftPayload>(apiEndpointPath(API_OPERATIONS.updateGift, { gift_id: giftId }), {
|
||||||
|
|||||||
@ -21,7 +21,6 @@ const drawerZIndex = 1600;
|
|||||||
export function GiftResourceSelectField({
|
export function GiftResourceSelectField({
|
||||||
disabled = false,
|
disabled = false,
|
||||||
drawerTitle = "选择资源",
|
drawerTitle = "选择资源",
|
||||||
excludedResourceIds = [],
|
|
||||||
giftTypes = [],
|
giftTypes = [],
|
||||||
gifts = [],
|
gifts = [],
|
||||||
label = "资源",
|
label = "资源",
|
||||||
@ -37,8 +36,6 @@ export function GiftResourceSelectField({
|
|||||||
const [activeType, setActiveType] = useState("");
|
const [activeType, setActiveType] = useState("");
|
||||||
const selectedIds = useMemo(() => selectedResourceIds.map((id) => String(id)), [selectedResourceIds]);
|
const selectedIds = useMemo(() => selectedResourceIds.map((id) => String(id)), [selectedResourceIds]);
|
||||||
const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]);
|
const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]);
|
||||||
const excludedIds = useMemo(() => excludedResourceIds.map((id) => String(id)), [excludedResourceIds]);
|
|
||||||
const excludedIdSet = useMemo(() => new Set(excludedIds), [excludedIds]);
|
|
||||||
const giftsByResourceId = useMemo(() => {
|
const giftsByResourceId = useMemo(() => {
|
||||||
const items = new Map();
|
const items = new Map();
|
||||||
gifts.forEach((gift) => {
|
gifts.forEach((gift) => {
|
||||||
@ -52,19 +49,10 @@ export function GiftResourceSelectField({
|
|||||||
() => buildResourceItems(resources, giftsByResourceId),
|
() => buildResourceItems(resources, giftsByResourceId),
|
||||||
[giftsByResourceId, resources],
|
[giftsByResourceId, resources],
|
||||||
);
|
);
|
||||||
const visibleResourceItems = useMemo(
|
const itemCounts = useMemo(() => countResourceItems(resourceItems), [resourceItems]);
|
||||||
() =>
|
|
||||||
resourceItems.filter((item) => {
|
|
||||||
const resourceId = String(item.resource.resourceId);
|
|
||||||
return !excludedIdSet.has(resourceId) || selectedIdSet.has(resourceId);
|
|
||||||
}),
|
|
||||||
[excludedIdSet, resourceItems, selectedIdSet],
|
|
||||||
);
|
|
||||||
const visibleResources = useMemo(() => visibleResourceItems.map((item) => item.resource), [visibleResourceItems]);
|
|
||||||
const itemCounts = useMemo(() => countResourceItems(visibleResourceItems), [visibleResourceItems]);
|
|
||||||
const categoryOptions = useMemo(
|
const categoryOptions = useMemo(
|
||||||
() => buildCategoryOptions(giftTypes, visibleResources, visibleResourceItems),
|
() => buildCategoryOptions(giftTypes, resources, resourceItems),
|
||||||
[giftTypes, visibleResourceItems, visibleResources],
|
[giftTypes, resourceItems, resources],
|
||||||
);
|
);
|
||||||
const activeCategory = categoryOptions.some((item) => item.value === activeType)
|
const activeCategory = categoryOptions.some((item) => item.value === activeType)
|
||||||
? activeType
|
? activeType
|
||||||
@ -88,7 +76,7 @@ export function GiftResourceSelectField({
|
|||||||
}, [resourcesById, selectedIds]);
|
}, [resourcesById, selectedIds]);
|
||||||
const filteredResources = useMemo(() => {
|
const filteredResources = useMemo(() => {
|
||||||
const keyword = query.trim().toLowerCase();
|
const keyword = query.trim().toLowerCase();
|
||||||
return visibleResourceItems.filter((item) => {
|
return resourceItems.filter((item) => {
|
||||||
if (activeCategory && item.categoryValue !== activeCategory) {
|
if (activeCategory && item.categoryValue !== activeCategory) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -111,7 +99,7 @@ export function GiftResourceSelectField({
|
|||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
return haystack.includes(keyword);
|
return haystack.includes(keyword);
|
||||||
});
|
});
|
||||||
}, [activeCategory, query, visibleResourceItems]);
|
}, [activeCategory, query, resourceItems]);
|
||||||
|
|
||||||
const openDrawer = () => {
|
const openDrawer = () => {
|
||||||
if (!disabled && !loading) {
|
if (!disabled && !loading) {
|
||||||
|
|||||||
@ -117,71 +117,3 @@ test("gift resource select drawer filters by gift type and supports multiple sel
|
|||||||
expect(changes.at(-1)).toEqual(["11", "12"]);
|
expect(changes.at(-1)).toEqual(["11", "12"]);
|
||||||
expect(screen.getByDisplayValue("Rose Gift,CP Ring")).toBeInTheDocument();
|
expect(screen.getByDisplayValue("Rose Gift,CP Ring")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("gift resource select drawer hides excluded gift resources from candidates", () => {
|
|
||||||
function Harness() {
|
|
||||||
const [value, setValue] = useState([]);
|
|
||||||
return (
|
|
||||||
<GiftResourceSelectField
|
|
||||||
excludedResourceIds={[11]}
|
|
||||||
giftTypes={giftTypes}
|
|
||||||
gifts={gifts}
|
|
||||||
resources={resources}
|
|
||||||
selectedResourceIds={value}
|
|
||||||
onChange={setValue}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
render(<Harness />);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByPlaceholderText("请选择资源"));
|
|
||||||
fireEvent.click(screen.getByRole("tab", { name: "普通礼物" }));
|
|
||||||
|
|
||||||
expect(screen.queryByText("Rose Gift")).not.toBeInTheDocument();
|
|
||||||
expect(screen.getByText("当前无可选资源")).toBeInTheDocument();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("tab", { name: "CP礼物" }));
|
|
||||||
expect(screen.getByText("CP Ring")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("gift resource select drawer hides excluded resources but keeps selected resources removable", () => {
|
|
||||||
const changes = [];
|
|
||||||
|
|
||||||
function Harness() {
|
|
||||||
const [value, setValue] = useState(["11"]);
|
|
||||||
return (
|
|
||||||
<GiftResourceSelectField
|
|
||||||
excludedResourceIds={[11, 21]}
|
|
||||||
giftTypes={giftTypes}
|
|
||||||
gifts={gifts}
|
|
||||||
resources={resources}
|
|
||||||
selectedResourceIds={value}
|
|
||||||
onChange={(nextValue) => {
|
|
||||||
changes.push(nextValue);
|
|
||||||
setValue(nextValue);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
render(<Harness />);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByPlaceholderText("请选择资源"));
|
|
||||||
|
|
||||||
expect(screen.getByText("Rose Gift")).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText("VIP Vehicle")).not.toBeInTheDocument();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("tab", { name: "CP礼物" }));
|
|
||||||
fireEvent.click(screen.getByText("CP Ring"));
|
|
||||||
|
|
||||||
expect(changes.at(-1)).toEqual(["11", "12"]);
|
|
||||||
expect(screen.getByDisplayValue("Rose Gift,CP Ring")).toBeInTheDocument();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("tab", { name: "普通礼物" }));
|
|
||||||
fireEvent.click(screen.getByText("Rose Gift"));
|
|
||||||
|
|
||||||
expect(changes.at(-1)).toEqual(["12"]);
|
|
||||||
expect(screen.queryByText("Rose Gift")).not.toBeInTheDocument();
|
|
||||||
expect(screen.getByDisplayValue("CP Ring")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|||||||
@ -1,117 +0,0 @@
|
|||||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
||||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
|
||||||
import { useGiftListPage } from "./useResourcePages.js";
|
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
|
||||||
batchCreateGifts: vi.fn(),
|
|
||||||
can: vi.fn(),
|
|
||||||
createGift: vi.fn(),
|
|
||||||
listGifts: vi.fn(),
|
|
||||||
listGiftTypes: vi.fn(),
|
|
||||||
listResources: vi.fn(),
|
|
||||||
reload: vi.fn(),
|
|
||||||
showToast: vi.fn(),
|
|
||||||
usePaginatedQuery: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/app/auth/AuthProvider.jsx", () => ({
|
|
||||||
useAuth: () => ({ can: mocks.can }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/shared/hooks/usePaginatedQuery.js", () => ({
|
|
||||||
usePaginatedQuery: mocks.usePaginatedQuery,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/shared/hooks/useRegionOptions.js", () => ({
|
|
||||||
useRegionOptions: () => ({ loadingRegions: false, regionOptions: [{ label: "全球", value: "0" }] }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/shared/ui/ConfirmProvider.jsx", () => ({
|
|
||||||
useConfirm: () => vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/shared/ui/ToastProvider.jsx", () => ({
|
|
||||||
useToast: () => ({ showToast: mocks.showToast }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/features/resources/api", async (importOriginal) => ({
|
|
||||||
...(await importOriginal()),
|
|
||||||
batchCreateGifts: mocks.batchCreateGifts,
|
|
||||||
createGift: mocks.createGift,
|
|
||||||
listGifts: mocks.listGifts,
|
|
||||||
listGiftTypes: mocks.listGiftTypes,
|
|
||||||
listResources: mocks.listResources,
|
|
||||||
}));
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mocks.can.mockReturnValue(true);
|
|
||||||
mocks.batchCreateGifts.mockResolvedValue([]);
|
|
||||||
mocks.createGift.mockResolvedValue({});
|
|
||||||
mocks.listGifts.mockResolvedValue({ items: [], page: 1, pageSize: 100, total: 0 });
|
|
||||||
mocks.listGiftTypes.mockResolvedValue([]);
|
|
||||||
mocks.listResources.mockResolvedValue({ items: [], page: 1, pageSize: 100, total: 0 });
|
|
||||||
mocks.reload.mockResolvedValue(undefined);
|
|
||||||
mocks.usePaginatedQuery.mockReturnValue({
|
|
||||||
data: { items: [], page: 1, pageSize: 50, total: 0 },
|
|
||||||
error: null,
|
|
||||||
loading: false,
|
|
||||||
reload: mocks.reload,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("creating multiple gift drafts calls the batch create endpoint once", async () => {
|
|
||||||
const { result } = renderHook(() => useGiftListPage());
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
result.current.openCreateGift();
|
|
||||||
});
|
|
||||||
await waitFor(() => expect(result.current.activeAction).toBe("create"));
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
result.current.setForm({
|
|
||||||
...giftDraft({ giftId: "rose", name: "Rose", resourceId: "11" }),
|
|
||||||
items: [
|
|
||||||
giftDraft({ giftId: "rose", name: "Rose", resourceId: "11" }),
|
|
||||||
giftDraft({ giftId: "ring", name: "Ring", resourceId: "12" }),
|
|
||||||
],
|
|
||||||
resourceId: "11",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.submitGift({ preventDefault: vi.fn() });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mocks.batchCreateGifts).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mocks.batchCreateGifts).toHaveBeenCalledWith([
|
|
||||||
expect.objectContaining({ giftId: "rose", name: "Rose", resourceId: 11 }),
|
|
||||||
expect.objectContaining({ giftId: "ring", name: "Ring", resourceId: 12 }),
|
|
||||||
]);
|
|
||||||
expect(mocks.createGift).not.toHaveBeenCalled();
|
|
||||||
expect(mocks.reload).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
function giftDraft(patch = {}) {
|
|
||||||
return {
|
|
||||||
chargeAssetType: "COIN",
|
|
||||||
coinPrice: "10",
|
|
||||||
cpRelationType: "",
|
|
||||||
effectTypes: ["animation"],
|
|
||||||
effectiveFrom: "",
|
|
||||||
effectiveTo: "",
|
|
||||||
enabled: true,
|
|
||||||
giftId: "gift",
|
|
||||||
giftTypeCode: "normal",
|
|
||||||
name: "Gift",
|
|
||||||
presentationJson: "{}",
|
|
||||||
priceVersion: "default",
|
|
||||||
regionIds: ["0"],
|
|
||||||
resourceId: "1",
|
|
||||||
sortOrder: "0",
|
|
||||||
...patch,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,158 +0,0 @@
|
|||||||
import { act, renderHook } from "@testing-library/react";
|
|
||||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
|
||||||
import { useResourceListPage } from "./useResourcePages.js";
|
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
|
||||||
batchDeleteResources: vi.fn(),
|
|
||||||
can: vi.fn(),
|
|
||||||
confirm: vi.fn(),
|
|
||||||
deleteResource: vi.fn(),
|
|
||||||
reload: vi.fn(),
|
|
||||||
showToast: vi.fn(),
|
|
||||||
usePaginatedQuery: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/app/auth/AuthProvider.jsx", () => ({
|
|
||||||
useAuth: () => ({ can: mocks.can }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/shared/hooks/usePaginatedQuery.js", () => ({
|
|
||||||
usePaginatedQuery: mocks.usePaginatedQuery,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/shared/ui/ConfirmProvider.jsx", () => ({
|
|
||||||
useConfirm: () => mocks.confirm,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/shared/ui/ToastProvider.jsx", () => ({
|
|
||||||
useToast: () => ({ showToast: mocks.showToast }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/features/resources/api", async (importOriginal) => ({
|
|
||||||
...(await importOriginal()),
|
|
||||||
batchDeleteResources: mocks.batchDeleteResources,
|
|
||||||
deleteResource: mocks.deleteResource,
|
|
||||||
}));
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mocks.can.mockReturnValue(true);
|
|
||||||
mocks.confirm.mockResolvedValue(true);
|
|
||||||
mocks.batchDeleteResources.mockResolvedValue([]);
|
|
||||||
mocks.deleteResource.mockResolvedValue({});
|
|
||||||
mocks.reload.mockResolvedValue(undefined);
|
|
||||||
mocks.usePaginatedQuery.mockReturnValue({
|
|
||||||
data: resourcePage(),
|
|
||||||
error: null,
|
|
||||||
loading: false,
|
|
||||||
reload: mocks.reload,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("deleting a resource hides only that row without reloading the list", async () => {
|
|
||||||
const { result } = renderHook(() => useResourceListPage());
|
|
||||||
|
|
||||||
let deletedResourceIds = [];
|
|
||||||
await act(async () => {
|
|
||||||
deletedResourceIds = await result.current.deleteResources([resourceFixture({ resourceId: 11 })]);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(deletedResourceIds).toEqual([11]);
|
|
||||||
expect(mocks.deleteResource).toHaveBeenCalledWith(11);
|
|
||||||
expect(mocks.reload).not.toHaveBeenCalled();
|
|
||||||
expect(result.current.data.items.map((resource) => resource.resourceId)).toEqual([12]);
|
|
||||||
expect(mocks.showToast).toHaveBeenCalledWith("资源已删除", "success");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("batch resource delete hides successful rows without reloading the list", async () => {
|
|
||||||
const { result } = renderHook(() => useResourceListPage());
|
|
||||||
|
|
||||||
let deletedResourceIds = [];
|
|
||||||
await act(async () => {
|
|
||||||
deletedResourceIds = await result.current.deleteResources([
|
|
||||||
resourceFixture({ resourceId: 11 }),
|
|
||||||
resourceFixture({ resourceId: 12 }),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(deletedResourceIds).toEqual([11, 12]);
|
|
||||||
expect(mocks.batchDeleteResources).toHaveBeenCalledWith([11, 12]);
|
|
||||||
expect(mocks.deleteResource).not.toHaveBeenCalled();
|
|
||||||
expect(mocks.reload).not.toHaveBeenCalled();
|
|
||||||
expect(result.current.data.items).toEqual([]);
|
|
||||||
expect(mocks.showToast).toHaveBeenCalledWith("已删除 2 个资源", "success");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("failed batch resource delete keeps all rows visible", async () => {
|
|
||||||
mocks.usePaginatedQuery.mockReturnValue({
|
|
||||||
data: {
|
|
||||||
items: [
|
|
||||||
resourceFixture({ resourceId: 11 }),
|
|
||||||
resourceFixture({ resourceId: 12 }),
|
|
||||||
resourceFixture({ resourceId: 13 }),
|
|
||||||
],
|
|
||||||
page: 1,
|
|
||||||
pageSize: 50,
|
|
||||||
total: 3,
|
|
||||||
},
|
|
||||||
error: null,
|
|
||||||
loading: false,
|
|
||||||
reload: mocks.reload,
|
|
||||||
});
|
|
||||||
mocks.batchDeleteResources.mockRejectedValueOnce(new Error("delete failed"));
|
|
||||||
const { result } = renderHook(() => useResourceListPage());
|
|
||||||
|
|
||||||
let deletedResourceIds = [];
|
|
||||||
await act(async () => {
|
|
||||||
deletedResourceIds = await result.current.deleteResources([
|
|
||||||
resourceFixture({ resourceId: 11 }),
|
|
||||||
resourceFixture({ resourceId: 12 }),
|
|
||||||
resourceFixture({ resourceId: 13 }),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(deletedResourceIds).toEqual([]);
|
|
||||||
expect(mocks.batchDeleteResources).toHaveBeenCalledWith([11, 12, 13]);
|
|
||||||
expect(mocks.deleteResource).not.toHaveBeenCalled();
|
|
||||||
expect(result.current.data.items.map((resource) => resource.resourceId)).toEqual([11, 12, 13]);
|
|
||||||
expect(mocks.showToast).toHaveBeenCalledWith("delete failed", "error");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("failed resource delete keeps the row visible", async () => {
|
|
||||||
mocks.deleteResource.mockRejectedValueOnce(new Error("delete failed"));
|
|
||||||
const { result } = renderHook(() => useResourceListPage());
|
|
||||||
|
|
||||||
let deletedResourceIds = [];
|
|
||||||
await act(async () => {
|
|
||||||
deletedResourceIds = await result.current.deleteResources([resourceFixture({ resourceId: 11 })]);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(deletedResourceIds).toEqual([]);
|
|
||||||
expect(mocks.reload).not.toHaveBeenCalled();
|
|
||||||
expect(result.current.data.items.map((resource) => resource.resourceId)).toEqual([11, 12]);
|
|
||||||
expect(mocks.showToast).toHaveBeenCalledWith("delete failed", "error");
|
|
||||||
});
|
|
||||||
|
|
||||||
function resourcePage() {
|
|
||||||
return {
|
|
||||||
items: [resourceFixture({ resourceId: 11, name: "Rose" }), resourceFixture({ resourceId: 12, name: "Ring" })],
|
|
||||||
page: 1,
|
|
||||||
pageSize: 50,
|
|
||||||
total: 2,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function resourceFixture(patch = {}) {
|
|
||||||
return {
|
|
||||||
coinPrice: 10,
|
|
||||||
name: "Rose",
|
|
||||||
resourceCode: "gift_rose",
|
|
||||||
resourceId: 11,
|
|
||||||
resourceType: "gift",
|
|
||||||
status: "active",
|
|
||||||
...patch,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -6,14 +6,11 @@ import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
|||||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
import { emptyUserIdentityFilter, userIdentityFilterParams } from "@/shared/ui/userIdentityFilter.js";
|
import { emptyUserIdentityFilter, userIdentityFilterParams } from "@/shared/ui/userIdentityFilter.js";
|
||||||
import {
|
import {
|
||||||
batchCreateGifts,
|
|
||||||
batchDeleteResources as requestBatchDeleteResources,
|
|
||||||
createEmojiPack,
|
createEmojiPack,
|
||||||
createGift,
|
createGift,
|
||||||
createResource,
|
createResource,
|
||||||
createResourceGroup,
|
createResourceGroup,
|
||||||
deleteGift as requestDeleteGift,
|
deleteGift as requestDeleteGift,
|
||||||
deleteResource as requestDeleteResource,
|
|
||||||
disableResourceShopItem,
|
disableResourceShopItem,
|
||||||
disableGift,
|
disableGift,
|
||||||
disableResource,
|
disableResource,
|
||||||
@ -166,7 +163,6 @@ const emptyGiftForm = (gift = {}) => {
|
|||||||
priceVersion: gift.priceVersion || "default",
|
priceVersion: gift.priceVersion || "default",
|
||||||
regionIds: (gift.regionIds?.length ? gift.regionIds : [0]).map(String),
|
regionIds: (gift.regionIds?.length ? gift.regionIds : [0]).map(String),
|
||||||
resourceId: gift.resourceId ? String(gift.resourceId) : "",
|
resourceId: gift.resourceId ? String(gift.resourceId) : "",
|
||||||
items: [],
|
|
||||||
sortOrder: gift.sortOrder === 0 || gift.sortOrder ? String(gift.sortOrder) : "0",
|
sortOrder: gift.sortOrder === 0 || gift.sortOrder ? String(gift.sortOrder) : "0",
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@ -184,52 +180,6 @@ export function applyGiftResourceSelection(form, resource, fillIdentity = true)
|
|||||||
return applyGiftResourcePrice(nextForm, resource);
|
return applyGiftResourcePrice(nextForm, resource);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildGiftResourceDrafts(form, resourceIds = [], resources = []) {
|
|
||||||
const resourcesById = new Map(resources.map((resource) => [String(resource.resourceId), resource]));
|
|
||||||
const existingDraftsByResourceId = new Map(
|
|
||||||
(form.items || [])
|
|
||||||
.filter((item) => item?.resourceId)
|
|
||||||
.map((item) => [String(item.resourceId), item]),
|
|
||||||
);
|
|
||||||
|
|
||||||
return resourceIds
|
|
||||||
.map((resourceId) => String(resourceId || "").trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((resourceId) => {
|
|
||||||
const existingDraft = existingDraftsByResourceId.get(resourceId);
|
|
||||||
if (existingDraft) {
|
|
||||||
return existingDraft;
|
|
||||||
}
|
|
||||||
const resource = resourcesById.get(resourceId) || { resourceId };
|
|
||||||
return {
|
|
||||||
...applyGiftResourceSelection(giftDraftDefaultsFromForm(form), resource, true),
|
|
||||||
resource,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function giftDraftDefaultsFromForm(form) {
|
|
||||||
const giftTypeCode = form.giftTypeCode || "normal";
|
|
||||||
const cpRelationType = giftTypeCode === cpGiftTypeCode ? form.cpRelationType || defaultCPRelationType : "";
|
|
||||||
return {
|
|
||||||
chargeAssetType: form.chargeAssetType || "COIN",
|
|
||||||
coinPrice: "",
|
|
||||||
cpRelationType,
|
|
||||||
effectTypes: form.effectTypes || [],
|
|
||||||
effectiveFrom: form.effectiveFrom || "",
|
|
||||||
effectiveTo: form.effectiveTo || "",
|
|
||||||
enabled: form.enabled !== false,
|
|
||||||
giftId: "",
|
|
||||||
giftTypeCode,
|
|
||||||
name: "",
|
|
||||||
presentationJson: normalizeGiftPresentationJson(form.presentationJson || "{}", giftTypeCode, cpRelationType),
|
|
||||||
priceVersion: form.priceVersion || "default",
|
|
||||||
regionIds: form.regionIds?.length ? form.regionIds : ["0"],
|
|
||||||
resourceId: "",
|
|
||||||
sortOrder: form.sortOrder || "0",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyGiftPriceDefaults(form, coinPrice) {
|
export function applyGiftPriceDefaults(form, coinPrice) {
|
||||||
const priceValue = String(coinPrice ?? "");
|
const priceValue = String(coinPrice ?? "");
|
||||||
return {
|
return {
|
||||||
@ -284,12 +234,12 @@ export function normalizeGiftPresentationJson(presentationJson, giftTypeCode, cp
|
|||||||
return isCPGift ? cpPresentationJson(nextPresentation, cpRelationType) : JSON.stringify(nextPresentation);
|
return isCPGift ? cpPresentationJson(nextPresentation, cpRelationType) : JSON.stringify(nextPresentation);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAllOptionPages(fetcher, query = {}, pageSize = optionPageSize, maxPages = optionMaxPages) {
|
export async function fetchAllOptionPages(fetcher, query = {}, pageSize = optionPageSize) {
|
||||||
const items = [];
|
const items = [];
|
||||||
let total = Number.POSITIVE_INFINITY;
|
let total = Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
// 后台会把选项接口的 page_size 截断到 100;抽屉候选项必须按真实分页拉全,避免只展示第一页资源。
|
// 后台会把选项接口的 page_size 截断到 100;抽屉候选项必须按真实分页拉全,避免只展示第一页资源。
|
||||||
for (let page = 1; page <= maxPages; page += 1) {
|
for (let page = 1; page <= optionMaxPages; page += 1) {
|
||||||
const data = await fetcher({ ...query, page, page_size: pageSize });
|
const data = await fetcher({ ...query, page, page_size: pageSize });
|
||||||
const pageItems = Array.isArray(data?.items) ? data.items : [];
|
const pageItems = Array.isArray(data?.items) ? data.items : [];
|
||||||
const responsePageSize = Number(data?.pageSize || data?.page_size || pageSize) || pageSize;
|
const responsePageSize = Number(data?.pageSize || data?.page_size || pageSize) || pageSize;
|
||||||
@ -394,7 +344,6 @@ function groupItemToForm(item) {
|
|||||||
|
|
||||||
export function useResourceListPage() {
|
export function useResourceListPage() {
|
||||||
const abilities = useResourceAbilities();
|
const abilities = useResourceAbilities();
|
||||||
const confirm = useConfirm();
|
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [status, setStatus] = useState("");
|
const [status, setStatus] = useState("");
|
||||||
@ -404,8 +353,6 @@ export function useResourceListPage() {
|
|||||||
const [form, setForm] = useState(emptyResourceForm);
|
const [form, setForm] = useState(emptyResourceForm);
|
||||||
const [selectedResource, setSelectedResource] = useState(null);
|
const [selectedResource, setSelectedResource] = useState(null);
|
||||||
const [loadingAction, setLoadingAction] = useState("");
|
const [loadingAction, setLoadingAction] = useState("");
|
||||||
const [resourceRowPatches, setResourceRowPatches] = useState({});
|
|
||||||
const [hiddenResourceIds, setHiddenResourceIds] = useState([]);
|
|
||||||
const filters = useMemo(
|
const filters = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
keyword: query,
|
keyword: query,
|
||||||
@ -422,61 +369,6 @@ export function useResourceListPage() {
|
|||||||
pageSize,
|
pageSize,
|
||||||
queryKey: ["resources", filters, page],
|
queryKey: ["resources", filters, page],
|
||||||
});
|
});
|
||||||
const hiddenResourceIdSet = useMemo(() => new Set(hiddenResourceIds), [hiddenResourceIds]);
|
|
||||||
const resourceData = useMemo(() => {
|
|
||||||
const source = result.data || emptyData;
|
|
||||||
const sourceItems = Array.isArray(source.items) ? source.items : [];
|
|
||||||
const patchedItems = sourceItems
|
|
||||||
.map((item) => ({
|
|
||||||
...item,
|
|
||||||
...(resourceRowPatches[item.resourceId] || {}),
|
|
||||||
}))
|
|
||||||
.filter((item) => !hiddenResourceIdSet.has(item.resourceId));
|
|
||||||
return {
|
|
||||||
...source,
|
|
||||||
items: patchedItems,
|
|
||||||
};
|
|
||||||
}, [hiddenResourceIdSet, resourceRowPatches, result.data]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setResourceRowPatches({});
|
|
||||||
setHiddenResourceIds([]);
|
|
||||||
}, [filters]);
|
|
||||||
|
|
||||||
const patchResourceRow = (resourceId, patch) => {
|
|
||||||
if (!resourceId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setHiddenResourceIds((current) => current.filter((hiddenId) => hiddenId !== resourceId));
|
|
||||||
setResourceRowPatches((current) => ({
|
|
||||||
...current,
|
|
||||||
[resourceId]: {
|
|
||||||
...(current[resourceId] || {}),
|
|
||||||
...patch,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const hideResourceRow = (resourceId) => {
|
|
||||||
if (!resourceId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setHiddenResourceIds((current) => (current.includes(resourceId) ? current : [...current, resourceId]));
|
|
||||||
};
|
|
||||||
|
|
||||||
const hideResourceRows = (resourceIds) => {
|
|
||||||
const nextIds = resourceIds.filter(Boolean);
|
|
||||||
if (!nextIds.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setHiddenResourceIds((current) => Array.from(new Set([...current, ...nextIds])));
|
|
||||||
};
|
|
||||||
|
|
||||||
const reloadResources = async () => {
|
|
||||||
setResourceRowPatches({});
|
|
||||||
setHiddenResourceIds([]);
|
|
||||||
return result.reload();
|
|
||||||
};
|
|
||||||
|
|
||||||
const openCreateResource = () => {
|
const openCreateResource = () => {
|
||||||
setForm(emptyResourceForm());
|
setForm(emptyResourceForm());
|
||||||
@ -530,21 +422,12 @@ export function useResourceListPage() {
|
|||||||
`resource-status-${resource.resourceId}`,
|
`resource-status-${resource.resourceId}`,
|
||||||
nextEnabled ? "资源已启用" : "资源已禁用",
|
nextEnabled ? "资源已启用" : "资源已禁用",
|
||||||
async () => {
|
async () => {
|
||||||
let updatedResource;
|
|
||||||
if (nextStatus === "active") {
|
if (nextStatus === "active") {
|
||||||
updatedResource = await enableResource(resource.resourceId);
|
await enableResource(resource.resourceId);
|
||||||
} else {
|
} else {
|
||||||
updatedResource = await disableResource(resource.resourceId);
|
await disableResource(resource.resourceId);
|
||||||
}
|
|
||||||
if (status && nextStatus !== status) {
|
|
||||||
hideResourceRow(resource.resourceId);
|
|
||||||
} else {
|
|
||||||
patchResourceRow(resource.resourceId, {
|
|
||||||
...(updatedResource || {}),
|
|
||||||
status: updatedResource?.status || nextStatus,
|
|
||||||
updatedAtMs: updatedResource?.updatedAtMs ?? Date.now(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
await result.reload();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -564,59 +447,12 @@ export function useResourceListPage() {
|
|||||||
...emptyResourceForm(resource),
|
...emptyResourceForm(resource),
|
||||||
managerGrantEnabled: nextEnabled,
|
managerGrantEnabled: nextEnabled,
|
||||||
});
|
});
|
||||||
const updatedResource = await updateResource(resource.resourceId, buildResourcePayload(formValue));
|
await updateResource(resource.resourceId, buildResourcePayload(formValue));
|
||||||
patchResourceRow(resource.resourceId, {
|
await result.reload();
|
||||||
...(updatedResource || {}),
|
|
||||||
managerGrantEnabled: updatedResource?.managerGrantEnabled ?? nextEnabled,
|
|
||||||
updatedAtMs: updatedResource?.updatedAtMs ?? Date.now(),
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteResources = async (resources = []) => {
|
|
||||||
const targetResources = resources.filter((resource) => resource?.resourceId);
|
|
||||||
if (!abilities.canDelete || !targetResources.length) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const title = targetResources.length > 1 ? "批量删除资源" : "删除资源";
|
|
||||||
const message =
|
|
||||||
targetResources.length > 1
|
|
||||||
? `确认删除选中的 ${targetResources.length} 个资源?删除成功后资源会从资源列表移除。`
|
|
||||||
: `确认删除资源 ${targetResources[0].name || targetResources[0].resourceCode || targetResources[0].resourceId}?删除成功后资源会从资源列表移除。`;
|
|
||||||
const ok = await confirm({
|
|
||||||
confirmText: "删除",
|
|
||||||
message,
|
|
||||||
title,
|
|
||||||
tone: "danger",
|
|
||||||
});
|
|
||||||
if (!ok) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const deletedResourceIds = [];
|
|
||||||
setLoadingAction(
|
|
||||||
targetResources.length > 1 ? "resource-delete-batch" : `resource-delete-${targetResources[0].resourceId}`,
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
if (targetResources.length > 1) {
|
|
||||||
await requestBatchDeleteResources(targetResources.map((resource) => resource.resourceId));
|
|
||||||
deletedResourceIds.push(...targetResources.map((resource) => resource.resourceId));
|
|
||||||
} else {
|
|
||||||
await requestDeleteResource(targetResources[0].resourceId);
|
|
||||||
deletedResourceIds.push(targetResources[0].resourceId);
|
|
||||||
}
|
|
||||||
hideResourceRows(deletedResourceIds);
|
|
||||||
showToast(targetResources.length > 1 ? `已删除 ${deletedResourceIds.length} 个资源` : "资源已删除", "success");
|
|
||||||
return deletedResourceIds;
|
|
||||||
} catch (err) {
|
|
||||||
showToast(err.message || "删除资源失败", "error");
|
|
||||||
return [];
|
|
||||||
} finally {
|
|
||||||
setLoadingAction("");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const runAction = async (action, successMessage, submitter) => {
|
const runAction = async (action, successMessage, submitter) => {
|
||||||
setLoadingAction(action);
|
setLoadingAction(action);
|
||||||
try {
|
try {
|
||||||
@ -637,13 +473,12 @@ export function useResourceListPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...sharedPageState({ data: resourceData, page, query, reload: reloadResources, result, setPage, setQuery }),
|
...sharedPageState({ page, query, result, setPage, setQuery }),
|
||||||
abilities,
|
abilities,
|
||||||
activeAction,
|
activeAction,
|
||||||
changeResourceType: resetSetter(setResourceType, setPage),
|
changeResourceType: resetSetter(setResourceType, setPage),
|
||||||
changeStatus: resetSetter(setStatus, setPage),
|
changeStatus: resetSetter(setStatus, setPage),
|
||||||
closeAction,
|
closeAction,
|
||||||
deleteResources,
|
|
||||||
form,
|
form,
|
||||||
loadingAction,
|
loadingAction,
|
||||||
openCreateResource,
|
openCreateResource,
|
||||||
@ -1214,8 +1049,6 @@ export function useGiftListPage() {
|
|||||||
const [giftTypeDialogOpen, setGiftTypeDialogOpen] = useState(false);
|
const [giftTypeDialogOpen, setGiftTypeDialogOpen] = useState(false);
|
||||||
const [giftTypesLoading, setGiftTypesLoading] = useState(false);
|
const [giftTypesLoading, setGiftTypesLoading] = useState(false);
|
||||||
const [resourceOptions, setResourceOptions] = useState([]);
|
const [resourceOptions, setResourceOptions] = useState([]);
|
||||||
const [giftResourceGiftOptions, setGiftResourceGiftOptions] = useState([]);
|
|
||||||
const [existingGiftResourceIds, setExistingGiftResourceIds] = useState([]);
|
|
||||||
const [resourceOptionsLoading, setResourceOptionsLoading] = useState(false);
|
const [resourceOptionsLoading, setResourceOptionsLoading] = useState(false);
|
||||||
const { loadingRegions, regionOptions } = useRegionOptions();
|
const { loadingRegions, regionOptions } = useRegionOptions();
|
||||||
const filters = useMemo(
|
const filters = useMemo(
|
||||||
@ -1280,25 +1113,10 @@ export function useGiftListPage() {
|
|||||||
}
|
}
|
||||||
let ignore = false;
|
let ignore = false;
|
||||||
setResourceOptionsLoading(true);
|
setResourceOptionsLoading(true);
|
||||||
const optionRequests =
|
fetchAllOptionPages(listResources, { resource_type: "gift" })
|
||||||
activeAction === "create"
|
.then((data) => {
|
||||||
? [
|
|
||||||
fetchAllOptionPages(listResources, { resource_type: "gift" }),
|
|
||||||
fetchAllOptionPages(listGifts, {}, optionPageSize, 1000),
|
|
||||||
]
|
|
||||||
: [fetchAllOptionPages(listResources, { resource_type: "gift" }), Promise.resolve({ items: [] })];
|
|
||||||
Promise.all(optionRequests)
|
|
||||||
.then(([resourceData, giftOptionsData]) => {
|
|
||||||
if (!ignore) {
|
if (!ignore) {
|
||||||
const giftOptions = giftOptionsData.items || [];
|
setResourceOptions(toGiftResourceOptions(data.items || [], selectedGift));
|
||||||
setGiftResourceGiftOptions(giftOptions);
|
|
||||||
setExistingGiftResourceIds(
|
|
||||||
giftOptions
|
|
||||||
.map((gift) => gift?.resourceId)
|
|
||||||
.filter((resourceId) => resourceId || resourceId === 0)
|
|
||||||
.map(String),
|
|
||||||
);
|
|
||||||
setResourceOptions(toGiftResourceOptions(resourceData.items || [], selectedGift));
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@ -1347,66 +1165,6 @@ export function useGiftListPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectGiftResources = (resourceIds) => {
|
|
||||||
setForm((currentForm) => {
|
|
||||||
const items = buildGiftResourceDrafts(currentForm, resourceIds, resourceOptions);
|
|
||||||
return {
|
|
||||||
...currentForm,
|
|
||||||
items,
|
|
||||||
resourceId: items[0]?.resourceId || "",
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateGiftDraft = (resourceId, patch) => {
|
|
||||||
setForm((currentForm) => ({
|
|
||||||
...currentForm,
|
|
||||||
items: (currentForm.items || []).map((item) => {
|
|
||||||
if (String(item.resourceId) !== String(resourceId)) {
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
const nextItem = { ...item, ...patch };
|
|
||||||
if (Object.prototype.hasOwnProperty.call(patch, "giftTypeCode")) {
|
|
||||||
const cpRelationType =
|
|
||||||
nextItem.giftTypeCode === cpGiftTypeCode
|
|
||||||
? nextItem.cpRelationType || defaultCPRelationType
|
|
||||||
: "";
|
|
||||||
return {
|
|
||||||
...nextItem,
|
|
||||||
cpRelationType,
|
|
||||||
presentationJson: normalizeGiftPresentationJson(
|
|
||||||
nextItem.presentationJson,
|
|
||||||
nextItem.giftTypeCode,
|
|
||||||
cpRelationType,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (Object.prototype.hasOwnProperty.call(patch, "cpRelationType")) {
|
|
||||||
return {
|
|
||||||
...nextItem,
|
|
||||||
presentationJson: normalizeGiftPresentationJson(
|
|
||||||
nextItem.presentationJson,
|
|
||||||
nextItem.giftTypeCode,
|
|
||||||
nextItem.cpRelationType,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return nextItem;
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeGiftDraft = (resourceId) => {
|
|
||||||
setForm((currentForm) => {
|
|
||||||
const items = (currentForm.items || []).filter((item) => String(item.resourceId) !== String(resourceId));
|
|
||||||
return {
|
|
||||||
...currentForm,
|
|
||||||
items,
|
|
||||||
resourceId: items[0]?.resourceId || "",
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const changeGiftPrice = (coinPrice) => {
|
const changeGiftPrice = (coinPrice) => {
|
||||||
setForm((currentForm) => applyGiftPriceDefaults(currentForm, coinPrice));
|
setForm((currentForm) => applyGiftPriceDefaults(currentForm, coinPrice));
|
||||||
};
|
};
|
||||||
@ -1491,58 +1249,21 @@ export function useGiftListPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoadingAction(editing ? "gift-edit" : "gift-create");
|
setLoadingAction(editing ? "gift-edit" : "gift-create");
|
||||||
const createdDrafts = [];
|
|
||||||
let shouldReload = false;
|
|
||||||
try {
|
try {
|
||||||
const createDrafts = !editing && Array.isArray(form.items) && form.items.length ? form.items : [form];
|
const payload = buildGiftPayload(parseForm(giftFormSchema, form));
|
||||||
if (editing) {
|
if (editing) {
|
||||||
const payload = buildGiftPayload(parseForm(giftFormSchema, form));
|
|
||||||
await updateGift(selectedGift.giftId, payload);
|
await updateGift(selectedGift.giftId, payload);
|
||||||
} else {
|
} else {
|
||||||
const payloads = createDrafts.map((draft) => ({
|
await createGift(payload);
|
||||||
draft,
|
|
||||||
payload: buildGiftPayload(parseForm(giftFormSchema, draft)),
|
|
||||||
}));
|
|
||||||
if (payloads.length > 1) {
|
|
||||||
await batchCreateGifts(payloads.map(({ payload }) => payload));
|
|
||||||
createdDrafts.push(...payloads.map(({ draft }) => draft));
|
|
||||||
} else {
|
|
||||||
await createGift(payloads[0].payload);
|
|
||||||
createdDrafts.push(payloads[0].draft);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
showToast(editing ? "礼物已更新" : `已创建 ${createDrafts.length} 个礼物`, "success");
|
showToast(editing ? "礼物已更新" : "礼物已创建", "success");
|
||||||
closeAction();
|
closeAction();
|
||||||
shouldReload = true;
|
await result.reload();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!editing && createdDrafts.length) {
|
showToast(err.message || "操作失败", "error");
|
||||||
const createdResourceIds = new Set(createdDrafts.map((draft) => String(draft.resourceId)));
|
|
||||||
setExistingGiftResourceIds((current) => Array.from(new Set([...current, ...createdResourceIds])));
|
|
||||||
setForm((currentForm) => {
|
|
||||||
const items = (currentForm.items || []).filter(
|
|
||||||
(item) => !createdResourceIds.has(String(item.resourceId)),
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
...currentForm,
|
|
||||||
items,
|
|
||||||
resourceId: items[0]?.resourceId || "",
|
|
||||||
};
|
|
||||||
});
|
|
||||||
shouldReload = true;
|
|
||||||
showToast(`已创建 ${createdDrafts.length} 个礼物,剩余礼物创建失败:${err.message || "操作失败"}`, "error");
|
|
||||||
} else {
|
|
||||||
showToast(err.message || "操作失败", "error");
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingAction("");
|
setLoadingAction("");
|
||||||
}
|
}
|
||||||
if (shouldReload) {
|
|
||||||
try {
|
|
||||||
await result.reload();
|
|
||||||
} catch (err) {
|
|
||||||
showToast(err.message || "刷新礼物列表失败", "error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleGift = async (gift, nextEnabled = gift.status !== "active") => {
|
const toggleGift = async (gift, nextEnabled = gift.status !== "active") => {
|
||||||
@ -1668,8 +1389,6 @@ export function useGiftListPage() {
|
|||||||
giftTypeForm,
|
giftTypeForm,
|
||||||
giftTypeOptions: giftTypes,
|
giftTypeOptions: giftTypes,
|
||||||
giftTypesLoading,
|
giftTypesLoading,
|
||||||
giftResourceGiftOptions,
|
|
||||||
existingGiftResourceIds,
|
|
||||||
loadingAction,
|
loadingAction,
|
||||||
loadingRegions,
|
loadingRegions,
|
||||||
openCreateGift,
|
openCreateGift,
|
||||||
@ -1682,15 +1401,12 @@ export function useGiftListPage() {
|
|||||||
selectedGift,
|
selectedGift,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
selectGiftResource,
|
selectGiftResource,
|
||||||
selectGiftResources,
|
|
||||||
removeGiftDraft,
|
|
||||||
setForm,
|
setForm,
|
||||||
saveGiftSortOrder,
|
saveGiftSortOrder,
|
||||||
submitGiftTypes,
|
submitGiftTypes,
|
||||||
status,
|
status,
|
||||||
submitGift,
|
submitGift,
|
||||||
toggleGift,
|
toggleGift,
|
||||||
updateGiftDraft,
|
|
||||||
updateGiftTypeFormItem,
|
updateGiftTypeFormItem,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -1944,14 +1660,13 @@ export function useResourceGrantListPage() {
|
|||||||
!abilities.canRevokeGrant ||
|
!abilities.canRevokeGrant ||
|
||||||
!grant?.grantId ||
|
!grant?.grantId ||
|
||||||
grant.status !== "succeeded" ||
|
grant.status !== "succeeded" ||
|
||||||
!isRevocableGrantSubject(grant.grantSubjectType)
|
grant.grantSubjectType !== "resource_group"
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const accepted = await confirm({
|
const accepted = await confirm({
|
||||||
title: "撤销资源赠送",
|
title: "撤销资源组赠送",
|
||||||
message:
|
message: "撤销后会扣回金币并移除资源组内装扮。该操作不可自动恢复。",
|
||||||
"撤销后会按原发放内容扣回资源:素材扣减对应有效天数,金币扣减对应数量,最低扣至 0。该操作不可自动恢复。",
|
|
||||||
confirmText: "撤销",
|
confirmText: "撤销",
|
||||||
tone: "danger",
|
tone: "danger",
|
||||||
});
|
});
|
||||||
@ -1961,7 +1676,7 @@ export function useResourceGrantListPage() {
|
|||||||
setLoadingAction(`grant-revoke-${grant.grantId}`);
|
setLoadingAction(`grant-revoke-${grant.grantId}`);
|
||||||
try {
|
try {
|
||||||
await revokeResourceGrant(grant.grantId);
|
await revokeResourceGrant(grant.grantId);
|
||||||
showToast("资源赠送已撤销", "success");
|
showToast("资源组赠送已撤销", "success");
|
||||||
await result.reload();
|
await result.reload();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message || "撤销失败", "error");
|
showToast(err.message || "撤销失败", "error");
|
||||||
@ -2005,14 +1720,14 @@ export function useResourceGrantListPage() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function sharedPageState({ data, page, query, reload, result, setPage, setQuery }) {
|
function sharedPageState({ page, query, result, setPage, setQuery }) {
|
||||||
return {
|
return {
|
||||||
data: data || result.data || emptyData,
|
data: result.data || emptyData,
|
||||||
error: result.error,
|
error: result.error,
|
||||||
loading: result.loading,
|
loading: result.loading,
|
||||||
page,
|
page,
|
||||||
query,
|
query,
|
||||||
reload: reload || result.reload,
|
reload: result.reload,
|
||||||
setPage,
|
setPage,
|
||||||
changeQuery: resetSetter(setQuery, setPage),
|
changeQuery: resetSetter(setQuery, setPage),
|
||||||
};
|
};
|
||||||
@ -2366,10 +2081,6 @@ function grantResourceIds(form) {
|
|||||||
return form.resourceId ? [form.resourceId] : [];
|
return form.resourceId ? [form.resourceId] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRevocableGrantSubject(subjectType) {
|
|
||||||
return subjectType === "resource" || subjectType === "resource_group";
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeCommandId(prefix) {
|
function makeCommandId(prefix) {
|
||||||
return `${prefix}-${Date.now()}`;
|
return `${prefix}-${Date.now()}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import { expect, test, vi } from "vitest";
|
|||||||
import {
|
import {
|
||||||
applyGiftPriceDefaults,
|
applyGiftPriceDefaults,
|
||||||
applyGiftResourceSelection,
|
applyGiftResourceSelection,
|
||||||
buildGiftResourceDrafts,
|
|
||||||
cpRelationTypeFromGift,
|
cpRelationTypeFromGift,
|
||||||
cpRelationTypeFromPresentationJson,
|
cpRelationTypeFromPresentationJson,
|
||||||
cpRelationTypeShortLabel,
|
cpRelationTypeShortLabel,
|
||||||
@ -48,76 +47,6 @@ test("fills gift price without gift points", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("builds gift drafts from multiple selected resources and preserves edits", () => {
|
|
||||||
const form = {
|
|
||||||
chargeAssetType: "COIN",
|
|
||||||
cpRelationType: "",
|
|
||||||
effectTypes: ["animation"],
|
|
||||||
effectiveFrom: "",
|
|
||||||
effectiveTo: "",
|
|
||||||
enabled: true,
|
|
||||||
giftTypeCode: "normal",
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
chargeAssetType: "COIN",
|
|
||||||
coinPrice: "88",
|
|
||||||
effectTypes: [],
|
|
||||||
effectiveFrom: "",
|
|
||||||
effectiveTo: "",
|
|
||||||
enabled: false,
|
|
||||||
giftId: "custom_rose",
|
|
||||||
giftTypeCode: "normal",
|
|
||||||
name: "Custom Rose",
|
|
||||||
presentationJson: "{}",
|
|
||||||
priceVersion: "default",
|
|
||||||
regionIds: ["0"],
|
|
||||||
resourceId: "11",
|
|
||||||
sortOrder: "7",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
presentationJson: "{}",
|
|
||||||
priceVersion: "default",
|
|
||||||
regionIds: ["0", "1001"],
|
|
||||||
sortOrder: "3",
|
|
||||||
};
|
|
||||||
|
|
||||||
const drafts = buildGiftResourceDrafts(form, ["11", "12"], [
|
|
||||||
{
|
|
||||||
coinPrice: 10,
|
|
||||||
name: "Rose",
|
|
||||||
priceType: "coin",
|
|
||||||
resourceCode: "gift_rose",
|
|
||||||
resourceId: 11,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
coinPrice: 20,
|
|
||||||
name: "Ring",
|
|
||||||
priceType: "coin",
|
|
||||||
resourceCode: "gift_ring",
|
|
||||||
resourceId: 12,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(drafts).toHaveLength(2);
|
|
||||||
expect(drafts[0]).toMatchObject({
|
|
||||||
coinPrice: "88",
|
|
||||||
giftId: "custom_rose",
|
|
||||||
name: "Custom Rose",
|
|
||||||
resourceId: "11",
|
|
||||||
sortOrder: "7",
|
|
||||||
});
|
|
||||||
expect(drafts[1]).toMatchObject({
|
|
||||||
coinPrice: "20",
|
|
||||||
effectTypes: ["animation"],
|
|
||||||
giftId: "12",
|
|
||||||
giftTypeCode: "normal",
|
|
||||||
name: "Ring",
|
|
||||||
regionIds: ["0", "1001"],
|
|
||||||
resourceId: "12",
|
|
||||||
sortOrder: "3",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("fetches all option pages instead of only the first resource page", async () => {
|
test("fetches all option pages instead of only the first resource page", async () => {
|
||||||
const fetcher = vi
|
const fetcher = vi
|
||||||
.fn()
|
.fn()
|
||||||
|
|||||||
@ -21,9 +21,6 @@ const videoFrameStepSeconds = 1 / 24;
|
|||||||
const maxSeekFrameCount = 360;
|
const maxSeekFrameCount = 360;
|
||||||
const videoLoadTimeoutMs = 8000;
|
const videoLoadTimeoutMs = 8000;
|
||||||
const videoSeekTimeoutMs = 5000;
|
const videoSeekTimeoutMs = 5000;
|
||||||
const mp4BoxHeaderSize = 8;
|
|
||||||
const mp4LargeBoxHeaderSize = 16;
|
|
||||||
const vapcMarkerBytes = [118, 97, 112, 99];
|
|
||||||
|
|
||||||
export function isMp4File(file) {
|
export function isMp4File(file) {
|
||||||
if (!file?.name) {
|
if (!file?.name) {
|
||||||
@ -173,10 +170,6 @@ export async function detectMp4AlphaLayout(file) {
|
|||||||
if (!isMp4File(file)) {
|
if (!isMp4File(file)) {
|
||||||
throw new Error("请选择 MP4 动效素材");
|
throw new Error("请选择 MP4 动效素材");
|
||||||
}
|
}
|
||||||
const vapcLayout = await detectVapcMp4AlphaLayout(file);
|
|
||||||
if (vapcLayout) {
|
|
||||||
return vapcLayout;
|
|
||||||
}
|
|
||||||
const frames = await readVideoFrames(file);
|
const frames = await readVideoFrames(file);
|
||||||
if (!frames.length) {
|
if (!frames.length) {
|
||||||
const info = await readMp4VideoInfo(file);
|
const info = await readMp4VideoInfo(file);
|
||||||
@ -190,44 +183,6 @@ export async function detectMp4AlphaLayout(file) {
|
|||||||
return detectMp4AlphaLayoutFromFrames(frames);
|
return detectMp4AlphaLayoutFromFrames(frames);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function detectVapcMp4AlphaLayout(file) {
|
|
||||||
const info = await parseVapcInfo(file).catch(() => null);
|
|
||||||
if (!info || typeof info !== "object") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return createMp4AlphaLayout({
|
|
||||||
alphaLayout: "custom",
|
|
||||||
alphaFrame: info.aFrame ?? info.a_frame,
|
|
||||||
confirmed: false,
|
|
||||||
confidence: 0.99,
|
|
||||||
rgbFrame: info.rgbFrame ?? info.rgb_frame,
|
|
||||||
videoH: info.videoH ?? info.video_height,
|
|
||||||
videoW: info.videoW ?? info.video_width,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function parseVapcInfo(file) {
|
|
||||||
if (!file?.arrayBuffer) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
||||||
const boxPayload = findMp4BoxPayload(bytes, vapcMarkerBytes);
|
|
||||||
return boxPayload ? parseVapcJsonPayload(boxPayload, 0, boxPayload.length) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseVapcJsonPayload(bytes, start, end) {
|
|
||||||
const jsonStart = findByte(bytes, 123, start, end);
|
|
||||||
if (jsonStart < 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const jsonEnd = findJsonObjectEnd(bytes, jsonStart, end);
|
|
||||||
if (jsonEnd <= jsonStart) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const payload = JSON.parse(new TextDecoder().decode(bytes.slice(jsonStart, jsonEnd + 1)));
|
|
||||||
return payload?.info || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function mp4FileFromSource(source) {
|
export async function mp4FileFromSource(source) {
|
||||||
const normalizedSource = String(source || "").trim();
|
const normalizedSource = String(source || "").trim();
|
||||||
if (!isMp4Source(normalizedSource)) {
|
if (!isMp4Source(normalizedSource)) {
|
||||||
@ -398,90 +353,6 @@ function parseFrameText(value) {
|
|||||||
return values.length >= 4 && values.every(Number.isFinite) ? values.slice(0, 4) : null;
|
return values.length >= 4 && values.every(Number.isFinite) ? values.slice(0, 4) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findMp4BoxPayload(bytes, boxType) {
|
|
||||||
let offset = 0;
|
|
||||||
while (offset + mp4BoxHeaderSize <= bytes.length) {
|
|
||||||
const size = readUint32(bytes, offset);
|
|
||||||
const headerSize = size === 1 ? mp4LargeBoxHeaderSize : mp4BoxHeaderSize;
|
|
||||||
if (offset + headerSize > bytes.length) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const boxEnd = size === 0 ? bytes.length : offset + (size === 1 ? readUint64(bytes, offset + 8) : size);
|
|
||||||
if (boxEnd < offset + headerSize || boxEnd > bytes.length) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (bytesMatchAt(bytes, boxType, offset + 4)) {
|
|
||||||
return bytes.slice(offset + headerSize, boxEnd);
|
|
||||||
}
|
|
||||||
offset = boxEnd;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function bytesMatchAt(bytes, pattern, offset) {
|
|
||||||
if (!bytes?.length || !pattern?.length || offset < 0 || offset + pattern.length > bytes.length) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
for (let index = 0; index < pattern.length; index += 1) {
|
|
||||||
if (bytes[offset + index] !== pattern[index]) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readUint32(bytes, offset) {
|
|
||||||
return bytes[offset] * 2 ** 24 + bytes[offset + 1] * 2 ** 16 + bytes[offset + 2] * 2 ** 8 + bytes[offset + 3];
|
|
||||||
}
|
|
||||||
|
|
||||||
function readUint64(bytes, offset) {
|
|
||||||
const value = readUint32(bytes, offset) * 2 ** 32 + readUint32(bytes, offset + 4);
|
|
||||||
return Number.isSafeInteger(value) ? value : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findByte(bytes, target, start, end) {
|
|
||||||
for (let index = Math.max(0, start); index < Math.min(bytes.length, end); index += 1) {
|
|
||||||
if (bytes[index] === target) {
|
|
||||||
return index;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findJsonObjectEnd(bytes, start, end) {
|
|
||||||
let depth = 0;
|
|
||||||
let inString = false;
|
|
||||||
let escaping = false;
|
|
||||||
for (let index = start; index < Math.min(bytes.length, end); index += 1) {
|
|
||||||
const byte = bytes[index];
|
|
||||||
if (inString) {
|
|
||||||
if (escaping) {
|
|
||||||
escaping = false;
|
|
||||||
} else if (byte === 92) {
|
|
||||||
escaping = true;
|
|
||||||
} else if (byte === 34) {
|
|
||||||
inString = false;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (byte === 34) {
|
|
||||||
inString = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (byte === 123) {
|
|
||||||
depth += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (byte === 125) {
|
|
||||||
depth -= 1;
|
|
||||||
if (depth === 0) {
|
|
||||||
return index;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function filenameFromSource(source) {
|
function filenameFromSource(source) {
|
||||||
try {
|
try {
|
||||||
const url = new URL(source, "https://admin.local");
|
const url = new URL(source, "https://admin.local");
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import { afterEach, describe, expect, test, vi } from "vitest";
|
|||||||
import {
|
import {
|
||||||
confirmMp4AlphaLayoutMetadata,
|
confirmMp4AlphaLayoutMetadata,
|
||||||
createMp4AlphaLayout,
|
createMp4AlphaLayout,
|
||||||
detectMp4AlphaLayout,
|
|
||||||
detectMp4AlphaLayoutFromFrames,
|
detectMp4AlphaLayoutFromFrames,
|
||||||
isMp4Source,
|
isMp4Source,
|
||||||
mp4AlphaLayoutFromMetadata,
|
mp4AlphaLayoutFromMetadata,
|
||||||
@ -159,154 +158,8 @@ describe("mp4 alpha layout metadata", () => {
|
|||||||
video_w: 1500,
|
video_w: 1500,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("uses VAPC payload info as a custom transparent MP4 layout", async () => {
|
|
||||||
const createElementSpy = vi.spyOn(document, "createElement").mockImplementation((tagName, options) => {
|
|
||||||
if (String(tagName).toLowerCase() === "video") {
|
|
||||||
throw new Error("VAPC payload should be parsed without decoding video frames");
|
|
||||||
}
|
|
||||||
return Document.prototype.createElement.call(document, tagName, options);
|
|
||||||
});
|
|
||||||
const file = buildVapcMp4File({
|
|
||||||
info: {
|
|
||||||
aFrame: [724, 0, 360, 780],
|
|
||||||
rgbFrame: [0, 0, 720, 1560],
|
|
||||||
videoH: 1568,
|
|
||||||
videoW: 1088,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const layout = await detectMp4AlphaLayout(file);
|
|
||||||
|
|
||||||
expect(layout?.alpha_layout).not.toBe("normal");
|
|
||||||
expect(layout).toMatchObject({
|
|
||||||
alpha_frame: [724, 0, 360, 780],
|
|
||||||
alpha_layout: "custom",
|
|
||||||
rgb_frame: [0, 0, 720, 1560],
|
|
||||||
video_h: 1568,
|
|
||||||
video_w: 1088,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
createElementSpy.mockRestore();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("ignores VAPC-looking bytes outside a real MP4 box and falls back to frame detection", async () => {
|
|
||||||
const restoreMockVideo = mockVideoFrameRead({ height: 30, width: 20 });
|
|
||||||
const file = buildMdatWithVapcBytesFile({
|
|
||||||
info: {
|
|
||||||
aFrame: [724, 0, 360, 780],
|
|
||||||
rgbFrame: [0, 0, 720, 1560],
|
|
||||||
videoH: 1568,
|
|
||||||
videoW: 1088,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const layout = await detectMp4AlphaLayout(file);
|
|
||||||
|
|
||||||
expect(layout).toMatchObject({
|
|
||||||
alpha_frame: null,
|
|
||||||
alpha_layout: "normal",
|
|
||||||
rgb_frame: [0, 0, 20, 30],
|
|
||||||
video_h: 30,
|
|
||||||
video_w: 20,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
restoreMockVideo();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function buildVapcMp4File(payload) {
|
|
||||||
return new File(
|
|
||||||
[
|
|
||||||
mp4Box("ftyp", new Uint8Array([105, 115, 111, 109, 0, 0, 0, 1, 105, 115, 111, 109])),
|
|
||||||
mp4Box("vapc", new TextEncoder().encode(JSON.stringify(payload))),
|
|
||||||
],
|
|
||||||
"vapc-layout.mp4",
|
|
||||||
{ type: "video/mp4" },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildMdatWithVapcBytesFile(payload) {
|
|
||||||
return new File(
|
|
||||||
[
|
|
||||||
mp4Box("ftyp", new Uint8Array([105, 115, 111, 109, 0, 0, 0, 1, 105, 115, 111, 109])),
|
|
||||||
mp4Box("mdat", new TextEncoder().encode(`frame bytes vapc ${JSON.stringify(payload)}`)),
|
|
||||||
],
|
|
||||||
"ordinary-with-vapc-bytes.mp4",
|
|
||||||
{ type: "video/mp4" },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function mp4Box(type, payload) {
|
|
||||||
const bytes = new Uint8Array(8 + payload.length);
|
|
||||||
const view = new DataView(bytes.buffer);
|
|
||||||
view.setUint32(0, bytes.length);
|
|
||||||
for (let index = 0; index < 4; index += 1) {
|
|
||||||
bytes[index + 4] = type.charCodeAt(index);
|
|
||||||
}
|
|
||||||
bytes.set(payload, 8);
|
|
||||||
return bytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mockVideoFrameRead({ width, height }) {
|
|
||||||
const originalCreateElement = document.createElement.bind(document);
|
|
||||||
const createElementSpy = vi.spyOn(document, "createElement").mockImplementation((tagName, options) => {
|
|
||||||
const normalizedTag = String(tagName).toLowerCase();
|
|
||||||
if (normalizedTag === "video") {
|
|
||||||
return mockVideoElement({ height, width });
|
|
||||||
}
|
|
||||||
if (normalizedTag === "canvas") {
|
|
||||||
const canvas = originalCreateElement(tagName, options);
|
|
||||||
canvas.getContext = vi.fn(() => ({
|
|
||||||
drawImage: vi.fn(),
|
|
||||||
getImageData: vi.fn((left, top, canvasWidth, canvasHeight) => ({
|
|
||||||
data: new Uint8ClampedArray(canvasWidth * canvasHeight * 4),
|
|
||||||
height: canvasHeight,
|
|
||||||
width: canvasWidth,
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
return canvas;
|
|
||||||
}
|
|
||||||
return originalCreateElement(tagName, options);
|
|
||||||
});
|
|
||||||
vi.stubGlobal("URL", {
|
|
||||||
createObjectURL: vi.fn(() => "blob:mock-video"),
|
|
||||||
revokeObjectURL: vi.fn(),
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
createElementSpy.mockRestore();
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mockVideoElement({ width, height }) {
|
|
||||||
return {
|
|
||||||
currentTime: 0,
|
|
||||||
duration: 0,
|
|
||||||
load: vi.fn(),
|
|
||||||
muted: false,
|
|
||||||
onerror: null,
|
|
||||||
playsInline: false,
|
|
||||||
preload: "",
|
|
||||||
readyState: 2,
|
|
||||||
removeAttribute: vi.fn(),
|
|
||||||
src: "",
|
|
||||||
videoHeight: height,
|
|
||||||
videoWidth: width,
|
|
||||||
addEventListener: vi.fn(),
|
|
||||||
removeEventListener: vi.fn(),
|
|
||||||
set onloadedmetadata(handler) {
|
|
||||||
if (typeof handler === "function") {
|
|
||||||
queueMicrotask(handler);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSyntheticFrame(width, height, { alphaFrame, rgbFrame }) {
|
function buildSyntheticFrame(width, height, { alphaFrame, rgbFrame }) {
|
||||||
const data = new Uint8ClampedArray(width * height * 4);
|
const data = new Uint8ClampedArray(width * height * 4);
|
||||||
fillRect(data, width, [0, 0, width, height], [32, 32, 32]);
|
fillRect(data, width, [0, 0, width, height], [32, 32, 32]);
|
||||||
|
|||||||
@ -43,7 +43,6 @@ import {
|
|||||||
resourceStatusFilters,
|
resourceStatusFilters,
|
||||||
} from "@/features/resources/constants.js";
|
} from "@/features/resources/constants.js";
|
||||||
import { GiftTypeConfigDialog } from "@/features/resources/components/GiftTypeConfigDialog.jsx";
|
import { GiftTypeConfigDialog } from "@/features/resources/components/GiftTypeConfigDialog.jsx";
|
||||||
import { GiftResourceSelectField } from "@/features/resources/components/GiftResourceSelectDrawer.jsx";
|
|
||||||
import { ResourceSelectField } from "@/features/resources/components/ResourceSelectDrawer.jsx";
|
import { ResourceSelectField } from "@/features/resources/components/ResourceSelectDrawer.jsx";
|
||||||
import {
|
import {
|
||||||
cpRelationTypeFromGift,
|
cpRelationTypeFromGift,
|
||||||
@ -138,7 +137,7 @@ const baseColumns = (giftTypeOptions) => [
|
|||||||
|
|
||||||
export function GiftListPage() {
|
export function GiftListPage() {
|
||||||
const page = useGiftListPage();
|
const page = useGiftListPage();
|
||||||
const items = useMemo(() => page.data.items || [], [page.data.items]);
|
const items = page.data.items || [];
|
||||||
const total = page.data.total || 0;
|
const total = page.data.total || 0;
|
||||||
const [selectedGiftIds, setSelectedGiftIds] = useState([]);
|
const [selectedGiftIds, setSelectedGiftIds] = useState([]);
|
||||||
const isEditing = page.activeAction === "edit";
|
const isEditing = page.activeAction === "edit";
|
||||||
@ -420,14 +419,7 @@ function GiftSortOrderCell({ gift, page }) {
|
|||||||
function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open, page, regionOptions }) {
|
function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open, page, regionOptions }) {
|
||||||
const submitDisabled =
|
const submitDisabled =
|
||||||
disabled || loading || page.resourceOptionsLoading || page.loadingRegions || page.giftTypesLoading;
|
disabled || loading || page.resourceOptionsLoading || page.loadingRegions || page.giftTypesLoading;
|
||||||
const isCreateMode = mode === "create";
|
const showGiftIdentityFields = mode === "edit" || Boolean(form.resourceId);
|
||||||
const giftDrafts = Array.isArray(form.items) ? form.items : [];
|
|
||||||
const selectedResourceIds = isCreateMode
|
|
||||||
? giftDrafts.map((item) => item.resourceId).filter(Boolean)
|
|
||||||
: form.resourceId
|
|
||||||
? [form.resourceId]
|
|
||||||
: [];
|
|
||||||
const showGiftIdentityFields = mode === "edit" && Boolean(form.resourceId);
|
|
||||||
const isCPGiftType = form.giftTypeCode === "cp";
|
const isCPGiftType = form.giftTypeCode === "cp";
|
||||||
return (
|
return (
|
||||||
<AdminFormDialog
|
<AdminFormDialog
|
||||||
@ -441,30 +433,15 @@ function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
|
|||||||
>
|
>
|
||||||
<AdminFormSection title="资源信息">
|
<AdminFormSection title="资源信息">
|
||||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||||
{isCreateMode ? (
|
<ResourceSelectField
|
||||||
<GiftResourceSelectField
|
disabled={disabled || page.resourceOptionsLoading}
|
||||||
disabled={disabled || page.resourceOptionsLoading}
|
drawerTitle="选择礼物资源"
|
||||||
drawerTitle="选择礼物资源"
|
label="礼物资源"
|
||||||
excludedResourceIds={page.existingGiftResourceIds}
|
loading={page.resourceOptionsLoading}
|
||||||
giftTypes={page.giftTypeOptions}
|
resources={page.resourceOptions}
|
||||||
gifts={page.giftResourceGiftOptions}
|
value={form.resourceId}
|
||||||
label="礼物资源"
|
onChange={(resourceId) => page.selectGiftResource(resourceId)}
|
||||||
loading={page.resourceOptionsLoading}
|
/>
|
||||||
resources={page.resourceOptions}
|
|
||||||
selectedResourceIds={selectedResourceIds}
|
|
||||||
onChange={page.selectGiftResources}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ResourceSelectField
|
|
||||||
disabled={disabled || page.resourceOptionsLoading}
|
|
||||||
drawerTitle="选择礼物资源"
|
|
||||||
label="礼物资源"
|
|
||||||
loading={page.resourceOptionsLoading}
|
|
||||||
resources={page.resourceOptions}
|
|
||||||
value={form.resourceId}
|
|
||||||
onChange={(resourceId) => page.selectGiftResource(resourceId)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{showGiftIdentityFields ? (
|
{showGiftIdentityFields ? (
|
||||||
<>
|
<>
|
||||||
<TextField
|
<TextField
|
||||||
@ -483,23 +460,21 @@ function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{!isCreateMode ? (
|
<TextField
|
||||||
<TextField
|
disabled={disabled || page.giftTypesLoading}
|
||||||
disabled={disabled || page.giftTypesLoading}
|
label="礼物类型"
|
||||||
label="礼物类型"
|
required
|
||||||
required
|
select
|
||||||
select
|
value={form.giftTypeCode}
|
||||||
value={form.giftTypeCode}
|
onChange={(event) => page.changeGiftFormTypeCode(event.target.value)}
|
||||||
onChange={(event) => page.changeGiftFormTypeCode(event.target.value)}
|
>
|
||||||
>
|
{page.giftTypeOptions.map((item) => (
|
||||||
{page.giftTypeOptions.map((item) => (
|
<MenuItem key={item.tabKey} value={item.tabKey}>
|
||||||
<MenuItem key={item.tabKey} value={item.tabKey}>
|
{item.displayName || item.tabKey}
|
||||||
{item.displayName || item.tabKey}
|
</MenuItem>
|
||||||
</MenuItem>
|
))}
|
||||||
))}
|
</TextField>
|
||||||
</TextField>
|
{isCPGiftType ? (
|
||||||
) : null}
|
|
||||||
{!isCreateMode && isCPGiftType ? (
|
|
||||||
<TextField
|
<TextField
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
label="关系类型"
|
label="关系类型"
|
||||||
@ -517,216 +492,56 @@ function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
|
|||||||
) : null}
|
) : null}
|
||||||
</AdminFormFieldGrid>
|
</AdminFormFieldGrid>
|
||||||
</AdminFormSection>
|
</AdminFormSection>
|
||||||
{isCreateMode ? (
|
<AdminFormSection title="价格与排序">
|
||||||
<GiftDraftSection
|
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||||
disabled={disabled}
|
<TextField disabled label="价格" required type="number" value={form.coinPrice} />
|
||||||
drafts={giftDrafts}
|
|
||||||
page={page}
|
|
||||||
regionOptions={regionOptions}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<AdminFormSection title="价格与排序">
|
|
||||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
|
||||||
<TextField disabled label="价格" required type="number" value={form.coinPrice} />
|
|
||||||
<TextField
|
|
||||||
disabled={disabled}
|
|
||||||
label="排序"
|
|
||||||
type="number"
|
|
||||||
value={form.sortOrder}
|
|
||||||
onChange={(event) => page.setForm({ ...form, sortOrder: event.target.value })}
|
|
||||||
/>
|
|
||||||
<AdminFormSwitchField
|
|
||||||
checked={form.enabled}
|
|
||||||
checkedLabel="启用"
|
|
||||||
className={styles.giftSwitch}
|
|
||||||
disabled={disabled}
|
|
||||||
label="礼物状态"
|
|
||||||
uncheckedLabel="禁用"
|
|
||||||
onChange={(checked) => page.setForm({ ...form, enabled: checked })}
|
|
||||||
/>
|
|
||||||
</AdminFormFieldGrid>
|
|
||||||
</AdminFormSection>
|
|
||||||
<AdminFormSection title="投放与时间">
|
|
||||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
|
||||||
<GiftRegionSelect
|
|
||||||
disabled={disabled || page.loadingRegions}
|
|
||||||
labels={buildRegionLabelMap(regionOptions)}
|
|
||||||
options={regionOptions}
|
|
||||||
value={form.regionIds}
|
|
||||||
onChange={(regionIds) => page.setForm({ ...form, regionIds })}
|
|
||||||
/>
|
|
||||||
<DateTimeField
|
|
||||||
disabled={disabled}
|
|
||||||
label="有效开始时间"
|
|
||||||
value={form.effectiveFrom}
|
|
||||||
onChange={(effectiveFrom) => page.setForm({ ...form, effectiveFrom })}
|
|
||||||
/>
|
|
||||||
<DateTimeField
|
|
||||||
disabled={disabled}
|
|
||||||
label="有效结束时间"
|
|
||||||
value={form.effectiveTo}
|
|
||||||
onChange={(effectiveTo) => page.setForm({ ...form, effectiveTo })}
|
|
||||||
/>
|
|
||||||
<GiftEffectSelect
|
|
||||||
disabled={disabled}
|
|
||||||
value={form.effectTypes}
|
|
||||||
onChange={(effectTypes) => page.setForm({ ...form, effectTypes })}
|
|
||||||
/>
|
|
||||||
</AdminFormFieldGrid>
|
|
||||||
</AdminFormSection>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</AdminFormDialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function GiftDraftSection({ disabled, drafts, page, regionOptions }) {
|
|
||||||
const regionLabels = buildRegionLabelMap(regionOptions);
|
|
||||||
if (!drafts.length) {
|
|
||||||
return (
|
|
||||||
<AdminFormSection title="礼物配置">
|
|
||||||
<div className={styles.giftDraftEmpty}>当前无数据</div>
|
|
||||||
</AdminFormSection>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AdminFormSection title={`礼物配置(${drafts.length})`}>
|
|
||||||
<div className={styles.giftDraftList}>
|
|
||||||
{drafts.map((draft, index) => (
|
|
||||||
<GiftDraftEditor
|
|
||||||
disabled={disabled}
|
|
||||||
draft={draft}
|
|
||||||
index={index}
|
|
||||||
key={draft.resourceId || index}
|
|
||||||
page={page}
|
|
||||||
regionLabels={regionLabels}
|
|
||||||
regionOptions={regionOptions}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</AdminFormSection>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function GiftDraftEditor({ disabled, draft, index, page, regionLabels, regionOptions }) {
|
|
||||||
const isCPGiftType = draft.giftTypeCode === "cp";
|
|
||||||
const updateDraft = (patch) => page.updateGiftDraft(draft.resourceId, patch);
|
|
||||||
return (
|
|
||||||
<div className={styles.giftDraftItem}>
|
|
||||||
<div className={styles.giftDraftHeader}>
|
|
||||||
<GiftDraftThumb draft={draft} />
|
|
||||||
<div className={styles.giftDraftTitle}>
|
|
||||||
<span>{draft.name || draft.giftId || `礼物 ${index + 1}`}</span>
|
|
||||||
<span>资源 {draft.resourceId || "-"}</span>
|
|
||||||
</div>
|
|
||||||
<AdminActionIconButton
|
|
||||||
disabled={disabled}
|
|
||||||
label="移除礼物"
|
|
||||||
onClick={() => page.removeGiftDraft(draft.resourceId)}
|
|
||||||
>
|
|
||||||
<DeleteOutlineOutlined fontSize="small" />
|
|
||||||
</AdminActionIconButton>
|
|
||||||
</div>
|
|
||||||
<AdminFormFieldGrid columns="repeat(4, minmax(0, 1fr))">
|
|
||||||
<TextField
|
|
||||||
disabled={disabled}
|
|
||||||
label="礼物名称"
|
|
||||||
required
|
|
||||||
value={draft.name}
|
|
||||||
onChange={(event) => updateDraft({ name: event.target.value })}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
disabled={disabled}
|
|
||||||
label="礼物 ID"
|
|
||||||
required
|
|
||||||
value={draft.giftId}
|
|
||||||
onChange={(event) => updateDraft({ giftId: event.target.value })}
|
|
||||||
/>
|
|
||||||
<TextField disabled label="价格" required type="number" value={draft.coinPrice} />
|
|
||||||
<TextField
|
|
||||||
disabled={disabled}
|
|
||||||
label="排序"
|
|
||||||
type="number"
|
|
||||||
value={draft.sortOrder}
|
|
||||||
onChange={(event) => updateDraft({ sortOrder: event.target.value })}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
disabled={disabled || page.giftTypesLoading}
|
|
||||||
label="礼物类型"
|
|
||||||
required
|
|
||||||
select
|
|
||||||
value={draft.giftTypeCode}
|
|
||||||
onChange={(event) => updateDraft({ giftTypeCode: event.target.value })}
|
|
||||||
>
|
|
||||||
{page.giftTypeOptions.map((item) => (
|
|
||||||
<MenuItem key={item.tabKey} value={item.tabKey}>
|
|
||||||
{item.displayName || item.tabKey}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
|
||||||
{isCPGiftType ? (
|
|
||||||
<TextField
|
<TextField
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
label="关系类型"
|
label="排序"
|
||||||
required
|
type="number"
|
||||||
select
|
value={form.sortOrder}
|
||||||
value={draft.cpRelationType || "cp"}
|
onChange={(event) => page.setForm({ ...form, sortOrder: event.target.value })}
|
||||||
onChange={(event) => updateDraft({ cpRelationType: event.target.value })}
|
/>
|
||||||
>
|
<AdminFormSwitchField
|
||||||
{cpRelationTypeOptions.map(([value, label]) => (
|
checked={form.enabled}
|
||||||
<MenuItem key={value} value={value}>
|
checkedLabel="启用"
|
||||||
{label}
|
className={styles.giftSwitch}
|
||||||
</MenuItem>
|
disabled={disabled}
|
||||||
))}
|
label="礼物状态"
|
||||||
</TextField>
|
uncheckedLabel="禁用"
|
||||||
) : null}
|
onChange={(checked) => page.setForm({ ...form, enabled: checked })}
|
||||||
<GiftRegionSelect
|
/>
|
||||||
disabled={disabled || page.loadingRegions}
|
</AdminFormFieldGrid>
|
||||||
labels={regionLabels}
|
</AdminFormSection>
|
||||||
options={regionOptions}
|
<AdminFormSection title="投放与时间">
|
||||||
value={draft.regionIds}
|
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||||
onChange={(regionIds) => updateDraft({ regionIds })}
|
<GiftRegionSelect
|
||||||
/>
|
disabled={disabled || page.loadingRegions}
|
||||||
<AdminFormSwitchField
|
labels={buildRegionLabelMap(regionOptions)}
|
||||||
checked={draft.enabled}
|
options={regionOptions}
|
||||||
checkedLabel="启用"
|
value={form.regionIds}
|
||||||
className={styles.giftSwitch}
|
onChange={(regionIds) => page.setForm({ ...form, regionIds })}
|
||||||
disabled={disabled}
|
/>
|
||||||
label="礼物状态"
|
<DateTimeField
|
||||||
uncheckedLabel="禁用"
|
disabled={disabled}
|
||||||
onChange={(checked) => updateDraft({ enabled: checked })}
|
label="有效开始时间"
|
||||||
/>
|
value={form.effectiveFrom}
|
||||||
<DateTimeField
|
onChange={(effectiveFrom) => page.setForm({ ...form, effectiveFrom })}
|
||||||
disabled={disabled}
|
/>
|
||||||
label="有效开始时间"
|
<DateTimeField
|
||||||
value={draft.effectiveFrom}
|
disabled={disabled}
|
||||||
onChange={(effectiveFrom) => updateDraft({ effectiveFrom })}
|
label="有效结束时间"
|
||||||
/>
|
value={form.effectiveTo}
|
||||||
<DateTimeField
|
onChange={(effectiveTo) => page.setForm({ ...form, effectiveTo })}
|
||||||
disabled={disabled}
|
/>
|
||||||
label="有效结束时间"
|
<GiftEffectSelect
|
||||||
value={draft.effectiveTo}
|
disabled={disabled}
|
||||||
onChange={(effectiveTo) => updateDraft({ effectiveTo })}
|
value={form.effectTypes}
|
||||||
/>
|
onChange={(effectTypes) => page.setForm({ ...form, effectTypes })}
|
||||||
<GiftEffectSelect
|
/>
|
||||||
disabled={disabled}
|
</AdminFormFieldGrid>
|
||||||
value={draft.effectTypes}
|
</AdminFormSection>
|
||||||
onChange={(effectTypes) => updateDraft({ effectTypes })}
|
</AdminFormDialog>
|
||||||
/>
|
|
||||||
</AdminFormFieldGrid>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function GiftDraftThumb({ draft }) {
|
|
||||||
const resource = draft.resource || {};
|
|
||||||
const imageUrl = imageURL(resource.previewUrl || resource.assetUrl || resource.animationUrl);
|
|
||||||
return (
|
|
||||||
<span className={styles.giftDraftThumb}>
|
|
||||||
{imageUrl ? <img alt="" src={imageUrl} /> : <CardGiftcardOutlined fontSize="small" />}
|
|
||||||
</span>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -174,9 +174,7 @@ export function ResourceGrantListPage() {
|
|||||||
|
|
||||||
function ResourceGrantActions({ grant, page }) {
|
function ResourceGrantActions({ grant, page }) {
|
||||||
const canRevoke =
|
const canRevoke =
|
||||||
page.abilities.canRevokeGrant &&
|
page.abilities.canRevokeGrant && grant?.status === "succeeded" && grant?.grantSubjectType === "resource_group";
|
||||||
grant?.status === "succeeded" &&
|
|
||||||
isRevocableGrantSubject(grant?.grantSubjectType);
|
|
||||||
if (!canRevoke) {
|
if (!canRevoke) {
|
||||||
return <span className={styles.meta}>-</span>;
|
return <span className={styles.meta}>-</span>;
|
||||||
}
|
}
|
||||||
@ -187,7 +185,7 @@ function ResourceGrantActions({ grant, page }) {
|
|||||||
disabled={page.loadingAction === `grant-revoke-${grant.grantId}`}
|
disabled={page.loadingAction === `grant-revoke-${grant.grantId}`}
|
||||||
label="撤销"
|
label="撤销"
|
||||||
sx={dangerActionSx}
|
sx={dangerActionSx}
|
||||||
tooltip="撤销资源赠送"
|
tooltip="撤销资源组赠送"
|
||||||
onClick={() => page.revokeGrant(grant)}
|
onClick={() => page.revokeGrant(grant)}
|
||||||
>
|
>
|
||||||
<UndoOutlined fontSize="small" />
|
<UndoOutlined fontSize="small" />
|
||||||
@ -463,10 +461,6 @@ function subjectMeta(grant) {
|
|||||||
return grant.grantSubjectId ? `${subjectLabel} ID ${grant.grantSubjectId}` : "-";
|
return grant.grantSubjectId ? `${subjectLabel} ID ${grant.grantSubjectId}` : "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRevocableGrantSubject(subjectType) {
|
|
||||||
return subjectType === "resource" || subjectType === "resource_group";
|
|
||||||
}
|
|
||||||
|
|
||||||
function grantItemLabel(item, snapshot = parseGrantItemSnapshot(item.resourceSnapshotJson)) {
|
function grantItemLabel(item, snapshot = parseGrantItemSnapshot(item.resourceSnapshotJson)) {
|
||||||
const walletAssetType = snapshotValue(snapshot, "walletAssetType", "wallet_asset_type", "WalletAssetType");
|
const walletAssetType = snapshotValue(snapshot, "walletAssetType", "wallet_asset_type", "WalletAssetType");
|
||||||
if (walletAssetType) {
|
if (walletAssetType) {
|
||||||
|
|||||||
@ -15,28 +15,19 @@ afterEach(() => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("resource grant list shows revoke for succeeded resource and resource group grants", () => {
|
test("resource grant list shows revoke only for succeeded resource group grants", () => {
|
||||||
const eligibleGroup = grantFixture({
|
const eligible = grantFixture({
|
||||||
grantId: "rgr_group_ok",
|
grantId: "rgr_group_ok",
|
||||||
grantSubjectType: "resource_group",
|
grantSubjectType: "resource_group",
|
||||||
status: "succeeded",
|
status: "succeeded",
|
||||||
});
|
});
|
||||||
const eligibleResource = grantFixture({
|
|
||||||
grantId: "rgr_resource_ok",
|
|
||||||
grantSubjectType: "resource",
|
|
||||||
status: "succeeded",
|
|
||||||
});
|
|
||||||
vi.mocked(useResourceGrantListPage).mockReturnValue(
|
vi.mocked(useResourceGrantListPage).mockReturnValue(
|
||||||
pageFixture({
|
pageFixture({
|
||||||
data: {
|
data: {
|
||||||
items: [
|
items: [
|
||||||
eligibleGroup,
|
eligible,
|
||||||
eligibleResource,
|
grantFixture({ grantId: "rgr_resource_ok", grantSubjectType: "resource", status: "succeeded" }),
|
||||||
grantFixture({
|
grantFixture({ grantId: "rgr_group_revoked", grantSubjectType: "resource_group", status: "revoked" }),
|
||||||
grantId: "rgr_group_revoked",
|
|
||||||
grantSubjectType: "resource_group",
|
|
||||||
status: "revoked",
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 50,
|
pageSize: 50,
|
||||||
@ -49,13 +40,11 @@ test("resource grant list shows revoke for succeeded resource and resource group
|
|||||||
|
|
||||||
expect(screen.getByText("已撤销")).toBeInTheDocument();
|
expect(screen.getByText("已撤销")).toBeInTheDocument();
|
||||||
const revokeButtons = screen.getAllByLabelText("撤销");
|
const revokeButtons = screen.getAllByLabelText("撤销");
|
||||||
expect(revokeButtons).toHaveLength(2);
|
expect(revokeButtons).toHaveLength(1);
|
||||||
|
|
||||||
fireEvent.click(revokeButtons[0]);
|
fireEvent.click(revokeButtons[0]);
|
||||||
fireEvent.click(revokeButtons[1]);
|
|
||||||
|
|
||||||
expect(mocks.revokeGrant).toHaveBeenNthCalledWith(1, eligibleGroup);
|
expect(mocks.revokeGrant).toHaveBeenCalledWith(eligible);
|
||||||
expect(mocks.revokeGrant).toHaveBeenNthCalledWith(2, eligibleResource);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("resource grant list hides revoke without permission", () => {
|
test("resource grant list hides revoke without permission", () => {
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import Add from "@mui/icons-material/Add";
|
import Add from "@mui/icons-material/Add";
|
||||||
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
|
||||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||||
import FileUploadOutlined from "@mui/icons-material/FileUploadOutlined";
|
import FileUploadOutlined from "@mui/icons-material/FileUploadOutlined";
|
||||||
import HelpOutlineOutlined from "@mui/icons-material/HelpOutlineOutlined";
|
import HelpOutlineOutlined from "@mui/icons-material/HelpOutlineOutlined";
|
||||||
@ -164,9 +163,6 @@ export function ResourceListPage() {
|
|||||||
const selectedOnPageCount = items.filter((resource) => selectedIdSet.has(resource.resourceId)).length;
|
const selectedOnPageCount = items.filter((resource) => selectedIdSet.has(resource.resourceId)).length;
|
||||||
const someChecked = selectedOnPageCount > 0 && !allChecked;
|
const someChecked = selectedOnPageCount > 0 && !allChecked;
|
||||||
const batchMp4Running = batchMp4Progress.running;
|
const batchMp4Running = batchMp4Progress.running;
|
||||||
const deletingResources = page.loadingAction.startsWith("resource-delete-");
|
|
||||||
const deletingBatch = page.loadingAction === "resource-delete-batch";
|
|
||||||
const canSelectResources = page.abilities.canUpdate || page.abilities.canDelete;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const itemIds = new Set(items.map((resource) => resource.resourceId));
|
const itemIds = new Set(items.map((resource) => resource.resourceId));
|
||||||
@ -200,10 +196,9 @@ export function ResourceListPage() {
|
|||||||
header: (
|
header: (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={allChecked}
|
checked={allChecked}
|
||||||
disabled={!canSelectResources || items.length === 0 || batchMp4Running || deletingResources}
|
disabled={!page.abilities.canUpdate || items.length === 0 || batchMp4Running}
|
||||||
indeterminate={someChecked}
|
indeterminate={someChecked}
|
||||||
size="small"
|
size="small"
|
||||||
slotProps={{ input: { "aria-label": "选择当前页资源" } }}
|
|
||||||
onChange={(event) => toggleAllResources(event.target.checked)}
|
onChange={(event) => toggleAllResources(event.target.checked)}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@ -213,10 +208,8 @@ export function ResourceListPage() {
|
|||||||
return (
|
return (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedIdSet.has(resource.resourceId)}
|
checked={selectedIdSet.has(resource.resourceId)}
|
||||||
disabled={!canSelectResources || batchMp4Running || deletingResources}
|
disabled={!page.abilities.canUpdate || batchMp4Running}
|
||||||
slotProps={{
|
inputProps={{ "aria-label": `选择 ${resource.name || resource.resourceCode || resource.resourceId}` }}
|
||||||
input: { "aria-label": `选择 ${resource.name || resource.resourceCode || resource.resourceId}` },
|
|
||||||
}}
|
|
||||||
size="small"
|
size="small"
|
||||||
onChange={(event) => toggleResourceSelection(resource, event.target.checked)}
|
onChange={(event) => toggleResourceSelection(resource, event.target.checked)}
|
||||||
/>
|
/>
|
||||||
@ -264,13 +257,7 @@ export function ResourceListPage() {
|
|||||||
: column.key === "actions"
|
: column.key === "actions"
|
||||||
? {
|
? {
|
||||||
...column,
|
...column,
|
||||||
render: (resource) => (
|
render: (resource) => <ResourceRowActions page={page} resource={resource} />,
|
||||||
<ResourceRowActions
|
|
||||||
deleteDisabled={batchMp4Running || Boolean(mp4BatchResult)}
|
|
||||||
page={page}
|
|
||||||
resource={resource}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
: column,
|
: column,
|
||||||
),
|
),
|
||||||
@ -407,18 +394,6 @@ export function ResourceListPage() {
|
|||||||
setMp4BatchResult({ ignoredCount, layoutCounts, rows: updates });
|
setMp4BatchResult({ ignoredCount, layoutCounts, rows: updates });
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteSelectedResources = async () => {
|
|
||||||
if (!page.abilities.canDelete || !selectedResources.length || deletingResources) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const deletedResourceIds = await page.deleteResources(selectedResources);
|
|
||||||
if (!deletedResourceIds.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const deletedIdSet = new Set(deletedResourceIds);
|
|
||||||
setSelectedResourceIds((current) => current.filter((resourceId) => !deletedIdSet.has(resourceId)));
|
|
||||||
};
|
|
||||||
|
|
||||||
const writeMp4BatchResult = async () => {
|
const writeMp4BatchResult = async () => {
|
||||||
const rows = mp4BatchResult?.rows || [];
|
const rows = mp4BatchResult?.rows || [];
|
||||||
if (!rows.length || batchMp4Running) {
|
if (!rows.length || batchMp4Running) {
|
||||||
@ -529,24 +504,11 @@ export function ResourceListPage() {
|
|||||||
<AdminListPage>
|
<AdminListPage>
|
||||||
<AdminListToolbar
|
<AdminListToolbar
|
||||||
actions={
|
actions={
|
||||||
page.abilities.canCreate || page.abilities.canUpdate || page.abilities.canDelete ? (
|
page.abilities.canCreate || page.abilities.canUpdate ? (
|
||||||
<>
|
<>
|
||||||
{page.abilities.canDelete ? (
|
|
||||||
<AdminActionIconButton
|
|
||||||
disabled={!selectedResources.length || deletingResources || batchMp4Running}
|
|
||||||
label={deletingBatch ? "批量删除中" : `批量删除资源(${selectedResources.length})`}
|
|
||||||
onClick={deleteSelectedResources}
|
|
||||||
>
|
|
||||||
{deletingBatch ? (
|
|
||||||
<CircularProgress size={16} />
|
|
||||||
) : (
|
|
||||||
<DeleteOutlineOutlined fontSize="small" />
|
|
||||||
)}
|
|
||||||
</AdminActionIconButton>
|
|
||||||
) : null}
|
|
||||||
{page.abilities.canUpdate ? (
|
{page.abilities.canUpdate ? (
|
||||||
<AdminActionIconButton
|
<AdminActionIconButton
|
||||||
disabled={!selectedResources.length || batchMp4Running || deletingResources}
|
disabled={!selectedResources.length || batchMp4Running}
|
||||||
label={
|
label={
|
||||||
batchMp4Running
|
batchMp4Running
|
||||||
? batchMp4Progress.label || "批量重算 MP4"
|
? batchMp4Progress.label || "批量重算 MP4"
|
||||||
@ -923,14 +885,11 @@ function Mp4BatchResultDialog({ loading, onClose, onSubmit, open, result }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResourceRowActions({ deleteDisabled = false, page, resource }) {
|
function ResourceRowActions({ page, resource }) {
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [recalculating, setRecalculating] = useState(false);
|
const [recalculating, setRecalculating] = useState(false);
|
||||||
const mp4AnimationSource = mp4ResourceAnimationSource(resource);
|
const mp4AnimationSource = mp4ResourceAnimationSource(resource);
|
||||||
const loading = recalculating || page.loadingAction === `resource-mp4-layout-${resource.resourceId}`;
|
const loading = recalculating || page.loadingAction === `resource-mp4-layout-${resource.resourceId}`;
|
||||||
const deleting = page.loadingAction === `resource-delete-${resource.resourceId}`;
|
|
||||||
const deletingBatch = page.loadingAction === "resource-delete-batch";
|
|
||||||
const deletingResources = page.loadingAction.startsWith("resource-delete-");
|
|
||||||
|
|
||||||
const recalculateMp4Layout = async () => {
|
const recalculateMp4Layout = async () => {
|
||||||
if (!mp4AnimationSource || !page.abilities.canUpdate || loading) {
|
if (!mp4AnimationSource || !page.abilities.canUpdate || loading) {
|
||||||
@ -968,15 +927,6 @@ function ResourceRowActions({ deleteDisabled = false, page, resource }) {
|
|||||||
>
|
>
|
||||||
<EditOutlined fontSize="small" />
|
<EditOutlined fontSize="small" />
|
||||||
</AdminActionIconButton>
|
</AdminActionIconButton>
|
||||||
{page.abilities.canDelete ? (
|
|
||||||
<AdminActionIconButton
|
|
||||||
disabled={deleteDisabled || deletingResources || deletingBatch}
|
|
||||||
label={deleting ? "删除中" : "删除资源"}
|
|
||||||
onClick={() => page.deleteResources([resource])}
|
|
||||||
>
|
|
||||||
{deleting ? <CircularProgress size={16} /> : <DeleteOutlineOutlined fontSize="small" />}
|
|
||||||
</AdminActionIconButton>
|
|
||||||
) : null}
|
|
||||||
</AdminRowActions>
|
</AdminRowActions>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,150 +0,0 @@
|
|||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
||||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
|
||||||
import { ResourceListPage } from "./ResourceListPage.jsx";
|
|
||||||
import { useResourceListPage } from "@/features/resources/hooks/useResourcePages.js";
|
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
|
||||||
deleteResources: vi.fn(),
|
|
||||||
showToast: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/features/resources/hooks/useResourcePages.js", () => ({
|
|
||||||
useResourceListPage: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/shared/ui/ToastProvider.jsx", () => ({
|
|
||||||
useToast: () => ({ showToast: mocks.showToast }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
globalThis.ResizeObserver = class ResizeObserver {
|
|
||||||
observe() {}
|
|
||||||
disconnect() {}
|
|
||||||
};
|
|
||||||
mocks.deleteResources.mockResolvedValue([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resource list calls row delete action with the target resource", async () => {
|
|
||||||
const page = pageFixture();
|
|
||||||
vi.mocked(useResourceListPage).mockReturnValue(page);
|
|
||||||
|
|
||||||
render(<ResourceListPage />);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getAllByRole("button", { name: "删除资源" })[0]);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mocks.deleteResources).toHaveBeenCalledWith([page.data.items[0]]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resource list batch delete uses selected resources and clears selected rows after success", async () => {
|
|
||||||
const page = pageFixture();
|
|
||||||
mocks.deleteResources.mockResolvedValue([11, 12]);
|
|
||||||
vi.mocked(useResourceListPage).mockReturnValue(page);
|
|
||||||
|
|
||||||
render(<ResourceListPage />);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByLabelText("选择 Rose"));
|
|
||||||
fireEvent.click(screen.getByLabelText("选择 Ring"));
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "批量删除资源(2)" }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mocks.deleteResources).toHaveBeenCalledWith(page.data.items);
|
|
||||||
});
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByLabelText("选择 Rose")).not.toBeChecked();
|
|
||||||
expect(screen.getByLabelText("选择 Ring")).not.toBeChecked();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resource list hides delete actions without delete permission", () => {
|
|
||||||
vi.mocked(useResourceListPage).mockReturnValue(
|
|
||||||
pageFixture({
|
|
||||||
abilities: {
|
|
||||||
canCreate: false,
|
|
||||||
canDelete: false,
|
|
||||||
canUpdate: true,
|
|
||||||
canUpload: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
render(<ResourceListPage />);
|
|
||||||
|
|
||||||
expect(screen.queryByLabelText(/批量删除资源/)).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByLabelText("删除资源")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resource list disables other delete actions while a resource delete is running", () => {
|
|
||||||
vi.mocked(useResourceListPage).mockReturnValue(pageFixture({ loadingAction: "resource-delete-11" }));
|
|
||||||
|
|
||||||
render(<ResourceListPage />);
|
|
||||||
|
|
||||||
expect(screen.getByRole("button", { name: "删除中" })).toBeDisabled();
|
|
||||||
expect(screen.getByRole("button", { name: "删除资源" })).toBeDisabled();
|
|
||||||
expect(screen.getByRole("button", { name: "批量删除资源(0)" })).toBeDisabled();
|
|
||||||
expect(screen.getByLabelText("选择 Rose")).toBeDisabled();
|
|
||||||
});
|
|
||||||
|
|
||||||
function pageFixture(patch = {}) {
|
|
||||||
return {
|
|
||||||
abilities: {
|
|
||||||
canCreate: true,
|
|
||||||
canDelete: true,
|
|
||||||
canUpdate: true,
|
|
||||||
canUpload: true,
|
|
||||||
},
|
|
||||||
activeAction: "",
|
|
||||||
changeQuery: vi.fn(),
|
|
||||||
changeResourceType: vi.fn(),
|
|
||||||
changeStatus: vi.fn(),
|
|
||||||
closeAction: vi.fn(),
|
|
||||||
data: {
|
|
||||||
items: [resourceFixture({ resourceId: 11, name: "Rose" }), resourceFixture({ resourceId: 12, name: "Ring" })],
|
|
||||||
page: 1,
|
|
||||||
pageSize: 50,
|
|
||||||
total: 2,
|
|
||||||
},
|
|
||||||
deleteResources: mocks.deleteResources,
|
|
||||||
error: null,
|
|
||||||
form: {},
|
|
||||||
loading: false,
|
|
||||||
loadingAction: "",
|
|
||||||
openCreateResource: vi.fn(),
|
|
||||||
openEditResource: vi.fn(),
|
|
||||||
page: 1,
|
|
||||||
query: "",
|
|
||||||
reload: vi.fn(),
|
|
||||||
resetFilters: vi.fn(),
|
|
||||||
resourceType: "",
|
|
||||||
selectedResource: null,
|
|
||||||
setForm: vi.fn(),
|
|
||||||
setPage: vi.fn(),
|
|
||||||
status: "",
|
|
||||||
submitResource: vi.fn(),
|
|
||||||
toggleManagerGrant: vi.fn(),
|
|
||||||
toggleResource: vi.fn(),
|
|
||||||
...patch,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function resourceFixture(patch = {}) {
|
|
||||||
return {
|
|
||||||
assetUrl: "https://media.example.com/resource.png",
|
|
||||||
coinPrice: 10,
|
|
||||||
managerGrantEnabled: false,
|
|
||||||
name: "Rose",
|
|
||||||
previewUrl: "https://media.example.com/cover.png",
|
|
||||||
priceType: "coin",
|
|
||||||
resourceCode: "gift_rose",
|
|
||||||
resourceId: 11,
|
|
||||||
resourceType: "gift",
|
|
||||||
status: "active",
|
|
||||||
updatedAtMs: 1782287714973,
|
|
||||||
...patch,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -11,7 +11,6 @@ export function useResourceAbilities() {
|
|||||||
canCreateGroup: can(PERMISSIONS.resourceGroupCreate),
|
canCreateGroup: can(PERMISSIONS.resourceGroupCreate),
|
||||||
canRevokeGrant: can(PERMISSIONS.resourceGrantRevoke),
|
canRevokeGrant: can(PERMISSIONS.resourceGrantRevoke),
|
||||||
canCreateEmojiPack: can(PERMISSIONS.emojiPackCreate),
|
canCreateEmojiPack: can(PERMISSIONS.emojiPackCreate),
|
||||||
canDelete: can(PERMISSIONS.resourceDelete),
|
|
||||||
canDeleteGift: can(PERMISSIONS.giftDelete),
|
canDeleteGift: can(PERMISSIONS.giftDelete),
|
||||||
canStatusGift: can(PERMISSIONS.giftStatus),
|
canStatusGift: can(PERMISSIONS.giftStatus),
|
||||||
canUpdateShop: can(PERMISSIONS.resourceShopUpdate),
|
canUpdateShop: can(PERMISSIONS.resourceShopUpdate),
|
||||||
|
|||||||
@ -308,82 +308,6 @@
|
|||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.giftDraftList {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.giftDraftItem {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-3);
|
|
||||||
padding: var(--space-3);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-card);
|
|
||||||
background: var(--bg-card);
|
|
||||||
}
|
|
||||||
|
|
||||||
.giftDraftHeader {
|
|
||||||
display: grid;
|
|
||||||
min-width: 0;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
grid-template-columns: 44px minmax(0, 1fr) var(--control-height);
|
|
||||||
}
|
|
||||||
|
|
||||||
.giftDraftThumb {
|
|
||||||
display: inline-flex;
|
|
||||||
width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--bg-card-strong);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.giftDraftThumb img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.giftDraftTitle {
|
|
||||||
display: grid;
|
|
||||||
min-width: 0;
|
|
||||||
gap: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.giftDraftTitle span {
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.giftDraftTitle span:first-child {
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-weight: 750;
|
|
||||||
}
|
|
||||||
|
|
||||||
.giftDraftTitle span:last-child {
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.giftDraftEmpty {
|
|
||||||
display: flex;
|
|
||||||
min-height: 120px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border: 1px solid var(--border-muted);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--bg-card-strong);
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
font-weight: 650;
|
|
||||||
}
|
|
||||||
|
|
||||||
.batchInput {
|
.batchInput {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -108,9 +108,9 @@ function normalizeLevel(item: RawLevel): VipLevelDto {
|
|||||||
durationMs: numberValue(item.durationMs ?? item.duration_ms),
|
durationMs: numberValue(item.durationMs ?? item.duration_ms),
|
||||||
rewardResourceGroupId: numberValue(item.rewardResourceGroupId ?? item.reward_resource_group_id),
|
rewardResourceGroupId: numberValue(item.rewardResourceGroupId ?? item.reward_resource_group_id),
|
||||||
sortOrder: numberValue(item.sortOrder ?? item.sort_order),
|
sortOrder: numberValue(item.sortOrder ?? item.sort_order),
|
||||||
rechargeGateRequired: false,
|
rechargeGateRequired: Boolean(item.rechargeGateRequired ?? item.recharge_gate_required ?? level >= 6),
|
||||||
requiredRechargeCoinAmount: 0,
|
requiredRechargeCoinAmount: numberValue(item.requiredRechargeCoinAmount ?? item.required_recharge_coin_amount),
|
||||||
userRechargeCoinAmount: 0,
|
userRechargeCoinAmount: numberValue(item.userRechargeCoinAmount ?? item.user_recharge_coin_amount),
|
||||||
purchaseLockedReason: stringValue(item.purchaseLockedReason ?? item.purchase_locked_reason),
|
purchaseLockedReason: stringValue(item.purchaseLockedReason ?? item.purchase_locked_reason),
|
||||||
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
||||||
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||||
|
|||||||
@ -174,7 +174,7 @@ function defaultLevels() {
|
|||||||
durationMs: defaultDurationMs,
|
durationMs: defaultDurationMs,
|
||||||
rewardResourceGroupId: 0,
|
rewardResourceGroupId: 0,
|
||||||
requiredRechargeCoinAmount: 0,
|
requiredRechargeCoinAmount: 0,
|
||||||
rechargeGateRequired: false,
|
rechargeGateRequired: level >= 6,
|
||||||
sortOrder: level * 10,
|
sortOrder: level * 10,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@ -216,6 +216,7 @@ function payloadFromForm(form) {
|
|||||||
const priceCoin = Number(level.priceCoin || 0);
|
const priceCoin = Number(level.priceCoin || 0);
|
||||||
const durationDays = Number(level.durationDays || 0);
|
const durationDays = Number(level.durationDays || 0);
|
||||||
const rewardResourceGroupId = Number(level.rewardResourceGroupId || 0);
|
const rewardResourceGroupId = Number(level.rewardResourceGroupId || 0);
|
||||||
|
const requiredRechargeCoinAmount = level.level >= 6 ? Number(level.requiredRechargeCoinAmount || 0) : 0;
|
||||||
const sortOrder = Number(level.sortOrder || level.level * 10);
|
const sortOrder = Number(level.sortOrder || level.level * 10);
|
||||||
const status = level.status === "active" ? "active" : "disabled";
|
const status = level.status === "active" ? "active" : "disabled";
|
||||||
if (!String(level.name || "").trim()) {
|
if (!String(level.name || "").trim()) {
|
||||||
@ -233,6 +234,9 @@ function payloadFromForm(form) {
|
|||||||
if (status === "active" && rewardResourceGroupId <= 0) {
|
if (status === "active" && rewardResourceGroupId <= 0) {
|
||||||
throw new Error(`VIP${level.level} 启用时必须选择奖励资源组`);
|
throw new Error(`VIP${level.level} 启用时必须选择奖励资源组`);
|
||||||
}
|
}
|
||||||
|
if (status === "active" && level.level >= 6 && requiredRechargeCoinAmount <= 0) {
|
||||||
|
throw new Error(`VIP${level.level} 启用时必须配置累计充值门槛`);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
level: Number(level.level),
|
level: Number(level.level),
|
||||||
name: String(level.name || "").trim(),
|
name: String(level.name || "").trim(),
|
||||||
@ -240,7 +244,7 @@ function payloadFromForm(form) {
|
|||||||
priceCoin,
|
priceCoin,
|
||||||
durationMs: durationDays * 86400000,
|
durationMs: durationDays * 86400000,
|
||||||
rewardResourceGroupId,
|
rewardResourceGroupId,
|
||||||
requiredRechargeCoinAmount: 0,
|
requiredRechargeCoinAmount,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@ -28,7 +28,9 @@ export function VipConfigPage() {
|
|||||||
<div className={styles.summaryItems}>
|
<div className={styles.summaryItems}>
|
||||||
<SummaryItem label="总等级">{levels.length || 10}</SummaryItem>
|
<SummaryItem label="总等级">{levels.length || 10}</SummaryItem>
|
||||||
<SummaryItem label="启用等级">{page.activeCount}</SummaryItem>
|
<SummaryItem label="启用等级">{page.activeCount}</SummaryItem>
|
||||||
<SummaryItem label="购买门槛">无</SummaryItem>
|
<SummaryItem label="高阶门槛">
|
||||||
|
{levels.filter((level) => level.level >= 6 && level.status === "active").length}
|
||||||
|
</SummaryItem>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.summaryActions}>
|
<div className={styles.summaryActions}>
|
||||||
<Button
|
<Button
|
||||||
@ -200,6 +202,22 @@ function VipConfigDrawer({ page }) {
|
|||||||
page.updateLevel(level.level, { rewardResourceGroupId: value })
|
page.updateLevel(level.level, { rewardResourceGroupId: value })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{level.level >= 6 ? (
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
inputProps={{ min: 0, step: 1 }}
|
||||||
|
label="累计充值门槛"
|
||||||
|
type="number"
|
||||||
|
value={level.requiredRechargeCoinAmount}
|
||||||
|
onChange={(event) =>
|
||||||
|
page.updateLevel(level.level, {
|
||||||
|
requiredRechargeCoinAmount: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TextField disabled label="购买门槛" value="直接购买" />
|
||||||
|
)}
|
||||||
<TextField
|
<TextField
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
inputProps={{ min: 0, step: 1 }}
|
inputProps={{ min: 0, step: 1 }}
|
||||||
@ -265,7 +283,8 @@ function vipColumns(resourceLabels) {
|
|||||||
{
|
{
|
||||||
key: "gate",
|
key: "gate",
|
||||||
label: "购买门槛",
|
label: "购买门槛",
|
||||||
render: () => "直接购买",
|
render: (item) =>
|
||||||
|
item.level >= 6 ? `${formatNumber(item.requiredRechargeCoinAmount)} 累充金币` : "直接购买",
|
||||||
width: "minmax(180px, 0.8fr)",
|
width: "minmax(180px, 0.8fr)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -86,6 +86,11 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.directLabel {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.summaryPanel {
|
.summaryPanel {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
|||||||
@ -26,8 +26,6 @@ export const API_OPERATIONS = {
|
|||||||
appSetPassword: "appSetPassword",
|
appSetPassword: "appSetPassword",
|
||||||
appUnbanUser: "appUnbanUser",
|
appUnbanUser: "appUnbanUser",
|
||||||
appUpdateUser: "appUpdateUser",
|
appUpdateUser: "appUpdateUser",
|
||||||
batchCreateGifts: "batchCreateGifts",
|
|
||||||
batchDeleteResources: "batchDeleteResources",
|
|
||||||
batchUpdateUserStatus: "batchUpdateUserStatus",
|
batchUpdateUserStatus: "batchUpdateUserStatus",
|
||||||
cancelRoomPin: "cancelRoomPin",
|
cancelRoomPin: "cancelRoomPin",
|
||||||
changePassword: "changePassword",
|
changePassword: "changePassword",
|
||||||
@ -89,7 +87,6 @@ export const API_OPERATIONS = {
|
|||||||
deletePermission: "deletePermission",
|
deletePermission: "deletePermission",
|
||||||
deletePopup: "deletePopup",
|
deletePopup: "deletePopup",
|
||||||
deleteRechargeProduct: "deleteRechargeProduct",
|
deleteRechargeProduct: "deleteRechargeProduct",
|
||||||
deleteResource: "deleteResource",
|
|
||||||
deleteRole: "deleteRole",
|
deleteRole: "deleteRole",
|
||||||
deleteRoom: "deleteRoom",
|
deleteRoom: "deleteRoom",
|
||||||
deleteSplashScreen: "deleteSplashScreen",
|
deleteSplashScreen: "deleteSplashScreen",
|
||||||
@ -109,7 +106,6 @@ export const API_OPERATIONS = {
|
|||||||
expireRoomRpsChallenge: "expireRoomRpsChallenge",
|
expireRoomRpsChallenge: "expireRoomRpsChallenge",
|
||||||
exportLoginLogs: "exportLoginLogs",
|
exportLoginLogs: "exportLoginLogs",
|
||||||
exportOperationLogs: "exportOperationLogs",
|
exportOperationLogs: "exportOperationLogs",
|
||||||
exportRechargeBills: "exportRechargeBills",
|
|
||||||
exportUsers: "exportUsers",
|
exportUsers: "exportUsers",
|
||||||
generateDiceRobots: "generateDiceRobots",
|
generateDiceRobots: "generateDiceRobots",
|
||||||
generatePrettyIds: "generatePrettyIds",
|
generatePrettyIds: "generatePrettyIds",
|
||||||
@ -124,8 +120,6 @@ export const API_OPERATIONS = {
|
|||||||
getInviteActivityRewardConfig: "getInviteActivityRewardConfig",
|
getInviteActivityRewardConfig: "getInviteActivityRewardConfig",
|
||||||
getLuckyGiftConfig: "getLuckyGiftConfig",
|
getLuckyGiftConfig: "getLuckyGiftConfig",
|
||||||
getLuckyGiftDrawSummary: "getLuckyGiftDrawSummary",
|
getLuckyGiftDrawSummary: "getLuckyGiftDrawSummary",
|
||||||
getRechargeBillOverview: "getRechargeBillOverview",
|
|
||||||
getRechargeBillSummary: "getRechargeBillSummary",
|
|
||||||
getRedPacket: "getRedPacket",
|
getRedPacket: "getRedPacket",
|
||||||
getRedPacketConfig: "getRedPacketConfig",
|
getRedPacketConfig: "getRedPacketConfig",
|
||||||
getRegion: "getRegion",
|
getRegion: "getRegion",
|
||||||
@ -142,10 +136,6 @@ export const API_OPERATIONS = {
|
|||||||
getRoomTurnoverRewardConfig: "getRoomTurnoverRewardConfig",
|
getRoomTurnoverRewardConfig: "getRoomTurnoverRewardConfig",
|
||||||
getRoomWhitelistConfig: "getRoomWhitelistConfig",
|
getRoomWhitelistConfig: "getRoomWhitelistConfig",
|
||||||
getSevenDayCheckInConfig: "getSevenDayCheckInConfig",
|
getSevenDayCheckInConfig: "getSevenDayCheckInConfig",
|
||||||
getSocialBiFunnel: "getSocialBiFunnel",
|
|
||||||
getSocialBiKpi: "getSocialBiKpi",
|
|
||||||
getSocialBiMaster: "getSocialBiMaster",
|
|
||||||
getSocialBiOverview: "getSocialBiOverview",
|
|
||||||
getTemporaryPaymentLink: "getTemporaryPaymentLink",
|
getTemporaryPaymentLink: "getTemporaryPaymentLink",
|
||||||
getUser: "getUser",
|
getUser: "getUser",
|
||||||
getWeeklyStarCycle: "getWeeklyStarCycle",
|
getWeeklyStarCycle: "getWeeklyStarCycle",
|
||||||
@ -201,10 +191,8 @@ export const API_OPERATIONS = {
|
|||||||
listPopups: "listPopups",
|
listPopups: "listPopups",
|
||||||
listPrettyIdPools: "listPrettyIdPools",
|
listPrettyIdPools: "listPrettyIdPools",
|
||||||
listPrettyIds: "listPrettyIds",
|
listPrettyIds: "listPrettyIds",
|
||||||
listRechargeApps: "listRechargeApps",
|
|
||||||
listRechargeBills: "listRechargeBills",
|
listRechargeBills: "listRechargeBills",
|
||||||
listRechargeProducts: "listRechargeProducts",
|
listRechargeProducts: "listRechargeProducts",
|
||||||
listRechargeRegions: "listRechargeRegions",
|
|
||||||
listRedPackets: "listRedPackets",
|
listRedPackets: "listRedPackets",
|
||||||
listRegionBlocks: "listRegionBlocks",
|
listRegionBlocks: "listRegionBlocks",
|
||||||
listRegions: "listRegions",
|
listRegions: "listRegions",
|
||||||
@ -247,7 +235,6 @@ export const API_OPERATIONS = {
|
|||||||
publishHostAgencyPolicy: "publishHostAgencyPolicy",
|
publishHostAgencyPolicy: "publishHostAgencyPolicy",
|
||||||
recyclePrettyId: "recyclePrettyId",
|
recyclePrettyId: "recyclePrettyId",
|
||||||
refresh: "refresh",
|
refresh: "refresh",
|
||||||
refreshGoogleRechargePaid: "refreshGoogleRechargePaid",
|
|
||||||
rejectFinanceApplication: "rejectFinanceApplication",
|
rejectFinanceApplication: "rejectFinanceApplication",
|
||||||
rejectFinanceWithdrawalApplication: "rejectFinanceWithdrawalApplication",
|
rejectFinanceWithdrawalApplication: "rejectFinanceWithdrawalApplication",
|
||||||
replaceCoinSellerSalaryRates: "replaceCoinSellerSalaryRates",
|
replaceCoinSellerSalaryRates: "replaceCoinSellerSalaryRates",
|
||||||
@ -300,7 +287,6 @@ export const API_OPERATIONS = {
|
|||||||
updateHostAgencyPolicy: "updateHostAgencyPolicy",
|
updateHostAgencyPolicy: "updateHostAgencyPolicy",
|
||||||
updateHumanRoomRobotConfig: "updateHumanRoomRobotConfig",
|
updateHumanRoomRobotConfig: "updateHumanRoomRobotConfig",
|
||||||
updateInviteActivityRewardConfig: "updateInviteActivityRewardConfig",
|
updateInviteActivityRewardConfig: "updateInviteActivityRewardConfig",
|
||||||
updateManager: "updateManager",
|
|
||||||
updateMenu: "updateMenu",
|
updateMenu: "updateMenu",
|
||||||
updateMenuVisible: "updateMenuVisible",
|
updateMenuVisible: "updateMenuVisible",
|
||||||
updatePermission: "updatePermission",
|
updatePermission: "updatePermission",
|
||||||
@ -436,20 +422,6 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "app-user:update",
|
permission: "app-user:update",
|
||||||
permissions: ["app-user:update"]
|
permissions: ["app-user:update"]
|
||||||
},
|
},
|
||||||
batchCreateGifts: {
|
|
||||||
method: "POST",
|
|
||||||
operationId: API_OPERATIONS.batchCreateGifts,
|
|
||||||
path: "/v1/admin/gifts/batch",
|
|
||||||
permission: "gift:create",
|
|
||||||
permissions: ["gift:create"]
|
|
||||||
},
|
|
||||||
batchDeleteResources: {
|
|
||||||
method: "POST",
|
|
||||||
operationId: API_OPERATIONS.batchDeleteResources,
|
|
||||||
path: "/v1/admin/resources/batch-delete",
|
|
||||||
permission: "resource:delete",
|
|
||||||
permissions: ["resource:delete"]
|
|
||||||
},
|
|
||||||
batchUpdateUserStatus: {
|
batchUpdateUserStatus: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
operationId: API_OPERATIONS.batchUpdateUserStatus,
|
operationId: API_OPERATIONS.batchUpdateUserStatus,
|
||||||
@ -875,13 +847,6 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "payment-product:delete",
|
permission: "payment-product:delete",
|
||||||
permissions: ["payment-product:delete"]
|
permissions: ["payment-product:delete"]
|
||||||
},
|
},
|
||||||
deleteResource: {
|
|
||||||
method: "DELETE",
|
|
||||||
operationId: API_OPERATIONS.deleteResource,
|
|
||||||
path: "/v1/admin/resources/{resource_id}",
|
|
||||||
permission: "resource:delete",
|
|
||||||
permissions: ["resource:delete"]
|
|
||||||
},
|
|
||||||
deleteRole: {
|
deleteRole: {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
operationId: API_OPERATIONS.deleteRole,
|
operationId: API_OPERATIONS.deleteRole,
|
||||||
@ -1015,13 +980,6 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "log:export",
|
permission: "log:export",
|
||||||
permissions: ["log:export"]
|
permissions: ["log:export"]
|
||||||
},
|
},
|
||||||
exportRechargeBills: {
|
|
||||||
method: "GET",
|
|
||||||
operationId: API_OPERATIONS.exportRechargeBills,
|
|
||||||
path: "/v1/admin/payment/recharge-bills/export",
|
|
||||||
permission: "payment-bill:view",
|
|
||||||
permissions: ["payment-bill:view"]
|
|
||||||
},
|
|
||||||
exportUsers: {
|
exportUsers: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.exportUsers,
|
operationId: API_OPERATIONS.exportUsers,
|
||||||
@ -1120,20 +1078,6 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "lucky-gift:view",
|
permission: "lucky-gift:view",
|
||||||
permissions: ["lucky-gift:view"]
|
permissions: ["lucky-gift:view"]
|
||||||
},
|
},
|
||||||
getRechargeBillOverview: {
|
|
||||||
method: "GET",
|
|
||||||
operationId: API_OPERATIONS.getRechargeBillOverview,
|
|
||||||
path: "/v1/admin/payment/recharge-bills/overview",
|
|
||||||
permission: "payment-bill:view",
|
|
||||||
permissions: ["payment-bill:view"]
|
|
||||||
},
|
|
||||||
getRechargeBillSummary: {
|
|
||||||
method: "GET",
|
|
||||||
operationId: API_OPERATIONS.getRechargeBillSummary,
|
|
||||||
path: "/v1/admin/payment/recharge-bills/summary",
|
|
||||||
permission: "payment-bill:view",
|
|
||||||
permissions: ["payment-bill:view"]
|
|
||||||
},
|
|
||||||
getRedPacket: {
|
getRedPacket: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.getRedPacket,
|
operationId: API_OPERATIONS.getRedPacket,
|
||||||
@ -1246,34 +1190,6 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "seven-day-checkin:view",
|
permission: "seven-day-checkin:view",
|
||||||
permissions: ["seven-day-checkin:view"]
|
permissions: ["seven-day-checkin:view"]
|
||||||
},
|
},
|
||||||
getSocialBiFunnel: {
|
|
||||||
method: "GET",
|
|
||||||
operationId: API_OPERATIONS.getSocialBiFunnel,
|
|
||||||
path: "/v1/admin/databi/social/funnel",
|
|
||||||
permission: "overview:view",
|
|
||||||
permissions: ["overview:view"]
|
|
||||||
},
|
|
||||||
getSocialBiKpi: {
|
|
||||||
method: "GET",
|
|
||||||
operationId: API_OPERATIONS.getSocialBiKpi,
|
|
||||||
path: "/v1/admin/databi/social/kpi",
|
|
||||||
permission: "overview:view",
|
|
||||||
permissions: ["overview:view"]
|
|
||||||
},
|
|
||||||
getSocialBiMaster: {
|
|
||||||
method: "GET",
|
|
||||||
operationId: API_OPERATIONS.getSocialBiMaster,
|
|
||||||
path: "/v1/admin/databi/social/master",
|
|
||||||
permission: "overview:view",
|
|
||||||
permissions: ["overview:view"]
|
|
||||||
},
|
|
||||||
getSocialBiOverview: {
|
|
||||||
method: "GET",
|
|
||||||
operationId: API_OPERATIONS.getSocialBiOverview,
|
|
||||||
path: "/v1/admin/databi/social/overview",
|
|
||||||
permission: "overview:view",
|
|
||||||
permissions: ["overview:view"]
|
|
||||||
},
|
|
||||||
getTemporaryPaymentLink: {
|
getTemporaryPaymentLink: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.getTemporaryPaymentLink,
|
operationId: API_OPERATIONS.getTemporaryPaymentLink,
|
||||||
@ -1657,13 +1573,6 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "pretty-id:view",
|
permission: "pretty-id:view",
|
||||||
permissions: ["pretty-id:view"]
|
permissions: ["pretty-id:view"]
|
||||||
},
|
},
|
||||||
listRechargeApps: {
|
|
||||||
method: "GET",
|
|
||||||
operationId: API_OPERATIONS.listRechargeApps,
|
|
||||||
path: "/v1/admin/payment/recharge-apps",
|
|
||||||
permission: "payment-bill:view",
|
|
||||||
permissions: ["payment-bill:view"]
|
|
||||||
},
|
|
||||||
listRechargeBills: {
|
listRechargeBills: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.listRechargeBills,
|
operationId: API_OPERATIONS.listRechargeBills,
|
||||||
@ -1678,13 +1587,6 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "payment-product:view",
|
permission: "payment-product:view",
|
||||||
permissions: ["payment-product:view"]
|
permissions: ["payment-product:view"]
|
||||||
},
|
},
|
||||||
listRechargeRegions: {
|
|
||||||
method: "GET",
|
|
||||||
operationId: API_OPERATIONS.listRechargeRegions,
|
|
||||||
path: "/v1/admin/payment/recharge-regions",
|
|
||||||
permission: "payment-bill:view",
|
|
||||||
permissions: ["payment-bill:view"]
|
|
||||||
},
|
|
||||||
listRedPackets: {
|
listRedPackets: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.listRedPackets,
|
operationId: API_OPERATIONS.listRedPackets,
|
||||||
@ -1969,13 +1871,6 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
operationId: API_OPERATIONS.refresh,
|
operationId: API_OPERATIONS.refresh,
|
||||||
path: "/v1/auth/refresh"
|
path: "/v1/auth/refresh"
|
||||||
},
|
},
|
||||||
refreshGoogleRechargePaid: {
|
|
||||||
method: "POST",
|
|
||||||
operationId: API_OPERATIONS.refreshGoogleRechargePaid,
|
|
||||||
path: "/v1/admin/payment/recharge-bills/google-paid/refresh",
|
|
||||||
permission: "payment-bill:view",
|
|
||||||
permissions: ["payment-bill:view"]
|
|
||||||
},
|
|
||||||
rejectFinanceApplication: {
|
rejectFinanceApplication: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
operationId: API_OPERATIONS.rejectFinanceApplication,
|
operationId: API_OPERATIONS.rejectFinanceApplication,
|
||||||
@ -2338,13 +2233,6 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "invite-activity-reward:update",
|
permission: "invite-activity-reward:update",
|
||||||
permissions: ["invite-activity-reward:update"]
|
permissions: ["invite-activity-reward:update"]
|
||||||
},
|
},
|
||||||
updateManager: {
|
|
||||||
method: "PUT",
|
|
||||||
operationId: API_OPERATIONS.updateManager,
|
|
||||||
path: "/v1/admin/managers/{user_id}",
|
|
||||||
permission: "bd:update",
|
|
||||||
permissions: ["bd:update"]
|
|
||||||
},
|
|
||||||
updateMenu: {
|
updateMenu: {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
operationId: API_OPERATIONS.updateMenu,
|
operationId: API_OPERATIONS.updateMenu,
|
||||||
|
|||||||
519
src/shared/api/generated/schema.d.ts
vendored
519
src/shared/api/generated/schema.d.ts
vendored
@ -676,22 +676,6 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/admin/managers/{user_id}": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get?: never;
|
|
||||||
put: operations["updateManager"];
|
|
||||||
post?: never;
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/admin/agencies": {
|
"/admin/agencies": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -1620,22 +1604,6 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/admin/gifts/batch": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get?: never;
|
|
||||||
put?: never;
|
|
||||||
post: operations["batchCreateGifts"];
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/admin/gifts/{gift_id}": {
|
"/admin/gifts/{gift_id}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -1908,102 +1876,6 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/admin/payment/recharge-bills/overview": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get: operations["getRechargeBillOverview"];
|
|
||||||
put?: never;
|
|
||||||
post?: never;
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/admin/payment/recharge-bills/summary": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get: operations["getRechargeBillSummary"];
|
|
||||||
put?: never;
|
|
||||||
post?: never;
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/admin/payment/recharge-bills/export": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get: operations["exportRechargeBills"];
|
|
||||||
put?: never;
|
|
||||||
post?: never;
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/admin/payment/recharge-bills/google-paid/refresh": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get?: never;
|
|
||||||
put?: never;
|
|
||||||
post: operations["refreshGoogleRechargePaid"];
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/admin/payment/recharge-regions": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get: operations["listRechargeRegions"];
|
|
||||||
put?: never;
|
|
||||||
post?: never;
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/admin/payment/recharge-apps": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get: operations["listRechargeApps"];
|
|
||||||
put?: never;
|
|
||||||
post?: never;
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/admin/payment/recharge-products": {
|
"/admin/payment/recharge-products": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -2036,70 +1908,6 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/admin/databi/social/master": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get: operations["getSocialBiMaster"];
|
|
||||||
put?: never;
|
|
||||||
post?: never;
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/admin/databi/social/overview": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get: operations["getSocialBiOverview"];
|
|
||||||
put?: never;
|
|
||||||
post?: never;
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/admin/databi/social/funnel": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get: operations["getSocialBiFunnel"];
|
|
||||||
put?: never;
|
|
||||||
post?: never;
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/admin/databi/social/kpi": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get: operations["getSocialBiKpi"];
|
|
||||||
put?: never;
|
|
||||||
post?: never;
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/admin/finance/scope": {
|
"/admin/finance/scope": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -2660,22 +2468,6 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/admin/resources/batch-delete": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get?: never;
|
|
||||||
put?: never;
|
|
||||||
post: operations["batchDeleteResources"];
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/admin/resources/{resource_id}": {
|
"/admin/resources/{resource_id}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -2686,7 +2478,7 @@ export interface paths {
|
|||||||
get: operations["getResource"];
|
get: operations["getResource"];
|
||||||
put: operations["updateResource"];
|
put: operations["updateResource"];
|
||||||
post?: never;
|
post?: never;
|
||||||
delete: operations["deleteResource"];
|
delete?: never;
|
||||||
options?: never;
|
options?: never;
|
||||||
head?: never;
|
head?: never;
|
||||||
patch?: never;
|
patch?: never;
|
||||||
@ -4029,16 +3821,6 @@ export interface components {
|
|||||||
Manager: {
|
Manager: {
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
bdLeaderCount?: number;
|
bdLeaderCount?: number;
|
||||||
canAddAdmin?: boolean;
|
|
||||||
canAddBdLeader?: boolean;
|
|
||||||
canAddSuperadmin?: boolean;
|
|
||||||
canBlockUser?: boolean;
|
|
||||||
canGrantAvatarFrame?: boolean;
|
|
||||||
canGrantBadge?: boolean;
|
|
||||||
canGrantVehicle?: boolean;
|
|
||||||
canGrantVip?: boolean;
|
|
||||||
canTransferUserCountry?: boolean;
|
|
||||||
canUpdateUserLevel?: boolean;
|
|
||||||
createdAtMs?: number;
|
createdAtMs?: number;
|
||||||
contact?: string;
|
contact?: string;
|
||||||
displayUserId?: string;
|
displayUserId?: string;
|
||||||
@ -4052,34 +3834,9 @@ export interface components {
|
|||||||
username?: string;
|
username?: string;
|
||||||
};
|
};
|
||||||
CreateManagerPayload: {
|
CreateManagerPayload: {
|
||||||
canAddAdmin?: boolean;
|
|
||||||
canAddBdLeader?: boolean;
|
|
||||||
canAddSuperadmin?: boolean;
|
|
||||||
canBlockUser?: boolean;
|
|
||||||
canGrantAvatarFrame?: boolean;
|
|
||||||
canGrantBadge?: boolean;
|
|
||||||
canGrantVehicle?: boolean;
|
|
||||||
canGrantVip?: boolean;
|
|
||||||
canTransferUserCountry?: boolean;
|
|
||||||
canUpdateUserLevel?: boolean;
|
|
||||||
contact?: string;
|
contact?: string;
|
||||||
targetUserId: string;
|
targetUserId: string;
|
||||||
};
|
};
|
||||||
UpdateManagerPayload: {
|
|
||||||
canAddAdmin?: boolean;
|
|
||||||
canAddBdLeader?: boolean;
|
|
||||||
canAddSuperadmin?: boolean;
|
|
||||||
canBlockUser?: boolean;
|
|
||||||
canGrantAvatarFrame?: boolean;
|
|
||||||
canGrantBadge?: boolean;
|
|
||||||
canGrantVehicle?: boolean;
|
|
||||||
canGrantVip?: boolean;
|
|
||||||
canTransferUserCountry?: boolean;
|
|
||||||
canUpdateUserLevel?: boolean;
|
|
||||||
contact?: string;
|
|
||||||
/** @enum {string} */
|
|
||||||
status?: "active" | "disabled";
|
|
||||||
};
|
|
||||||
BDProfile: {
|
BDProfile: {
|
||||||
agencyCount?: number;
|
agencyCount?: number;
|
||||||
createdAtMs?: number;
|
createdAtMs?: number;
|
||||||
@ -4617,20 +4374,6 @@ export interface components {
|
|||||||
Status: string;
|
Status: string;
|
||||||
};
|
};
|
||||||
requestBodies: {
|
requestBodies: {
|
||||||
BatchCreateGiftsRequest: {
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
items: Record<string, never>[];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
BatchDeleteResourcesRequest: {
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
resourceIds: number[];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
BatchStatusRequest: {
|
BatchStatusRequest: {
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
@ -4759,15 +4502,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
requestBody?: never;
|
requestBody?: never;
|
||||||
responses: {
|
responses: {
|
||||||
/** @description 批量创建的礼物 */
|
200: components["responses"]["EmptyResponse"];
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": Record<string, never>[];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
createAchievementDefinition: {
|
createAchievementDefinition: {
|
||||||
@ -4779,15 +4514,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
requestBody?: never;
|
requestBody?: never;
|
||||||
responses: {
|
responses: {
|
||||||
/** @description 批量删除的资源 */
|
200: components["responses"]["EmptyResponse"];
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": Record<string, never>[];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
updateAchievementDefinition: {
|
updateAchievementDefinition: {
|
||||||
@ -5513,24 +5240,6 @@ export interface operations {
|
|||||||
201: components["responses"]["ManagerResponse"];
|
201: components["responses"]["ManagerResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
updateManager: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path: {
|
|
||||||
user_id: number;
|
|
||||||
};
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody: {
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["UpdateManagerPayload"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
responses: {
|
|
||||||
200: components["responses"]["ManagerResponse"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
listAgencies: {
|
listAgencies: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
@ -6677,18 +6386,6 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
batchCreateGifts: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: components["requestBodies"]["BatchCreateGiftsRequest"];
|
|
||||||
responses: {
|
|
||||||
200: components["responses"]["EmptyResponse"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
updateGift: {
|
updateGift: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -6913,18 +6610,7 @@ export interface operations {
|
|||||||
path?: never;
|
path?: never;
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
requestBody: {
|
requestBody?: never;
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
/** Format: int64 */
|
|
||||||
amount: number;
|
|
||||||
commandId?: string;
|
|
||||||
reason: string;
|
|
||||||
remark: string;
|
|
||||||
targetUserId: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
responses: {
|
responses: {
|
||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
@ -7005,11 +6691,11 @@ export interface operations {
|
|||||||
sent_at_ms?: number;
|
sent_at_ms?: number;
|
||||||
summary: string;
|
summary: string;
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
target_scope: "all_registered_users" | "all_active_users" | "single_user" | "user_ids" | "region" | "country";
|
target_scope: "all_active_users" | "single_user" | "user_ids" | "region" | "country";
|
||||||
/** Format: int64 */
|
/** Format: int64 */
|
||||||
target_user_id?: number;
|
target_user_id?: number;
|
||||||
title: string;
|
title: string;
|
||||||
user_ids?: (number | string)[];
|
user_ids?: number[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@ -7049,106 +6735,6 @@ export interface operations {
|
|||||||
200: components["responses"]["RechargeBillPageResponse"];
|
200: components["responses"]["RechargeBillPageResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
getRechargeBillOverview: {
|
|
||||||
parameters: {
|
|
||||||
query?: {
|
|
||||||
keyword?: string;
|
|
||||||
recharge_type?: string;
|
|
||||||
status?: string;
|
|
||||||
region_id?: number;
|
|
||||||
start_at_ms?: number;
|
|
||||||
end_at_ms?: number;
|
|
||||||
tz_offset_minutes?: number;
|
|
||||||
};
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
200: components["responses"]["EmptyResponse"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
getRechargeBillSummary: {
|
|
||||||
parameters: {
|
|
||||||
query?: {
|
|
||||||
keyword?: string;
|
|
||||||
recharge_type?: string;
|
|
||||||
status?: string;
|
|
||||||
region_id?: number;
|
|
||||||
start_at_ms?: number;
|
|
||||||
end_at_ms?: number;
|
|
||||||
};
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
200: components["responses"]["EmptyResponse"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
exportRechargeBills: {
|
|
||||||
parameters: {
|
|
||||||
query?: {
|
|
||||||
keyword?: string;
|
|
||||||
recharge_type?: string;
|
|
||||||
status?: string;
|
|
||||||
region_id?: number;
|
|
||||||
start_at_ms?: number;
|
|
||||||
end_at_ms?: number;
|
|
||||||
};
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
200: components["responses"]["EmptyResponse"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
refreshGoogleRechargePaid: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody: {
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
transactionIds: string[];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
responses: {
|
|
||||||
200: components["responses"]["EmptyResponse"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
listRechargeRegions: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
200: components["responses"]["EmptyResponse"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
listRechargeApps: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
200: components["responses"]["EmptyResponse"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
listRechargeProducts: {
|
listRechargeProducts: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -7201,73 +6787,6 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
getSocialBiMaster: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
200: components["responses"]["StatisticsObjectResponse"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
getSocialBiOverview: {
|
|
||||||
parameters: {
|
|
||||||
query?: {
|
|
||||||
stat_tz?: string;
|
|
||||||
start_ms?: number;
|
|
||||||
end_ms?: number;
|
|
||||||
app_codes?: string;
|
|
||||||
region_id?: number;
|
|
||||||
};
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
200: components["responses"]["StatisticsObjectResponse"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
getSocialBiFunnel: {
|
|
||||||
parameters: {
|
|
||||||
query?: {
|
|
||||||
stat_tz?: string;
|
|
||||||
start_ms?: number;
|
|
||||||
end_ms?: number;
|
|
||||||
app_codes?: string;
|
|
||||||
region_id?: number;
|
|
||||||
};
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
200: components["responses"]["StatisticsObjectResponse"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
getSocialBiKpi: {
|
|
||||||
parameters: {
|
|
||||||
query?: {
|
|
||||||
stat_tz?: string;
|
|
||||||
start_ms?: number;
|
|
||||||
end_ms?: number;
|
|
||||||
period_month?: string;
|
|
||||||
app_codes?: string;
|
|
||||||
operator_user_id?: number;
|
|
||||||
};
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
200: components["responses"]["StatisticsObjectResponse"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
getFinanceScope: {
|
getFinanceScope: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -7879,18 +7398,6 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
batchDeleteResources: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: components["requestBodies"]["BatchDeleteResourcesRequest"];
|
|
||||||
responses: {
|
|
||||||
200: components["responses"]["EmptyResponse"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
getResource: {
|
getResource: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -7919,20 +7426,6 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
deleteResource: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path: {
|
|
||||||
resource_id: number;
|
|
||||||
};
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
200: components["responses"]["EmptyResponse"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
disableResource: {
|
disableResource: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import { afterEach, expect, test, vi } from "vitest";
|
|||||||
import { apiRequest, setAccessToken, setRefreshHandler, setUnauthorizedHandler } from "./request";
|
import { apiRequest, setAccessToken, setRefreshHandler, setUnauthorizedHandler } from "./request";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
|
||||||
setAccessToken("");
|
setAccessToken("");
|
||||||
setRefreshHandler(null);
|
setRefreshHandler(null);
|
||||||
setUnauthorizedHandler(null);
|
setUnauthorizedHandler(null);
|
||||||
@ -60,27 +59,3 @@ test("shares one refresh request across concurrent stale access token responses"
|
|||||||
expect(refreshCount).toBe(1);
|
expect(refreshCount).toBe(1);
|
||||||
expect(fetch).toHaveBeenCalledTimes(4);
|
expect(fetch).toHaveBeenCalledTimes(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("retries idempotent reads after a transient network failure", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
vi.stubGlobal(
|
|
||||||
"fetch",
|
|
||||||
vi
|
|
||||||
.fn()
|
|
||||||
.mockRejectedValueOnce(new TypeError("Failed to fetch"))
|
|
||||||
.mockResolvedValueOnce(new Response(JSON.stringify({ code: 0, data: { ok: true } })))
|
|
||||||
);
|
|
||||||
|
|
||||||
const request = apiRequest("/v1/admin/apps");
|
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(350);
|
|
||||||
await expect(request).resolves.toEqual({ ok: true });
|
|
||||||
expect(fetch).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not replay mutations after a network failure", async () => {
|
|
||||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValueOnce(new TypeError("Failed to fetch")));
|
|
||||||
|
|
||||||
await expect(apiRequest("/v1/admin/countries", { body: {}, method: "POST" })).rejects.toThrow("Failed to fetch");
|
|
||||||
expect(fetch).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|||||||
@ -3,8 +3,6 @@ import { API_BASE_URL } from "@/shared/config/env.js";
|
|||||||
const TOKEN_KEY = "hyapp-admin.access-token";
|
const TOKEN_KEY = "hyapp-admin.access-token";
|
||||||
const APP_CODE_KEY = "hyapp-admin.app-code";
|
const APP_CODE_KEY = "hyapp-admin.app-code";
|
||||||
const APP_CODE_HEADER = "X-App-Code";
|
const APP_CODE_HEADER = "X-App-Code";
|
||||||
const NETWORK_RETRY_ATTEMPTS = 2;
|
|
||||||
const NETWORK_RETRY_DELAY_MS = 350;
|
|
||||||
|
|
||||||
let accessToken = window.localStorage.getItem(TOKEN_KEY) || "";
|
let accessToken = window.localStorage.getItem(TOKEN_KEY) || "";
|
||||||
let selectedAppCode = normalizeAppCode(window.localStorage.getItem(APP_CODE_KEY) || "lalu");
|
let selectedAppCode = normalizeAppCode(window.localStorage.getItem(APP_CODE_KEY) || "lalu");
|
||||||
@ -104,7 +102,7 @@ export async function apiRequest<TData = unknown, TBody = unknown>(
|
|||||||
requestHeaders[APP_CODE_HEADER] = selectedAppCode;
|
requestHeaders[APP_CODE_HEADER] = selectedAppCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetchWithNetworkRetry(url, {
|
const response = await fetch(url, {
|
||||||
method,
|
method,
|
||||||
headers: requestHeaders,
|
headers: requestHeaders,
|
||||||
body: body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body),
|
body: body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body),
|
||||||
@ -157,34 +155,6 @@ function resolveErrorMessage(response: Response, message?: string) {
|
|||||||
return message || response.statusText || "请求失败";
|
return message || response.statusText || "请求失败";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchWithNetworkRetry(url: string, init: RequestInit) {
|
|
||||||
const method = String(init.method || "GET").toUpperCase();
|
|
||||||
const maxAttempts = method === "GET" || method === "HEAD" ? NETWORK_RETRY_ATTEMPTS + 1 : 1;
|
|
||||||
let lastError: unknown;
|
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
||||||
try {
|
|
||||||
return await fetch(url, init);
|
|
||||||
} catch (error) {
|
|
||||||
lastError = error;
|
|
||||||
if (attempt >= maxAttempts) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// GAAP occasionally resets the browser HTTP/2 stream before a response exists; retry only idempotent reads.
|
|
||||||
await delay(NETWORK_RETRY_DELAY_MS * attempt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw lastError;
|
|
||||||
}
|
|
||||||
|
|
||||||
function delay(ms: number) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
window.setTimeout(resolve, ms);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildURL(path: string, query?: QueryParams) {
|
function buildURL(path: string, query?: QueryParams) {
|
||||||
const url = new URL(`${API_BASE_URL}${path}`, window.location.origin);
|
const url = new URL(`${API_BASE_URL}${path}`, window.location.origin);
|
||||||
if (query) {
|
if (query) {
|
||||||
|
|||||||
@ -316,7 +316,6 @@ export interface CoinAdjustmentDto {
|
|||||||
operator?: CoinAdjustmentOperatorDto;
|
operator?: CoinAdjustmentOperatorDto;
|
||||||
operatorUserId?: string;
|
operatorUserId?: string;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
remark?: string;
|
|
||||||
transactionId: string;
|
transactionId: string;
|
||||||
user: CoinLedgerUserDto;
|
user: CoinLedgerUserDto;
|
||||||
userId: string;
|
userId: string;
|
||||||
@ -326,7 +325,6 @@ export interface CoinAdjustmentPayload {
|
|||||||
amount: number;
|
amount: number;
|
||||||
commandId?: string;
|
commandId?: string;
|
||||||
reason: string;
|
reason: string;
|
||||||
remark: string;
|
|
||||||
targetUserId: string;
|
targetUserId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -335,7 +333,6 @@ export interface CoinAdjustmentCreateDto {
|
|||||||
availableDelta: number;
|
availableDelta: number;
|
||||||
balanceAfter: number;
|
balanceAfter: number;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
remark?: string;
|
|
||||||
transactionId: string;
|
transactionId: string;
|
||||||
user: CoinLedgerUserDto;
|
user: CoinLedgerUserDto;
|
||||||
}
|
}
|
||||||
@ -684,7 +681,6 @@ export interface ManagerDto {
|
|||||||
canGrantAvatarFrame?: boolean;
|
canGrantAvatarFrame?: boolean;
|
||||||
canGrantBadge?: boolean;
|
canGrantBadge?: boolean;
|
||||||
canGrantVehicle?: boolean;
|
canGrantVehicle?: boolean;
|
||||||
canGrantVip?: boolean;
|
|
||||||
canTransferUserCountry?: boolean;
|
canTransferUserCountry?: boolean;
|
||||||
canUpdateUserLevel?: boolean;
|
canUpdateUserLevel?: boolean;
|
||||||
contact?: string;
|
contact?: string;
|
||||||
@ -707,7 +703,6 @@ export interface CreateManagerPayload {
|
|||||||
canGrantAvatarFrame?: boolean;
|
canGrantAvatarFrame?: boolean;
|
||||||
canGrantBadge?: boolean;
|
canGrantBadge?: boolean;
|
||||||
canGrantVehicle?: boolean;
|
canGrantVehicle?: boolean;
|
||||||
canGrantVip?: boolean;
|
|
||||||
canTransferUserCountry?: boolean;
|
canTransferUserCountry?: boolean;
|
||||||
canUpdateUserLevel?: boolean;
|
canUpdateUserLevel?: boolean;
|
||||||
contact?: string;
|
contact?: string;
|
||||||
@ -722,7 +717,6 @@ export interface UpdateManagerPayload {
|
|||||||
canGrantAvatarFrame?: boolean;
|
canGrantAvatarFrame?: boolean;
|
||||||
canGrantBadge?: boolean;
|
canGrantBadge?: boolean;
|
||||||
canGrantVehicle?: boolean;
|
canGrantVehicle?: boolean;
|
||||||
canGrantVip?: boolean;
|
|
||||||
canTransferUserCountry?: boolean;
|
canTransferUserCountry?: boolean;
|
||||||
canUpdateUserLevel?: boolean;
|
canUpdateUserLevel?: boolean;
|
||||||
contact?: string;
|
contact?: string;
|
||||||
|
|||||||
@ -18,9 +18,7 @@ const quickRanges = [
|
|||||||
];
|
];
|
||||||
const weekLabels = ["一", "二", "三", "四", "五", "六", "日"];
|
const weekLabels = ["一", "二", "三", "四", "五", "六", "日"];
|
||||||
|
|
||||||
// withTime=false 时是纯日期区间选择器:隐藏时/分与小时级快捷区间,
|
export function TimeRangeFilter({ className = "", disabled = false, label = "时间", onChange, value }) {
|
||||||
// 选中的开始/结束日分别取当天 00:00:00.000 与 23:59:59.999 的本地毫秒。
|
|
||||||
export function TimeRangeFilter({ className = "", disabled = false, label = "时间", onChange, value, withTime = true }) {
|
|
||||||
const range = useMemo(() => normalizeRange(value), [value]);
|
const range = useMemo(() => normalizeRange(value), [value]);
|
||||||
const [anchorEl, setAnchorEl] = useState(null);
|
const [anchorEl, setAnchorEl] = useState(null);
|
||||||
const [draft, setDraft] = useState(() => range);
|
const [draft, setDraft] = useState(() => range);
|
||||||
@ -41,14 +39,7 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
|
|||||||
}
|
}
|
||||||
}, [open, range]);
|
}, [open, range]);
|
||||||
|
|
||||||
const formatPart = withTime ? formatRangePart : formatDayPart;
|
const displayLabel = useMemo(() => timeRangeLabel(label, range), [label, range]);
|
||||||
const displayLabel = useMemo(
|
|
||||||
() =>
|
|
||||||
!range.startMs && !range.endMs
|
|
||||||
? label
|
|
||||||
: `${formatPart(range.startMs) || "不限"} - ${formatPart(range.endMs) || "不限"}`,
|
|
||||||
[formatPart, label, range],
|
|
||||||
);
|
|
||||||
|
|
||||||
const openPopover = (event) => {
|
const openPopover = (event) => {
|
||||||
if (disabled) {
|
if (disabled) {
|
||||||
@ -80,12 +71,15 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
|
|||||||
const changeDay = (day) => {
|
const changeDay = (day) => {
|
||||||
setDraft((current) => {
|
setDraft((current) => {
|
||||||
const base = dateForField(current, activeField, viewDate);
|
const base = dateForField(current, activeField, viewDate);
|
||||||
const timeParts = withTime
|
const nextDate = new Date(
|
||||||
? [base.getHours(), base.getMinutes(), 0, 0]
|
day.getFullYear(),
|
||||||
: activeField === "start"
|
day.getMonth(),
|
||||||
? [0, 0, 0, 0]
|
day.getDate(),
|
||||||
: [23, 59, 59, 999];
|
base.getHours(),
|
||||||
const nextDate = new Date(day.getFullYear(), day.getMonth(), day.getDate(), ...timeParts);
|
base.getMinutes(),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
);
|
||||||
return setRangeField(current, activeField, nextDate.getTime());
|
return setRangeField(current, activeField, nextDate.getTime());
|
||||||
});
|
});
|
||||||
setViewDate(new Date(day.getFullYear(), day.getMonth(), 1));
|
setViewDate(new Date(day.getFullYear(), day.getMonth(), 1));
|
||||||
@ -111,7 +105,7 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
const nextRange = normalizeRange(draft);
|
const nextRange = normalizeRange(draft);
|
||||||
if (nextRange.startMs && nextRange.endMs && nextRange.endMs <= nextRange.startMs) {
|
if (nextRange.startMs && nextRange.endMs && nextRange.endMs <= nextRange.startMs) {
|
||||||
setError(withTime ? "结束时间必须晚于开始时间" : "结束日期不能早于开始日期");
|
setError("结束时间必须晚于开始时间");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
applyRange(nextRange);
|
applyRange(nextRange);
|
||||||
@ -142,31 +136,27 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
|
|||||||
onClose={closePopover}
|
onClose={closePopover}
|
||||||
>
|
>
|
||||||
<form className={styles.panel} onSubmit={submitDraft}>
|
<form className={styles.panel} onSubmit={submitDraft}>
|
||||||
{withTime ? (
|
<div className={styles.quickRanges}>
|
||||||
<div className={styles.quickRanges}>
|
{quickRanges.map(([quickLabel, durationMs]) => (
|
||||||
{quickRanges.map(([quickLabel, durationMs]) => (
|
<Button
|
||||||
<Button
|
key={quickLabel}
|
||||||
key={quickLabel}
|
className={styles.quickButton}
|
||||||
className={styles.quickButton}
|
onClick={() => applyQuickRange(durationMs)}
|
||||||
onClick={() => applyQuickRange(durationMs)}
|
>
|
||||||
>
|
{quickLabel}
|
||||||
{quickLabel}
|
</Button>
|
||||||
</Button>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className={styles.rangeFields}>
|
<div className={styles.rangeFields}>
|
||||||
<RangeFieldButton
|
<RangeFieldButton
|
||||||
active={activeField === "start"}
|
active={activeField === "start"}
|
||||||
formatPart={formatPart}
|
label="开始时间"
|
||||||
label={withTime ? "开始时间" : "开始日期"}
|
|
||||||
value={draft.startMs}
|
value={draft.startMs}
|
||||||
onClick={() => activateField("start")}
|
onClick={() => activateField("start")}
|
||||||
/>
|
/>
|
||||||
<RangeFieldButton
|
<RangeFieldButton
|
||||||
active={activeField === "end"}
|
active={activeField === "end"}
|
||||||
formatPart={formatPart}
|
label="结束时间"
|
||||||
label={withTime ? "结束时间" : "结束日期"}
|
|
||||||
value={draft.endMs}
|
value={draft.endMs}
|
||||||
onClick={() => activateField("end")}
|
onClick={() => activateField("end")}
|
||||||
/>
|
/>
|
||||||
@ -207,36 +197,34 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{withTime ? (
|
<div className={styles.timeGrid}>
|
||||||
<div className={styles.timeGrid}>
|
<TextField
|
||||||
<TextField
|
label="时"
|
||||||
label="时"
|
select
|
||||||
select
|
size="small"
|
||||||
size="small"
|
value={String(selectedDate.getHours())}
|
||||||
value={String(selectedDate.getHours())}
|
onChange={(event) => changeTime("hour", event.target.value)}
|
||||||
onChange={(event) => changeTime("hour", event.target.value)}
|
>
|
||||||
>
|
{Array.from({ length: 24 }, (_, hour) => (
|
||||||
{Array.from({ length: 24 }, (_, hour) => (
|
<MenuItem key={hour} value={String(hour)}>
|
||||||
<MenuItem key={hour} value={String(hour)}>
|
{pad2(hour)}
|
||||||
{pad2(hour)}
|
</MenuItem>
|
||||||
</MenuItem>
|
))}
|
||||||
))}
|
</TextField>
|
||||||
</TextField>
|
<TextField
|
||||||
<TextField
|
label="分"
|
||||||
label="分"
|
select
|
||||||
select
|
size="small"
|
||||||
size="small"
|
value={String(selectedDate.getMinutes())}
|
||||||
value={String(selectedDate.getMinutes())}
|
onChange={(event) => changeTime("minute", event.target.value)}
|
||||||
onChange={(event) => changeTime("minute", event.target.value)}
|
>
|
||||||
>
|
{Array.from({ length: 60 }, (_, minute) => (
|
||||||
{Array.from({ length: 60 }, (_, minute) => (
|
<MenuItem key={minute} value={String(minute)}>
|
||||||
<MenuItem key={minute} value={String(minute)}>
|
{pad2(minute)}
|
||||||
{pad2(minute)}
|
</MenuItem>
|
||||||
</MenuItem>
|
))}
|
||||||
))}
|
</TextField>
|
||||||
</TextField>
|
</div>
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{error ? <div className={styles.error}>{error}</div> : null}
|
{error ? <div className={styles.error}>{error}</div> : null}
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
<Button onClick={clearRange}>清空</Button>
|
<Button onClick={clearRange}>清空</Button>
|
||||||
@ -250,7 +238,7 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RangeFieldButton({ active, formatPart = formatRangePart, label, onClick, value }) {
|
function RangeFieldButton({ active, label, onClick, value }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={[styles.rangeField, active ? styles.rangeFieldActive : ""].filter(Boolean).join(" ")}
|
className={[styles.rangeField, active ? styles.rangeFieldActive : ""].filter(Boolean).join(" ")}
|
||||||
@ -258,7 +246,7 @@ function RangeFieldButton({ active, formatPart = formatRangePart, label, onClick
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<span className={styles.rangeFieldLabel}>{label}</span>
|
<span className={styles.rangeFieldLabel}>{label}</span>
|
||||||
<span className={styles.rangeFieldValue}>{formatPart(value) || "不限"}</span>
|
<span className={styles.rangeFieldValue}>{formatRangePart(value) || "不限"}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -349,15 +337,6 @@ function formatRangePart(value) {
|
|||||||
return text ? text.replace("-", "/").replace("-", "/") : "";
|
return text ? text.replace("-", "/").replace("-", "/") : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDayPart(value) {
|
|
||||||
const ms = normalizeMs(value);
|
|
||||||
if (!ms) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
const date = new Date(ms);
|
|
||||||
return `${date.getFullYear()}/${pad2(date.getMonth() + 1)}/${pad2(date.getDate())}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function msToDatetimeText(value) {
|
function msToDatetimeText(value) {
|
||||||
const ms = normalizeMs(value);
|
const ms = normalizeMs(value);
|
||||||
if (!ms) {
|
if (!ms) {
|
||||||
|
|||||||
@ -26,33 +26,6 @@ describe("TimeRangeFilter helpers", () => {
|
|||||||
expect(timeRangeLabel("时间", {})).toBe("时间");
|
expect(timeRangeLabel("时间", {})).toBe("时间");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("date-only mode hides time controls and returns day boundaries", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const onChange = vi.fn();
|
|
||||||
const startMs = datetimeTextToMs("2026-05-13 00:00");
|
|
||||||
const endMs = datetimeTextToMs("2026-05-20 23:59");
|
|
||||||
|
|
||||||
render(<TimeRangeFilter label="自定义区间" value={{ endMs, startMs }} withTime={false} onChange={onChange} />);
|
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: /自定义区间/ }));
|
|
||||||
|
|
||||||
expect(screen.queryByLabelText("时")).toBeNull();
|
|
||||||
expect(screen.queryByRole("button", { name: "近 24 小时" })).toBeNull();
|
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: /结束日期/ }));
|
|
||||||
await user.click(screen.getByRole("button", { name: "21" }));
|
|
||||||
await user.click(screen.getByRole("button", { name: "确定" }));
|
|
||||||
|
|
||||||
const next = onChange.mock.calls[0][0];
|
|
||||||
const end = new Date(next.endMs);
|
|
||||||
expect(next.startMs).toBe(startMs);
|
|
||||||
expect(end.getFullYear()).toBe(2026);
|
|
||||||
expect(end.getMonth()).toBe(4);
|
|
||||||
expect(end.getDate()).toBe(21);
|
|
||||||
expect(end.getHours()).toBe(23);
|
|
||||||
expect(end.getMinutes()).toBe(59);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("confirms picker changes without submitting an outer form", async () => {
|
test("confirms picker changes without submitting an outer form", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
|
|||||||
@ -2,16 +2,10 @@ import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
|||||||
import FileUploadOutlined from "@mui/icons-material/FileUploadOutlined";
|
import FileUploadOutlined from "@mui/icons-material/FileUploadOutlined";
|
||||||
import ImageOutlined from "@mui/icons-material/ImageOutlined";
|
import ImageOutlined from "@mui/icons-material/ImageOutlined";
|
||||||
import InsertDriveFileOutlined from "@mui/icons-material/InsertDriveFileOutlined";
|
import InsertDriveFileOutlined from "@mui/icons-material/InsertDriveFileOutlined";
|
||||||
import VisibilityOutlined from "@mui/icons-material/VisibilityOutlined";
|
|
||||||
import CircularProgress from "@mui/material/CircularProgress";
|
import CircularProgress from "@mui/material/CircularProgress";
|
||||||
import Dialog from "@mui/material/Dialog";
|
|
||||||
import DialogActions from "@mui/material/DialogActions";
|
|
||||||
import DialogContent from "@mui/material/DialogContent";
|
|
||||||
import DialogTitle from "@mui/material/DialogTitle";
|
|
||||||
import Tooltip from "@mui/material/Tooltip";
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
import { useEffect, useId, useRef, useState } from "react";
|
import { useEffect, useId, useRef, useState } from "react";
|
||||||
import { uploadFile, uploadImage } from "@/shared/api/upload";
|
import { uploadFile, uploadImage } from "@/shared/api/upload";
|
||||||
import { Button } from "@/shared/ui/Button.jsx";
|
|
||||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
import styles from "./UploadField.module.css";
|
import styles from "./UploadField.module.css";
|
||||||
|
|
||||||
@ -39,7 +33,6 @@ export function UploadField({
|
|||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [localPreview, setLocalPreview] = useState(null);
|
const [localPreview, setLocalPreview] = useState(null);
|
||||||
const [videoPreviewOpen, setVideoPreviewOpen] = useState(false);
|
|
||||||
const isImage = kind === "image";
|
const isImage = kind === "image";
|
||||||
const uploadMode = uploadKind || kind;
|
const uploadMode = uploadKind || kind;
|
||||||
const useImageUpload = uploadMode === "image";
|
const useImageUpload = uploadMode === "image";
|
||||||
@ -47,7 +40,6 @@ export function UploadField({
|
|||||||
const assetKind = localPreview?.assetKind || getAssetKind(value, kind);
|
const assetKind = localPreview?.assetKind || getAssetKind(value, kind);
|
||||||
const displayName = localPreview?.name || getDisplayValue(value);
|
const displayName = localPreview?.name || getDisplayValue(value);
|
||||||
const inputAccept = accept !== undefined ? accept : useImageUpload && isImage ? imageAccept : undefined;
|
const inputAccept = accept !== undefined ? accept : useImageUpload && isImage ? imageAccept : undefined;
|
||||||
const canPreviewVideo = Boolean(source && assetKind === "mp4");
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@ -97,7 +89,6 @@ export function UploadField({
|
|||||||
|
|
||||||
const clearValue = () => {
|
const clearValue = () => {
|
||||||
setLocalPreview(null);
|
setLocalPreview(null);
|
||||||
setVideoPreviewOpen(false);
|
|
||||||
onChange("");
|
onChange("");
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -151,21 +142,6 @@ export function UploadField({
|
|||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{canPreviewVideo ? (
|
|
||||||
<Tooltip arrow title="查看 MP4">
|
|
||||||
<span>
|
|
||||||
<button
|
|
||||||
className={styles.action}
|
|
||||||
disabled={disabled || uploading}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setVideoPreviewOpen(true)}
|
|
||||||
>
|
|
||||||
<VisibilityOutlined fontSize="small" />
|
|
||||||
查看
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
) : null}
|
|
||||||
{source ? (
|
{source ? (
|
||||||
<Tooltip arrow title="清除">
|
<Tooltip arrow title="清除">
|
||||||
<span>
|
<span>
|
||||||
@ -184,12 +160,6 @@ export function UploadField({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<VideoPreviewDialog
|
|
||||||
label={label}
|
|
||||||
open={videoPreviewOpen}
|
|
||||||
src={canPreviewVideo ? source : ""}
|
|
||||||
onClose={() => setVideoPreviewOpen(false)}
|
|
||||||
/>
|
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
accept={inputAccept}
|
accept={inputAccept}
|
||||||
@ -202,24 +172,6 @@ export function UploadField({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function VideoPreviewDialog({ label, onClose, open, src }) {
|
|
||||||
return (
|
|
||||||
<Dialog fullWidth maxWidth="sm" open={open} onClose={onClose}>
|
|
||||||
<DialogTitle>{label} MP4</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<div className={styles.videoPreviewFrame}>
|
|
||||||
{src ? (
|
|
||||||
<video className={styles.videoPreview} controls playsInline preload="metadata" src={src} />
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={onClose}>关闭</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AssetPreview({ assetKind, isImage, src }) {
|
function AssetPreview({ assetKind, isImage, src }) {
|
||||||
if (!src) {
|
if (!src) {
|
||||||
return (
|
return (
|
||||||
@ -416,9 +368,6 @@ function getAssetKind(value, kind, contentType = "") {
|
|||||||
if (extension === "pag") {
|
if (extension === "pag") {
|
||||||
return "pag";
|
return "pag";
|
||||||
}
|
}
|
||||||
if (extension === "mp4") {
|
|
||||||
return "mp4";
|
|
||||||
}
|
|
||||||
if (isRasterImageExtension(extension)) {
|
if (isRasterImageExtension(extension)) {
|
||||||
return "image";
|
return "image";
|
||||||
}
|
}
|
||||||
@ -429,9 +378,6 @@ function getAssetKind(value, kind, contentType = "") {
|
|||||||
if (normalizedType.includes("pag")) {
|
if (normalizedType.includes("pag")) {
|
||||||
return "pag";
|
return "pag";
|
||||||
}
|
}
|
||||||
if (normalizedType === "video/mp4") {
|
|
||||||
return "mp4";
|
|
||||||
}
|
|
||||||
if (isRasterImageContentType(normalizedType)) {
|
if (isRasterImageContentType(normalizedType)) {
|
||||||
return "image";
|
return "image";
|
||||||
}
|
}
|
||||||
@ -476,8 +422,5 @@ function assetKindLabel(assetKind, isImage) {
|
|||||||
if (assetKind === "pag") {
|
if (assetKind === "pag") {
|
||||||
return "PAG";
|
return "PAG";
|
||||||
}
|
}
|
||||||
if (assetKind === "mp4") {
|
|
||||||
return "MP4";
|
|
||||||
}
|
|
||||||
return isImage ? "图片" : "文件";
|
return isImage ? "图片" : "文件";
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user