From 2eb18502a278a9eb4e639e2c367f2cd8d78fdbed Mon Sep 17 00:00:00 2001 From: ZuoZuo <68836346+Mrz-sakura@users.noreply.github.com> Date: Wed, 15 Jul 2026 09:44:04 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B4=BB=E5=8A=A8=E6=A8=A1=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contracts/admin-openapi.json | 2463 +++++++++++++++++ .../activity-templates/permissions.md | 35 + src/app/navigation/menu.js | 4 +- src/app/navigation/menu.test.jsx | 12 + src/app/permissions.ts | 9 + src/app/router/routeConfig.ts | 2 + .../activity-templates.module.css | 1302 +++++++++ src/features/activity-templates/api.ts | 861 ++++++ .../components/ActivityPhonePreview.jsx | 159 ++ .../components/ActivityPhonePreview.test.jsx | 23 + .../ActivityTemplateCloneDialog.jsx | 63 + .../components/ActivityTemplateDataDrawer.jsx | 184 ++ .../ActivityTemplateDataOverview.jsx | 175 ++ .../ActivityTemplateEditorDrawer.jsx | 256 ++ .../ActivityTemplateEditorDrawer.test.jsx | 125 + .../ActivityTemplateLeaderboardPanel.jsx | 140 + .../ActivityTemplateOverlaySkeletons.jsx | 30 + .../ActivityTemplateRewardsPanel.jsx | 206 ++ .../ActivityTemplateRewardsPanel.test.jsx | 62 + .../ActivityTemplateStatusBadge.jsx | 12 + .../ActivityTemplateTaskClaimsPanel.jsx | 213 ++ .../ActivityTemplateTaskClaimsPanel.test.jsx | 65 + .../ActivityTemplateVersionsDrawer.jsx | 123 + .../components/BasicAndContentSections.jsx | 227 ++ .../components/GiftTaskSections.jsx | 235 ++ .../components/RankingAssetsSections.jsx | 169 ++ src/features/activity-templates/constants.js | 58 + src/features/activity-templates/dataModel.js | 193 ++ .../activity-templates/dataModel.test.js | 145 + .../hooks/useActivityTemplateDataCenter.js | 449 +++ .../hooks/useActivityTemplatesPage.js | 600 ++++ .../hooks/useActivityTemplatesPage.test.jsx | 288 ++ src/features/activity-templates/model.js | 262 ++ src/features/activity-templates/model.test.js | 157 ++ .../pages/ActivityTemplatesPage.jsx | 395 +++ .../pages/ActivityTemplatesPage.test.jsx | 193 ++ .../activity-templates/permissions.js | 17 + src/features/activity-templates/routes.js | 12 + src/features/activity-templates/schema.ts | 310 +++ src/features/roles/hooks/useRolesPage.js | 3 +- src/shared/api/generated/endpoints.ts | 128 + src/shared/api/generated/schema.d.ts | 1075 +++++++ src/shared/charts/MultiSeriesLineChart.jsx | 141 + .../charts/MultiSeriesLineChart.test.jsx | 48 + src/styles/tokens.css | 1 + 45 files changed, 11628 insertions(+), 2 deletions(-) create mode 100644 generated/admin-modules/activity-templates/permissions.md create mode 100644 src/features/activity-templates/activity-templates.module.css create mode 100644 src/features/activity-templates/api.ts create mode 100644 src/features/activity-templates/components/ActivityPhonePreview.jsx create mode 100644 src/features/activity-templates/components/ActivityPhonePreview.test.jsx create mode 100644 src/features/activity-templates/components/ActivityTemplateCloneDialog.jsx create mode 100644 src/features/activity-templates/components/ActivityTemplateDataDrawer.jsx create mode 100644 src/features/activity-templates/components/ActivityTemplateDataOverview.jsx create mode 100644 src/features/activity-templates/components/ActivityTemplateEditorDrawer.jsx create mode 100644 src/features/activity-templates/components/ActivityTemplateEditorDrawer.test.jsx create mode 100644 src/features/activity-templates/components/ActivityTemplateLeaderboardPanel.jsx create mode 100644 src/features/activity-templates/components/ActivityTemplateOverlaySkeletons.jsx create mode 100644 src/features/activity-templates/components/ActivityTemplateRewardsPanel.jsx create mode 100644 src/features/activity-templates/components/ActivityTemplateRewardsPanel.test.jsx create mode 100644 src/features/activity-templates/components/ActivityTemplateStatusBadge.jsx create mode 100644 src/features/activity-templates/components/ActivityTemplateTaskClaimsPanel.jsx create mode 100644 src/features/activity-templates/components/ActivityTemplateTaskClaimsPanel.test.jsx create mode 100644 src/features/activity-templates/components/ActivityTemplateVersionsDrawer.jsx create mode 100644 src/features/activity-templates/components/BasicAndContentSections.jsx create mode 100644 src/features/activity-templates/components/GiftTaskSections.jsx create mode 100644 src/features/activity-templates/components/RankingAssetsSections.jsx create mode 100644 src/features/activity-templates/constants.js create mode 100644 src/features/activity-templates/dataModel.js create mode 100644 src/features/activity-templates/dataModel.test.js create mode 100644 src/features/activity-templates/hooks/useActivityTemplateDataCenter.js create mode 100644 src/features/activity-templates/hooks/useActivityTemplatesPage.js create mode 100644 src/features/activity-templates/hooks/useActivityTemplatesPage.test.jsx create mode 100644 src/features/activity-templates/model.js create mode 100644 src/features/activity-templates/model.test.js create mode 100644 src/features/activity-templates/pages/ActivityTemplatesPage.jsx create mode 100644 src/features/activity-templates/pages/ActivityTemplatesPage.test.jsx create mode 100644 src/features/activity-templates/permissions.js create mode 100644 src/features/activity-templates/routes.js create mode 100644 src/features/activity-templates/schema.ts create mode 100644 src/shared/charts/MultiSeriesLineChart.jsx create mode 100644 src/shared/charts/MultiSeriesLineChart.test.jsx diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index cf4f927..736a061 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -7220,6 +7220,696 @@ "x-permission": "pretty-id:update", "x-permissions": ["pretty-id:update"] } + }, + "/admin/activities/templates": { + "get": { + "operationId": "listActivityTemplates", + "parameters": [ + { + "$ref": "#/components/parameters/Keyword" + }, + { + "$ref": "#/components/parameters/Status" + }, + { + "$ref": "#/components/parameters/RegionId" + }, + { + "name": "start_ms", + "in": "query", + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "end_ms", + "in": "query", + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "all_regions", + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "$ref": "#/components/parameters/Page" + }, + { + "$ref": "#/components/parameters/PageSize" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/ActivityTemplatePageResponse" + } + }, + "x-permission": "activity-template:view", + "x-permissions": [ + "activity-template:view" + ] + }, + "post": { + "operationId": "createActivityTemplate", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActivityTemplateInput" + } + } + } + }, + "responses": { + "201": { + "$ref": "#/components/responses/ActivityTemplateResponse" + } + }, + "x-permission": "activity-template:create", + "x-permissions": [ + "activity-template:create" + ] + } + }, + "/admin/activities/templates/validate": { + "post": { + "operationId": "validateActivityTemplate", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActivityTemplateValidationInput" + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/ActivityTemplateValidationResponse" + } + }, + "x-permission": "activity-template:update", + "x-permissions": [ + "activity-template:create", + "activity-template:update", + "activity-template:publish" + ] + } + }, + "/admin/activities/templates/{template_id}": { + "parameters": [ + { + "name": "template_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "operationId": "getActivityTemplate", + "responses": { + "200": { + "$ref": "#/components/responses/ActivityTemplateResponse" + } + }, + "x-permission": "activity-template:view", + "x-permissions": [ + "activity-template:view" + ] + }, + "put": { + "operationId": "updateActivityTemplate", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActivityTemplateUpdateInput" + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/ActivityTemplateResponse" + } + }, + "x-permission": "activity-template:update", + "x-permissions": [ + "activity-template:update" + ] + }, + "delete": { + "operationId": "deleteActivityTemplate", + "parameters": [ + { + "name": "revision", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1 + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/ActivityTemplateArchiveResponse" + } + }, + "x-permission": "activity-template:delete", + "x-permissions": [ + "activity-template:delete" + ] + } + }, + "/admin/activities/templates/{template_id}/status": { + "parameters": [ + { + "name": "template_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "patch": { + "operationId": "setActivityTemplateStatus", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActivityTemplateStatusInput" + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/ActivityTemplateResponse" + } + }, + "x-permission": "activity-template:publish", + "x-permissions": [ + "activity-template:publish" + ] + } + }, + "/admin/activities/templates/{template_id}/clone": { + "parameters": [ + { + "name": "template_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "post": { + "operationId": "cloneActivityTemplate", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActivityTemplateCloneInput" + } + } + } + }, + "responses": { + "201": { + "$ref": "#/components/responses/ActivityTemplateResponse" + } + }, + "x-permission": "activity-template:create", + "x-permissions": [ + "activity-template:create" + ] + } + }, + "/admin/activities/templates/{template_id}/versions": { + "parameters": [ + { + "name": "template_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "operationId": "listActivityTemplateVersions", + "parameters": [ + { + "$ref": "#/components/parameters/Page" + }, + { + "$ref": "#/components/parameters/PageSize" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/ActivityTemplateVersionPageResponse" + } + }, + "x-permission": "activity-template:view", + "x-permissions": [ + "activity-template:view" + ] + } + }, + "/admin/activities/templates/{template_id}/data": { + "parameters": [ + { + "name": "template_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "operationId": "getActivityTemplateData", + "parameters": [ + { + "name": "version_no", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1 + } + }, + { + "name": "start_ms", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1 + } + }, + { + "name": "end_ms", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1 + } + }, + { + "name": "region_id", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1 + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/ActivityTemplateDataResponse" + } + }, + "x-permission": "activity-template:data", + "x-permissions": [ + "activity-template:data" + ] + } + }, + "/admin/activities/templates/{template_id}/data/export": { + "parameters": [ + { + "name": "template_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "operationId": "exportActivityTemplateData", + "parameters": [ + { + "name": "version_no", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1 + } + }, + { + "name": "start_ms", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1 + } + }, + { + "name": "end_ms", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1 + } + }, + { + "name": "region_id", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1 + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/ActivityTemplateDataResponse" + } + }, + "x-permission": "activity-template:export", + "x-permissions": [ + "activity-template:export" + ] + } + }, + "/admin/activities/templates/{template_id}/leaderboard": { + "parameters": [ + { + "name": "template_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "operationId": "listActivityTemplateLeaderboard", + "parameters": [ + { + "name": "version_no", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1 + } + }, + { + "name": "board_type", + "in": "query", + "required": true, + "schema": { + "type": "string", + "enum": [ + "daily", + "total" + ] + } + }, + { + "name": "period_key", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Page" + }, + { + "$ref": "#/components/parameters/PageSize" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/ActivityTemplateLeaderboardPageResponse" + } + }, + "x-permission": "activity-template:data", + "x-permissions": [ + "activity-template:data" + ] + } + }, + "/admin/activities/templates/{template_id}/rewards": { + "parameters": [ + { + "name": "template_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "operationId": "listActivityTemplateRewardJobs", + "parameters": [ + { + "name": "version_no", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1 + } + }, + { + "name": "board_type", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "daily", + "total" + ] + } + }, + { + "name": "period_key", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "pending", + "running", + "granted", + "failed", + "dead" + ] + } + }, + { + "$ref": "#/components/parameters/Page" + }, + { + "$ref": "#/components/parameters/PageSize" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/ActivityTemplateRewardJobPageResponse" + } + }, + "x-permission": "activity-template:data", + "x-permissions": [ + "activity-template:data" + ] + } + }, + "/admin/activities/templates/{template_id}/rewards/{reward_job_id}/retry": { + "parameters": [ + { + "name": "template_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "reward_job_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "post": { + "operationId": "retryActivityTemplateRewardJob", + "responses": { + "200": { + "$ref": "#/components/responses/ActivityTemplateRewardJobResponse" + } + }, + "x-permission": "activity-template:retry", + "x-permissions": [ + "activity-template:retry" + ] + } + }, + "/admin/activities/templates/{template_id}/task-claims": { + "parameters": [ + { + "name": "template_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "operationId": "listActivityTemplateTaskClaims", + "parameters": [ + { + "name": "version_no", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1 + } + }, + { + "name": "task_day", + "in": "query", + "required": false, + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "task_key", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "user_id", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1 + } + }, + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "pending", + "running", + "granted", + "failed" + ] + } + }, + { + "$ref": "#/components/parameters/Page" + }, + { + "$ref": "#/components/parameters/PageSize" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/ActivityTemplateTaskClaimPageResponse" + } + }, + "x-permission": "activity-template:data", + "x-permissions": [ + "activity-template:data" + ] + } + }, + "/admin/activities/templates/{template_id}/task-claims/{claim_id}/retry": { + "parameters": [ + { + "name": "template_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "claim_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "post": { + "operationId": "retryActivityTemplateTaskClaim", + "responses": { + "200": { + "$ref": "#/components/responses/ActivityTemplateTaskClaimResponse" + } + }, + "x-permission": "activity-template:retry", + "x-permissions": [ + "activity-template:retry" + ] + } } }, "components": { @@ -8284,6 +8974,116 @@ } } } + }, + "ActivityTemplatePageResponse": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseActivityTemplatePage" + } + } + } + }, + "ActivityTemplateResponse": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseActivityTemplate" + } + } + } + }, + "ActivityTemplateArchiveResponse": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseActivityTemplateArchive" + } + } + } + }, + "ActivityTemplateValidationResponse": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseActivityTemplateValidation" + } + } + } + }, + "ActivityTemplateVersionPageResponse": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseActivityTemplateVersionPage" + } + } + } + }, + "ActivityTemplateDataResponse": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseActivityTemplateData" + } + } + } + }, + "ActivityTemplateLeaderboardPageResponse": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseActivityTemplateLeaderboardPage" + } + } + } + }, + "ActivityTemplateRewardJobPageResponse": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseActivityTemplateRewardJobPage" + } + } + } + }, + "ActivityTemplateRewardJobResponse": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseActivityTemplateRewardJob" + } + } + } + }, + "ActivityTemplateTaskClaimPageResponse": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseActivityTemplateTaskClaimPage" + } + } + } + }, + "ActivityTemplateTaskClaimResponse": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseActivityTemplateTaskClaim" + } + } + } } }, "schemas": { @@ -12754,6 +13554,1669 @@ } } } + }, + "ActivityTemplateLocale": { + "type": "object", + "required": [ + "locale", + "title", + "rules" + ], + "properties": { + "locale": { + "type": "string", + "minLength": 2, + "maxLength": 16 + }, + "title": { + "type": "string", + "maxLength": 160 + }, + "rules": { + "type": "string", + "maxLength": 32768 + } + } + }, + "ActivityTemplateGift": { + "type": "object", + "required": [ + "gift_id", + "sort_order", + "name", + "icon_url", + "coin_price", + "price_version" + ], + "properties": { + "gift_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "sort_order": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "name": { + "type": "string", + "maxLength": 128 + }, + "icon_url": { + "type": "string", + "maxLength": 512 + }, + "coin_price": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "price_version": { + "type": "string", + "maxLength": 64 + } + } + }, + "ActivityTemplateRewardItem": { + "type": "object", + "properties": { + "item_type": { + "type": "string" + }, + "resource_id": { + "type": "integer", + "format": "int64" + }, + "resource_type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "icon_url": { + "type": "string" + }, + "quantity": { + "type": "integer", + "format": "int64" + }, + "duration_ms": { + "type": "integer", + "format": "int64" + }, + "wallet_asset_type": { + "type": "string" + }, + "wallet_asset_amount": { + "type": "integer", + "format": "int64" + }, + "sort_order": { + "type": "integer", + "format": "int32" + } + } + }, + "ActivityTemplateTask": { + "type": "object", + "required": [ + "task_key", + "task_type", + "target_value", + "reward_resource_group_id", + "sort_order" + ], + "properties": { + "task_key": { + "type": "string", + "minLength": 2, + "maxLength": 64, + "pattern": "^[a-z][a-z0-9_-]{1,63}$" + }, + "task_type": { + "type": "string", + "enum": [ + "daily_visit", + "gift_count", + "gift_coin_amount" + ] + }, + "target_value": { + "type": "integer", + "format": "int64", + "minimum": 1 + }, + "reward_resource_group_id": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "reward_snapshot_id": { + "type": "string" + }, + "reward_snapshot_hash": { + "type": "string" + }, + "reward_name": { + "type": "string" + }, + "reward_items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActivityTemplateRewardItem" + } + }, + "sort_order": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + }, + "ActivityTemplateRankReward": { + "type": "object", + "required": [ + "board_type", + "rank_from", + "rank_to", + "resource_group_id", + "sort_order" + ], + "properties": { + "board_type": { + "type": "string", + "enum": [ + "daily", + "total" + ] + }, + "rank_from": { + "type": "integer", + "format": "int32", + "minimum": 1 + }, + "rank_to": { + "type": "integer", + "format": "int32", + "minimum": 1 + }, + "resource_group_id": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "reward_snapshot_id": { + "type": "string" + }, + "reward_snapshot_hash": { + "type": "string" + }, + "reward_name": { + "type": "string" + }, + "reward_items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActivityTemplateRewardItem" + } + }, + "sort_order": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + }, + "ActivityTemplateAsset": { + "type": "object", + "required": [ + "asset_key", + "locale", + "url", + "media_type", + "sort_order" + ], + "properties": { + "asset_key": { + "type": "string", + "minLength": 2, + "maxLength": 64, + "pattern": "^[a-z][a-z0-9_.-]{1,63}$" + }, + "locale": { + "type": "string", + "minLength": 1, + "maxLength": 16 + }, + "url": { + "type": "string", + "maxLength": 1024 + }, + "media_type": { + "type": "string", + "enum": [ + "image", + "webp", + "svga", + "pag" + ] + }, + "sort_order": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + }, + "ActivityTemplateFields": { + "type": "object", + "required": [ + "template_code", + "name", + "activity_type", + "start_ms", + "end_ms", + "all_regions", + "region_ids", + "locales", + "gifts", + "tasks", + "daily_leaderboard_size", + "total_leaderboard_size", + "rank_rewards", + "assets", + "display_config_json" + ], + "properties": { + "template_code": { + "type": "string", + "minLength": 3, + "maxLength": 64, + "pattern": "^[a-z][a-z0-9_-]{2,63}$" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "activity_type": { + "type": "string", + "const": "gift_challenge" + }, + "start_ms": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "end_ms": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "all_regions": { + "type": "boolean" + }, + "region_ids": { + "type": "array", + "maxItems": 100, + "uniqueItems": true, + "items": { + "type": "integer", + "format": "int64", + "minimum": 1 + } + }, + "locales": { + "type": "array", + "maxItems": 20, + "items": { + "$ref": "#/components/schemas/ActivityTemplateLocale" + } + }, + "gifts": { + "type": "array", + "maxItems": 20, + "items": { + "$ref": "#/components/schemas/ActivityTemplateGift" + } + }, + "tasks": { + "type": "array", + "maxItems": 30, + "items": { + "$ref": "#/components/schemas/ActivityTemplateTask" + } + }, + "daily_leaderboard_size": { + "type": "integer", + "format": "int32", + "minimum": 3, + "maximum": 1000 + }, + "total_leaderboard_size": { + "type": "integer", + "format": "int32", + "minimum": 3, + "maximum": 1000 + }, + "rank_rewards": { + "type": "array", + "maxItems": 100, + "items": { + "$ref": "#/components/schemas/ActivityTemplateRankReward" + } + }, + "assets": { + "type": "array", + "maxItems": 100, + "items": { + "$ref": "#/components/schemas/ActivityTemplateAsset" + } + }, + "display_config_json": { + "type": "string", + "maxLength": 65536 + } + } + }, + "ActivityTemplateInput": { + "allOf": [ + { + "$ref": "#/components/schemas/ActivityTemplateFields" + } + ], + "unevaluatedProperties": false + }, + "ActivityTemplateUpdateInput": { + "allOf": [ + { + "$ref": "#/components/schemas/ActivityTemplateFields" + }, + { + "type": "object", + "required": [ + "expected_revision" + ], + "properties": { + "expected_revision": { + "type": "integer", + "format": "int64", + "minimum": 1 + } + } + } + ], + "unevaluatedProperties": false + }, + "ActivityTemplateStatusInput": { + "type": "object", + "additionalProperties": false, + "required": [ + "status", + "expected_revision" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "published", + "disabled" + ] + }, + "expected_revision": { + "type": "integer", + "format": "int64", + "minimum": 1 + } + } + }, + "ActivityTemplateCloneInput": { + "type": "object", + "additionalProperties": false, + "required": [ + "source_version", + "template_code", + "name" + ], + "properties": { + "source_version": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "template_code": { + "type": "string", + "minLength": 3, + "maxLength": 64, + "pattern": "^[a-z][a-z0-9_-]{2,63}$" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + } + }, + "ActivityTemplateValidationInput": { + "type": "object", + "additionalProperties": false, + "required": [ + "template", + "for_publish" + ], + "properties": { + "template": { + "$ref": "#/components/schemas/ActivityTemplateInput" + }, + "for_publish": { + "type": "boolean" + } + } + }, + "ActivityTemplateSummary": { + "type": "object", + "required": [ + "app_code", + "template_id", + "template_code", + "name", + "activity_type", + "status", + "lifecycle_status", + "start_ms", + "end_ms", + "all_regions", + "region_ids", + "revision", + "published_version", + "created_by_admin_id", + "updated_by_admin_id", + "created_at_ms", + "updated_at_ms" + ], + "properties": { + "app_code": { + "type": "string" + }, + "template_id": { + "type": "string" + }, + "template_code": { + "type": "string" + }, + "name": { + "type": "string" + }, + "activity_type": { + "type": "string", + "const": "gift_challenge" + }, + "status": { + "type": "string", + "enum": [ + "draft", + "published", + "disabled", + "archived" + ] + }, + "lifecycle_status": { + "type": "string", + "enum": [ + "draft", + "scheduled", + "ongoing", + "ended", + "disabled", + "archived" + ] + }, + "start_ms": { + "type": "integer", + "format": "int64" + }, + "end_ms": { + "type": "integer", + "format": "int64" + }, + "all_regions": { + "type": "boolean" + }, + "region_ids": { + "type": "array", + "items": { + "type": "integer", + "format": "int64" + } + }, + "revision": { + "type": "integer", + "format": "int64" + }, + "published_version": { + "type": "integer", + "format": "int64" + }, + "created_by_admin_id": { + "type": "integer", + "format": "int64" + }, + "updated_by_admin_id": { + "type": "integer", + "format": "int64" + }, + "created_at_ms": { + "type": "integer", + "format": "int64" + }, + "updated_at_ms": { + "type": "integer", + "format": "int64" + } + } + }, + "ActivityTemplate": { + "allOf": [ + { + "$ref": "#/components/schemas/ActivityTemplateSummary" + }, + { + "type": "object", + "required": [ + "locales", + "gifts", + "tasks", + "daily_leaderboard_size", + "total_leaderboard_size", + "rank_rewards", + "assets", + "display_config_json", + "published_by_admin_id", + "published_at_ms", + "archived_at_ms" + ], + "properties": { + "locales": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActivityTemplateLocale" + } + }, + "gifts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActivityTemplateGift" + } + }, + "tasks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActivityTemplateTask" + } + }, + "daily_leaderboard_size": { + "type": "integer", + "format": "int32" + }, + "total_leaderboard_size": { + "type": "integer", + "format": "int32" + }, + "rank_rewards": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActivityTemplateRankReward" + } + }, + "assets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActivityTemplateAsset" + } + }, + "display_config_json": { + "type": "string" + }, + "published_by_admin_id": { + "type": "integer", + "format": "int64" + }, + "published_at_ms": { + "type": "integer", + "format": "int64" + }, + "archived_at_ms": { + "type": "integer", + "format": "int64" + } + } + } + ] + }, + "ActivityTemplatePage": { + "type": "object", + "required": [ + "items", + "page", + "page_size", + "total", + "server_time_ms" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActivityTemplateSummary" + } + }, + "page": { + "type": "integer", + "minimum": 1 + }, + "page_size": { + "type": "integer", + "minimum": 1 + }, + "total": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "server_time_ms": { + "type": "integer", + "format": "int64" + } + } + }, + "ActivityTemplateVersion": { + "type": "object", + "required": [ + "template_id", + "version_no", + "snapshot", + "published_by_admin_id", + "published_at_ms", + "runtime_from_ms", + "runtime_to_ms" + ], + "properties": { + "template_id": { + "type": "string" + }, + "version_no": { + "type": "integer", + "format": "int64", + "minimum": 1 + }, + "snapshot": { + "$ref": "#/components/schemas/ActivityTemplate" + }, + "published_by_admin_id": { + "type": "integer", + "format": "int64" + }, + "published_at_ms": { + "type": "integer", + "format": "int64" + }, + "runtime_from_ms": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "runtime_to_ms": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "ActivityTemplateVersionPage": { + "type": "object", + "required": [ + "items", + "page", + "page_size", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActivityTemplateVersion" + } + }, + "page": { + "type": "integer", + "minimum": 1 + }, + "page_size": { + "type": "integer", + "minimum": 1 + }, + "total": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "ActivityTemplateArchive": { + "type": "object", + "required": [ + "template", + "archived" + ], + "properties": { + "template": { + "$ref": "#/components/schemas/ActivityTemplate" + }, + "archived": { + "type": "boolean" + } + } + }, + "ActivityTemplateValidationIssue": { + "type": "object", + "required": [ + "field", + "code", + "message" + ], + "properties": { + "field": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + }, + "ActivityTemplateValidation": { + "type": "object", + "required": [ + "valid", + "issues" + ], + "properties": { + "valid": { + "type": "boolean" + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActivityTemplateValidationIssue" + } + } + } + }, + "ApiResponseActivityTemplatePage": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/ActivityTemplatePage" + } + } + } + ] + }, + "ApiResponseActivityTemplate": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/ActivityTemplate" + } + } + } + ] + }, + "ApiResponseActivityTemplateArchive": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/ActivityTemplateArchive" + } + } + } + ] + }, + "ApiResponseActivityTemplateValidation": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/ActivityTemplateValidation" + } + } + } + ] + }, + "ApiResponseActivityTemplateVersionPage": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/ActivityTemplateVersionPage" + } + } + } + ] + }, + "ActivityTemplateDataOverview": { + "type": "object", + "properties": { + "template_id": { + "type": "string" + }, + "template_code": { + "type": "string" + }, + "published_version": { + "type": "integer", + "format": "int64" + }, + "participants": { + "type": "integer", + "format": "int64" + }, + "visit_count": { + "type": "integer", + "format": "int64" + }, + "gift_users": { + "type": "integer", + "format": "int64" + }, + "task_users": { + "type": "integer", + "format": "int64" + }, + "gift_event_count": { + "type": "integer", + "format": "int64" + }, + "gift_count": { + "type": "integer", + "format": "int64" + }, + "coin_amount": { + "type": "integer", + "format": "int64" + }, + "task_progress": { + "type": "integer", + "format": "int64" + }, + "task_completed": { + "type": "integer", + "format": "int64" + }, + "task_claimed": { + "type": "integer", + "format": "int64" + } + }, + "required": [ + "template_id", + "template_code", + "published_version", + "participants", + "visit_count", + "gift_users", + "task_users", + "gift_event_count", + "gift_count", + "coin_amount", + "task_progress", + "task_completed", + "task_claimed" + ] + }, + "ActivityTemplateDataTrendPoint": { + "type": "object", + "properties": { + "stat_day": { + "type": "string" + }, + "participants": { + "type": "integer", + "format": "int64" + }, + "visit_count": { + "type": "integer", + "format": "int64" + }, + "gift_users": { + "type": "integer", + "format": "int64" + }, + "task_users": { + "type": "integer", + "format": "int64" + }, + "gift_event_count": { + "type": "integer", + "format": "int64" + }, + "gift_count": { + "type": "integer", + "format": "int64" + }, + "coin_amount": { + "type": "integer", + "format": "int64" + }, + "task_progress": { + "type": "integer", + "format": "int64" + }, + "task_completed": { + "type": "integer", + "format": "int64" + }, + "task_claimed": { + "type": "integer", + "format": "int64" + } + }, + "required": [ + "stat_day", + "participants", + "visit_count", + "gift_users", + "task_users", + "gift_event_count", + "gift_count", + "coin_amount", + "task_progress", + "task_completed", + "task_claimed" + ] + }, + "ActivityTemplateGiftStat": { + "type": "object", + "properties": { + "gift_id": { + "type": "string" + }, + "gift_users": { + "type": "integer", + "format": "int64" + }, + "gift_event_count": { + "type": "integer", + "format": "int64" + }, + "gift_count": { + "type": "integer", + "format": "int64" + }, + "coin_amount": { + "type": "integer", + "format": "int64" + } + }, + "required": [ + "gift_id", + "gift_users", + "gift_event_count", + "gift_count", + "coin_amount" + ] + }, + "ActivityTemplateTaskStat": { + "type": "object", + "properties": { + "task_key": { + "type": "string" + }, + "task_type": { + "type": "string" + }, + "task_users": { + "type": "integer", + "format": "int64" + }, + "task_progress": { + "type": "integer", + "format": "int64" + }, + "task_completed": { + "type": "integer", + "format": "int64" + }, + "task_claimed": { + "type": "integer", + "format": "int64" + } + }, + "required": [ + "task_key", + "task_type", + "task_users", + "task_progress", + "task_completed", + "task_claimed" + ] + }, + "ActivityTemplateData": { + "type": "object", + "properties": { + "overview": { + "$ref": "#/components/schemas/ActivityTemplateDataOverview" + }, + "trend": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActivityTemplateDataTrendPoint" + } + }, + "gifts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActivityTemplateGiftStat" + } + }, + "tasks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActivityTemplateTaskStat" + } + }, + "granularity": { + "type": "string" + }, + "timezone": { + "type": "string" + }, + "start_ms": { + "type": "integer", + "format": "int64" + }, + "end_ms": { + "type": "integer", + "format": "int64" + }, + "region_id": { + "type": "integer", + "format": "int64" + }, + "dimension_limit": { + "type": "integer" + }, + "gift_sort": { + "type": "string" + }, + "task_sort": { + "type": "string" + }, + "server_time_ms": { + "type": "integer", + "format": "int64" + } + }, + "required": [ + "overview", + "trend", + "gifts", + "tasks", + "granularity", + "timezone", + "start_ms", + "end_ms", + "region_id", + "dimension_limit", + "gift_sort", + "task_sort", + "server_time_ms" + ] + }, + "ActivityTemplateRankPeriod": { + "type": "object", + "properties": { + "template_id": { + "type": "string" + }, + "template_code": { + "type": "string" + }, + "version_no": { + "type": "integer", + "format": "int64" + }, + "board_type": { + "type": "string", + "enum": [ + "daily", + "total" + ] + }, + "period_key": { + "type": "string" + }, + "period_start_ms": { + "type": "integer", + "format": "int64" + }, + "period_end_ms": { + "type": "integer", + "format": "int64" + }, + "settle_after_ms": { + "type": "integer", + "format": "int64" + }, + "status": { + "type": "string" + }, + "settled_at_ms": { + "type": "integer", + "format": "int64" + } + }, + "required": [ + "template_id", + "template_code", + "version_no", + "board_type", + "period_key", + "period_start_ms", + "period_end_ms", + "settle_after_ms", + "status", + "settled_at_ms" + ] + }, + "ActivityTemplateLeaderboardEntry": { + "type": "object", + "properties": { + "rank_no": { + "type": "integer" + }, + "user_id": { + "type": "integer", + "format": "int64" + }, + "score": { + "type": "integer", + "format": "int64" + }, + "gift_count": { + "type": "integer", + "format": "int64" + }, + "coin_amount": { + "type": "integer", + "format": "int64" + }, + "first_scored_at_ms": { + "type": "integer", + "format": "int64" + }, + "snapshot": { + "type": "boolean" + }, + "user": { + "$ref": "#/components/schemas/ActivityTemplateUser" + } + }, + "required": [ + "rank_no", + "user_id", + "score", + "gift_count", + "coin_amount", + "first_scored_at_ms", + "snapshot" + ] + }, + "ActivityTemplateLeaderboardPage": { + "type": "object", + "properties": { + "period": { + "$ref": "#/components/schemas/ActivityTemplateRankPeriod" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActivityTemplateLeaderboardEntry" + } + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total": { + "type": "integer", + "format": "int64" + } + }, + "required": [ + "period", + "items", + "page", + "page_size", + "total" + ] + }, + "ActivityTemplateRewardJob": { + "type": "object", + "properties": { + "reward_job_id": { + "type": "string" + }, + "template_id": { + "type": "string" + }, + "template_code": { + "type": "string" + }, + "version_no": { + "type": "integer", + "format": "int64" + }, + "board_type": { + "type": "string", + "enum": [ + "daily", + "total" + ] + }, + "period_key": { + "type": "string" + }, + "rank_no": { + "type": "integer" + }, + "user_id": { + "type": "integer", + "format": "int64" + }, + "score": { + "type": "integer", + "format": "int64" + }, + "resource_group_id": { + "type": "integer", + "format": "int64" + }, + "reward_snapshot_id": { + "type": "string" + }, + "reward_snapshot_hash": { + "type": "string" + }, + "reward_name": { + "type": "string" + }, + "reward_items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActivityTemplateRewardItem" + } + }, + "wallet_command_id": { + "type": "string" + }, + "wallet_grant_id": { + "type": "string" + }, + "status": { + "type": "string" + }, + "failure_reason": { + "type": "string" + }, + "attempt_count": { + "type": "integer" + }, + "max_attempts": { + "type": "integer" + }, + "next_retry_at_ms": { + "type": "integer", + "format": "int64" + }, + "created_at_ms": { + "type": "integer", + "format": "int64" + }, + "updated_at_ms": { + "type": "integer", + "format": "int64" + }, + "granted_at_ms": { + "type": "integer", + "format": "int64" + }, + "user": { + "$ref": "#/components/schemas/ActivityTemplateUser" + } + }, + "required": [ + "reward_job_id", + "template_id", + "template_code", + "version_no", + "board_type", + "period_key", + "rank_no", + "user_id", + "score", + "resource_group_id", + "reward_snapshot_id", + "reward_snapshot_hash", + "reward_name", + "reward_items", + "wallet_command_id", + "wallet_grant_id", + "status", + "failure_reason", + "attempt_count", + "max_attempts", + "next_retry_at_ms", + "created_at_ms", + "updated_at_ms", + "granted_at_ms" + ] + }, + "ActivityTemplateRewardJobPage": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActivityTemplateRewardJob" + } + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total": { + "type": "integer", + "format": "int64" + } + }, + "required": [ + "items", + "page", + "page_size", + "total" + ] + }, + "ActivityTemplateTaskClaim": { + "type": "object", + "properties": { + "claim_id": { + "type": "string" + }, + "template_id": { + "type": "string" + }, + "template_code": { + "type": "string" + }, + "version_no": { + "type": "integer", + "format": "int64" + }, + "task_day": { + "type": "string", + "format": "date" + }, + "task_key": { + "type": "string" + }, + "user_id": { + "type": "integer", + "format": "int64" + }, + "earning_region_id": { + "type": "integer", + "format": "int64" + }, + "attribution_at_ms": { + "type": "integer", + "format": "int64" + }, + "reward_resource_group_id": { + "type": "integer", + "format": "int64" + }, + "reward_snapshot_id": { + "type": "string" + }, + "reward_snapshot_hash": { + "type": "string" + }, + "reward_name": { + "type": "string" + }, + "reward_items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActivityTemplateRewardItem" + } + }, + "wallet_grant_id": { + "type": "string" + }, + "status": { + "type": "string" + }, + "failure_reason": { + "type": "string" + }, + "attempt_count": { + "type": "integer" + }, + "created_at_ms": { + "type": "integer", + "format": "int64" + }, + "updated_at_ms": { + "type": "integer", + "format": "int64" + }, + "granted_at_ms": { + "type": "integer", + "format": "int64" + }, + "user": { + "$ref": "#/components/schemas/ActivityTemplateUser" + } + }, + "required": [ + "claim_id", + "template_id", + "template_code", + "version_no", + "task_day", + "task_key", + "user_id", + "earning_region_id", + "attribution_at_ms", + "reward_resource_group_id", + "reward_snapshot_id", + "reward_snapshot_hash", + "reward_name", + "reward_items", + "wallet_grant_id", + "status", + "failure_reason", + "attempt_count", + "created_at_ms", + "updated_at_ms", + "granted_at_ms" + ] + }, + "ActivityTemplateTaskClaimPage": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActivityTemplateTaskClaim" + } + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total": { + "type": "integer", + "format": "int64" + } + }, + "required": [ + "items", + "page", + "page_size", + "total" + ] + }, + "ApiResponseActivityTemplateData": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/ActivityTemplateData" + } + } + } + ] + }, + "ApiResponseActivityTemplateLeaderboardPage": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/ActivityTemplateLeaderboardPage" + } + } + } + ] + }, + "ApiResponseActivityTemplateRewardJobPage": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/ActivityTemplateRewardJobPage" + } + } + } + ] + }, + "ApiResponseActivityTemplateRewardJob": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/ActivityTemplateRewardJob" + } + } + } + ] + }, + "ApiResponseActivityTemplateTaskClaimPage": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/ActivityTemplateTaskClaimPage" + } + } + } + ] + }, + "ApiResponseActivityTemplateTaskClaim": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/ActivityTemplateTaskClaim" + } + } + } + ] + }, + "ActivityTemplateUser": { + "type": "object", + "properties": { + "display_user_id": { + "type": "string" + }, + "username": { + "type": "string" + }, + "avatar": { + "type": "string" + } + }, + "required": [ + "display_user_id", + "username", + "avatar" + ] } } } diff --git a/generated/admin-modules/activity-templates/permissions.md b/generated/admin-modules/activity-templates/permissions.md new file mode 100644 index 0000000..accad41 --- /dev/null +++ b/generated/admin-modules/activity-templates/permissions.md @@ -0,0 +1,35 @@ +# 活动模版 + +## 权限 + +| Code | Kind | Name | +| --------------------------- | ------ | ---------------- | +| `activity-template:view` | menu | 活动模版查看 | +| `activity-template:create` | button | 活动模版创建 | +| `activity-template:update` | button | 活动模版更新 | +| `activity-template:publish` | button | 活动模版发布 | +| `activity-template:delete` | button | 活动模版归档 | +| `activity-template:data` | button | 活动模版数据查看 | +| `activity-template:export` | button | 活动模版数据导出 | +| `activity-template:retry` | button | 活动模版奖励补发 | + +## API 路由 + +| Method | Path | Permission | Handler | +| ------ | ---------------------------------------------------- | --------------------------- | ---------------------------- | +| GET | `/admin/activities/templates` | `activity-template:view` | ListActivityTemplates | +| POST | `/admin/activities/templates` | `activity-template:create` | CreateActivityTemplate | +| POST | `/admin/activities/templates/validate` | `activity-template:create/update/publish` | ValidateActivityTemplate | +| GET | `/admin/activities/templates/{template_id}` | `activity-template:view` | GetActivityTemplate | +| PUT | `/admin/activities/templates/{template_id}` | `activity-template:update` | UpdateActivityTemplate | +| PATCH | `/admin/activities/templates/{template_id}/status` | `activity-template:publish` | SetActivityTemplateStatus | +| DELETE | `/admin/activities/templates/{template_id}` | `activity-template:delete` | DeleteActivityTemplate | +| POST | `/admin/activities/templates/{template_id}/clone` | `activity-template:create` | CloneActivityTemplate | +| GET | `/admin/activities/templates/{template_id}/versions` | `activity-template:view` | ListActivityTemplateVersions | +| GET | `/admin/activities/templates/{template_id}/data` | `activity-template:data` | GetActivityTemplateData | +| GET | `/admin/activities/templates/{template_id}/data/export` | `activity-template:export` | ExportActivityTemplateData | +| GET | `/admin/activities/templates/{template_id}/leaderboard` | `activity-template:data` | ListActivityTemplateLeaderboard | +| GET | `/admin/activities/templates/{template_id}/rewards` | `activity-template:data` | ListActivityTemplateRewardJobs | +| POST | `/admin/activities/templates/{template_id}/rewards/{reward_job_id}/retry` | `activity-template:retry` | RetryActivityTemplateRewardJob | +| GET | `/admin/activities/templates/{template_id}/task-claims` | `activity-template:data` | ListActivityTemplateTaskClaims | +| POST | `/admin/activities/templates/{template_id}/task-claims/{claim_id}/retry` | `activity-template:retry` | RetryActivityTemplateTaskClaim | diff --git a/src/app/navigation/menu.js b/src/app/navigation/menu.js index c4429b4..66267e9 100644 --- a/src/app/navigation/menu.js +++ b/src/app/navigation/menu.js @@ -40,6 +40,7 @@ import { getRouteByMenuCode } from "@/app/router/routeConfig"; const deprecatedMenuCodes = new Set(["services"]); const menuLabelOverrides = { + activities: "运营活动", system: "后台设置", "system-users": "后台用户", "system-teams": "团队配置", @@ -212,8 +213,9 @@ export const fallbackNavigation = [ code: "activities", icon: CampaignOutlined, id: "activities", - label: "活动管理", + label: "运营活动", children: [ + routeNavItem("activity-template", { icon: CampaignOutlined }), routeNavItem("daily-task-list", { icon: TaskAltOutlined }), routeNavItem("registration-reward", { icon: CardGiftcardOutlined }), routeNavItem("first-recharge-reward", { icon: CardGiftcardOutlined }), diff --git a/src/app/navigation/menu.test.jsx b/src/app/navigation/menu.test.jsx index 99165eb..33f9e5e 100644 --- a/src/app/navigation/menu.test.jsx +++ b/src/app/navigation/menu.test.jsx @@ -19,6 +19,18 @@ describe("navigation menu helpers", () => { ]); }); + test("exposes activity templates below the operations activity menu", () => { + const activities = fallbackNavigation.find((item) => item.code === "activities"); + const template = activities.children.find((item) => item.code === "activity-template"); + + expect(activities.label).toBe("运营活动"); + expect(template).toMatchObject({ + label: "活动模版", + path: "/activities/templates", + permissionCode: "activity-template:view", + }); + }); + test("normalizes requested parent menu order from backend menus", () => { const mapped = mapBackendMenus([ { children: [], code: "system", icon: "settings", id: "system", label: "后台设置" }, diff --git a/src/app/permissions.ts b/src/app/permissions.ts index f77cdc4..a8904fe 100644 --- a/src/app/permissions.ts +++ b/src/app/permissions.ts @@ -159,6 +159,14 @@ export const PERMISSIONS = { giftDelete: "gift:delete", emojiPackView: "emoji-pack:view", emojiPackCreate: "emoji-pack:create", + activityTemplateView: "activity-template:view", + activityTemplateCreate: "activity-template:create", + activityTemplateUpdate: "activity-template:update", + activityTemplatePublish: "activity-template:publish", + activityTemplateDelete: "activity-template:delete", + activityTemplateData: "activity-template:data", + activityTemplateExport: "activity-template:export", + activityTemplateRetry: "activity-template:retry", dailyTaskView: "daily-task:view", dailyTaskCreate: "daily-task:create", dailyTaskUpdate: "daily-task:update", @@ -260,6 +268,7 @@ export const MENU_CODES = { luckyGift: "lucky-gift", wheel: "wheel", activities: "activities", + activityTemplate: "activity-template", dailyTaskList: "daily-task-list", registrationReward: "registration-reward", firstRechargeReward: "first-recharge-reward", diff --git a/src/app/router/routeConfig.ts b/src/app/router/routeConfig.ts index 8ec6582..42272d8 100644 --- a/src/app/router/routeConfig.ts +++ b/src/app/router/routeConfig.ts @@ -1,6 +1,7 @@ import { authRoutes } from "@/features/auth/routes.js"; import { agencyOpeningRoutes } from "@/features/agency-opening/routes.js"; import { achievementRoutes } from "@/features/achievements/routes.js"; +import { activityTemplatesRoutes } from "@/features/activity-templates/routes.js"; import { appConfigRoutes } from "@/features/app-config/routes.js"; import { appUserRoutes } from "@/features/app-users/routes.js"; import { cumulativeRechargeRewardRoutes } from "@/features/cumulative-recharge-reward/routes.js"; @@ -48,6 +49,7 @@ export const adminRoutes: AdminRoute[] = [ ...regionBlockRoutes, ...roomRoutes, ...appConfigRoutes, + ...activityTemplatesRoutes, ...dailyTaskRoutes, ...achievementRoutes, ...registrationRewardRoutes, diff --git a/src/features/activity-templates/activity-templates.module.css b/src/features/activity-templates/activity-templates.module.css new file mode 100644 index 0000000..bdbe995 --- /dev/null +++ b/src/features/activity-templates/activity-templates.module.css @@ -0,0 +1,1302 @@ +.root { + min-height: 100%; +} + +.cellStack { + display: grid; + min-width: 0; + gap: 2px; +} + +.cellStack > strong, +.cellStack > span, +.cellStack > small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cellStack > strong { + color: var(--text-primary); + font-weight: 700; +} + +.cellStack > small { + color: var(--text-tertiary); + font-size: 12px; +} + +.templateIdentity { + display: grid; + min-width: 0; + grid-template-columns: 34px minmax(0, 1fr); + align-items: center; + gap: var(--space-3); +} + +.templateTypeMark { + display: grid; + width: 34px; + height: 34px; + place-items: center; + border: 1px solid var(--primary-border); + border-radius: var(--radius-control); + background: var(--primary-surface); + color: var(--primary); + font-size: 13px; + font-weight: 800; +} + +.statusBadge { + display: inline-flex; + width: max-content; + min-height: var(--status-height); + align-items: center; + gap: 6px; + padding: 0 var(--space-2); + border: 1px solid var(--neutral-border); + border-radius: var(--radius-pill); + background: var(--neutral-surface); + color: var(--text-secondary); + font-size: 12px; + font-weight: 700; +} + +.statusDot { + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; + box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 14%, transparent); +} + +.status_ongoing, +.status_published { + border-color: var(--success-border); + background: var(--success-surface); + color: var(--success); +} + +.status_scheduled { + border-color: var(--info-border); + background: var(--info-surface); + color: var(--info); +} + +.status_draft { + border-color: var(--warning-border); + background: var(--warning-surface); + color: var(--warning); +} + +.status_disabled, +.status_ended, +.status_archived { + color: var(--stopped); +} + +.overlayDrawerSkeleton { + min-height: 0; + height: 100%; + overflow: hidden; +} + +.overlayDrawerSkeleton :global(.page-skeleton) { + height: 100%; +} + +.overlayDialogSkeleton { + display: grid; + gap: var(--space-3); + padding-top: var(--space-2) !important; +} + +.dataDrawer { + width: min(1320px, calc(100vw - 24px)) !important; + padding: var(--space-4) var(--space-5) !important; +} + +.dataDrawerBody { + grid-template-rows: auto auto auto minmax(0, 1fr); + gap: var(--space-3) !important; + overflow: hidden !important; +} + +.dataHeader, +.dataFilterBar, +.inlineFilters { + display: flex; + min-width: 0; + align-items: center; + gap: var(--space-2); +} + +.dataHeader { + min-height: var(--control-height); + justify-content: space-between; +} + +.dataIdentity { + display: flex; + min-width: 0; + align-items: baseline; + gap: var(--space-2); +} + +.dataIdentity strong { + overflow: hidden; + color: var(--text-primary); + text-overflow: ellipsis; + white-space: nowrap; +} + +.dataIdentity span, +.dataSectionHeader span, +.periodSummary small { + color: var(--text-tertiary); + font-size: 12px; +} + +.dataHeaderActions { + display: flex; + flex: 0 0 auto; + gap: var(--space-2); +} + +.dataFilterBar { + min-height: calc(var(--control-height) + var(--space-2)); + flex-wrap: wrap; + padding: var(--space-2) 0; + border-block: 1px solid var(--border-soft); +} + +.dataFilterBar > :global(.MuiTextField-root), +.inlineFilters > :global(.MuiTextField-root), +.dataFilterBar > :global(.region-filter-control) { + width: 176px; +} + +.dataFilterBar > :global(.time-range-filter), +.dataFilterBar > button[class*="trigger"] { + flex: 0 1 360px; +} + +.inlineErrorButton { + border: 0; + background: transparent; + color: var(--danger); + cursor: pointer; + font: inherit; + font-size: 12px; + text-decoration: underline; + text-underline-offset: 2px; +} + +.dataTabs { + min-height: var(--control-height) !important; + border-bottom: 1px solid var(--border); +} + +.dataTabs :global(.MuiTab-root) { + min-height: var(--control-height); + padding-block: 0; + font-size: 13px; +} + +.dataTabContent { + min-width: 0; + min-height: 0; + overflow: auto; + scrollbar-width: thin; +} + +.dataTabContent > :global(.data-state), +.dataPanel > :global(.data-state) { + min-height: 100%; +} + +.dataOverview, +.dataPanel { + display: grid; + min-width: 0; + align-content: start; + gap: var(--space-3); + animation: data-panel-enter var(--motion-base) var(--ease-emphasized) both; +} + +.metricGrid { + display: grid; + grid-template-columns: repeat(9, minmax(98px, 1fr)); + gap: 1px; + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--border); +} + +.metricItem { + position: relative; + display: grid; + min-height: 78px; + align-content: center; + gap: 1px; + padding: var(--space-2) var(--space-3); + background: var(--bg-card); + transition: + background var(--motion-fast) var(--ease-standard), + transform var(--motion-fast) var(--ease-standard); +} + +.metricItem:hover { + z-index: 1; + background: var(--primary-hover); + transform: translateY(-1px); +} + +.metricItem span { + color: var(--text-tertiary); + font-size: 11px; +} + +.metricItem strong { + color: var(--text-primary); + font-size: 20px; + font-variant-numeric: tabular-nums; + letter-spacing: -0.02em; +} + +.metricItem small { + position: absolute; + right: var(--space-2); + bottom: var(--space-2); + color: var(--text-tertiary); + font-size: 10px; +} + +.dataSection { + display: grid; + min-width: 0; + align-content: start; + gap: var(--space-2); + padding-top: var(--space-2); + border-top: 1px solid var(--border-soft); +} + +.dataSectionHeader { + display: flex; + min-height: 32px; + align-items: center; + justify-content: space-between; + gap: var(--space-3); +} + +.dataSectionHeader > div { + display: grid; + gap: 1px; +} + +.dataSectionHeader h3 { + margin: 0; + color: var(--text-primary); + font-size: 13px; + font-weight: 760; +} + +.dataMetaBadge, +.dataStatus { + display: inline-flex; + width: max-content; + min-height: 22px; + align-items: center; + padding: 0 var(--space-2); + border: 1px solid var(--neutral-border); + border-radius: var(--radius-pill); + background: var(--neutral-surface); + color: var(--text-secondary); + font-size: 11px; + font-weight: 700; +} + +.dataRankingGrid { + display: grid; + min-width: 0; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-3); +} + +.numericCell { + color: var(--text-primary); + font-variant-numeric: tabular-nums; +} + +.dataUserIdentity { + display: grid; + min-width: 0; + grid-template-columns: 30px minmax(0, 1fr); + align-items: center; + gap: var(--space-2); +} + +.dataUserIdentity :global(.MuiAvatar-root) { + width: 30px; + height: 30px; + border: 1px solid var(--border-soft); + background: var(--primary-surface); + color: var(--primary); + font-size: 12px; + font-weight: 750; +} + +.inlineFilters { + min-height: calc(var(--control-height) + var(--space-2)); + flex-wrap: wrap; + padding-bottom: var(--space-2); +} + +.periodSummary { + display: flex; + min-height: var(--control-height); + align-items: center; + gap: var(--space-2); + margin-left: auto; +} + +.dataStatus_open, +.dataStatus_pending { + border-color: var(--info-border); + background: var(--info-surface); + color: var(--info); +} + +.dataStatus_settling, +.dataStatus_running { + border-color: var(--warning-border); + background: var(--warning-surface); + color: var(--warning); +} + +.dataStatus_settled, +.dataStatus_granted { + border-color: var(--success-border); + background: var(--success-surface); + color: var(--success); +} + +.dataStatus_failed, +.dataStatus_dead { + border-color: var(--danger-border); + background: var(--danger-surface); + color: var(--danger); +} + +.dataStatus_cancelled { + color: var(--stopped); +} + +.ellipsisCell { + display: block; + overflow: hidden; + color: var(--text-secondary); + text-overflow: ellipsis; + white-space: nowrap; +} + +.dataDrawer :global(.multi-series-line-chart) { + min-height: 260px; +} + +.dataDrawer :global(.multi-series-line-chart__canvas) { + width: 100%; + height: 260px; +} + +.dataDrawer :global(.multi-series-line-chart__empty) { + display: grid; + min-height: 180px; + place-items: center; + color: var(--text-tertiary); +} + +@keyframes data-panel-enter { + from { + opacity: 0; + transform: translateY(5px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (max-width: 1120px) { + .metricGrid { + grid-template-columns: repeat(5, minmax(110px, 1fr)); + } + + .dataRankingGrid { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (max-width: 760px) { + .dataDrawer { + width: 100vw !important; + padding: var(--space-3) !important; + } + + .dataHeader { + align-items: flex-start; + } + + .dataIdentity { + display: grid; + } + + .dataHeaderActions button { + min-width: var(--control-height); + } + + .metricGrid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .periodSummary { + width: 100%; + margin-left: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .dataOverview, + .dataPanel, + .metricItem { + animation: none !important; + transition: none !important; + } + + .metricItem:hover { + transform: none; + } +} + +.editorDrawer { + width: min(1560px, calc(100vw - 24px)) !important; + padding: var(--space-4) var(--space-5) !important; +} + +.editorHeader { + min-height: 48px; +} + +.editorTitleLine { + display: flex; + min-width: 0; + align-items: center; + gap: var(--space-3); +} + +.editorTitleLine h2 { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dirtyBadge { + display: inline-flex; + min-height: var(--status-height); + align-items: center; + padding: 0 var(--space-2); + border: 1px solid var(--warning-border); + border-radius: var(--radius-pill); + background: var(--warning-surface); + color: var(--warning); + font-size: 12px; + font-weight: 700; +} + +.editorTabs { + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; +} + +.editorTabs button { + flex: 0 0 auto; +} + +.editorWorkspace { + display: grid; + min-height: 0; + grid-template-columns: minmax(620px, 1fr) 350px; + gap: var(--space-4); + overflow: hidden !important; +} + +.editorLoading { + min-height: 100%; + grid-column: 1 / -1; + overflow: hidden; +} + +.editorMain { + display: grid; + min-width: 0; + min-height: 0; + align-content: start; + gap: var(--space-3); + overflow: auto; + padding: 1px var(--space-2) var(--space-3) 1px; +} + +.editorActions { + min-height: 48px; + align-items: flex-end; +} + +.validationList { + display: grid; + gap: 3px; +} + +.editorSection { + display: grid; + min-width: 0; + gap: var(--space-3); + animation: section-enter var(--motion-base) var(--ease-emphasized) both; +} + +.editorSectionHeader { + display: flex; + min-height: var(--control-height); + align-items: center; + justify-content: space-between; + gap: var(--space-3); +} + +.editorSectionHeader h3 { + margin: 0; + color: var(--text-primary); + font-size: var(--admin-font-size); + font-weight: 750; +} + +.editorSectionBody { + display: grid; + min-width: 0; + gap: var(--space-4); +} + +.formGridThree { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-3); +} + +.localizedGrid, +.rulesGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-3); +} + +.settingRow { + display: flex; + min-height: 56px; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + padding: var(--space-3); + border: 1px solid var(--border-soft); + border-radius: var(--radius-control); + background: var(--bg-card); + transition: + border-color var(--motion-fast) var(--ease-standard), + box-shadow var(--motion-base) var(--ease-standard); +} + +.settingRow:focus-within, +.settingRow:hover { + border-color: var(--primary-border); + box-shadow: 0 0 0 3px var(--focus-ring); +} + +.settingCopy { + display: grid; + min-width: 0; + gap: 2px; +} + +.settingCopy strong { + color: var(--text-primary); + font-size: 13px; +} + +.settingCopy span, +.editorCountLine { + color: var(--text-tertiary); + font-size: 12px; +} + +.timeRangeField { + flex: 0 0 min(430px, 58%); +} + +.sortableList { + display: grid; + gap: var(--space-2); +} + +.giftEditorRow { + display: grid; + min-width: 0; + grid-template-columns: 28px minmax(300px, 1fr) auto; + align-items: center; + gap: var(--space-2); + padding: var(--space-2); + border: 1px solid var(--border-soft); + border-radius: var(--radius-control); + background: var(--bg-card); + transition: + border-color var(--motion-fast) var(--ease-standard), + box-shadow var(--motion-base) var(--ease-standard), + transform var(--motion-fast) var(--ease-standard); +} + +.giftEditorRow:hover { + border-color: var(--primary-border); + box-shadow: var(--shadow-soft); + transform: translateY(-1px); +} + +.rowOrder { + display: grid; + width: 26px; + height: 26px; + place-items: center; + border-radius: var(--radius-pill); + background: var(--neutral-surface); + color: var(--text-secondary); + font-size: 12px; + font-weight: 750; +} + +.giftPicker { + min-width: 0; +} + +.rowMoveActions { + display: flex; + align-items: center; + gap: 2px; +} + +.taskTable, +.rankRewardTable { + display: grid; + min-width: 0; + gap: 1px; + overflow-x: auto; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--border); +} + +.taskTableHead, +.taskTableRow { + display: grid; + min-width: 930px; + grid-template-columns: 58px minmax(150px, 0.72fr) minmax(180px, 0.8fr) 110px minmax(240px, 1fr) 132px; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + background: var(--bg-card); +} + +.taskTableHead, +.rankRewardHead { + min-height: var(--table-head-height); + background: var(--bg-card-strong); + color: var(--text-secondary); + font-size: 12px; + font-weight: 750; +} + +.taskTableRow { + min-height: 60px; + transition: background var(--motion-fast) var(--ease-standard); +} + +.taskTableRow:hover, +.rankRewardRow:hover { + background: var(--primary-hover); +} + +.rankBoard { + display: grid; + gap: var(--space-3); + padding: var(--space-3); + border: 1px solid var(--border-soft); + border-radius: var(--radius-card); + background: var(--bg-card); +} + +.rankBoard + .rankBoard { + margin-top: var(--space-1); +} + +.rankBoardHeader, +.rankBoardActions { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); +} + +.rankBoardHeader > div:first-child { + display: grid; + gap: 2px; +} + +.rankBoardHeader strong { + color: var(--text-primary); +} + +.rankBoardHeader span { + color: var(--text-tertiary); + font-size: 12px; +} + +.leaderboardSizeField { + width: 130px; +} + +.rankRewardHead, +.rankRewardRow { + display: grid; + min-width: 680px; + grid-template-columns: 120px 120px minmax(300px, 1fr) 64px; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + background: var(--bg-card); +} + +.rankRewardRow { + min-height: 58px; + transition: background var(--motion-fast) var(--ease-standard); +} + +.editorEmpty { + min-height: 96px; + display: grid; + place-items: center; + background: var(--bg-card); + color: var(--text-tertiary); +} + +.assetGrid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-3); +} + +.assetSlot { + display: grid; + min-width: 0; + gap: var(--space-2); + padding: var(--space-2); + border: 1px solid var(--border-soft); + border-radius: var(--radius-control); + background: var(--bg-card); +} + +.assetMeta { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + color: var(--text-tertiary); + font-size: 11px; +} + +.assetMeta code { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.previewPanel { + display: grid; + min-width: 0; + min-height: 0; + grid-template-rows: auto minmax(0, 1fr); + gap: var(--space-2); + padding-left: var(--space-4); + border-left: 1px solid var(--border); + overflow: hidden; +} + +.previewToolbar { + display: flex; + min-height: var(--control-height); + align-items: center; + justify-content: space-between; + gap: var(--space-2); + color: var(--text-secondary); + font-size: 12px; + font-weight: 750; +} + +.previewLocale { + display: inline-flex; + padding: 2px; + border: 1px solid var(--border); + border-radius: var(--radius-pill); + background: var(--bg-card-strong); +} + +.previewLocale button { + min-width: 34px; + height: 24px; + padding: 0 8px; + border: 0; + border-radius: var(--radius-pill); + background: transparent; + color: var(--text-tertiary); + cursor: pointer; + font-size: 11px; + font-weight: 750; + transition: + background var(--motion-fast) var(--ease-standard), + color var(--motion-fast) var(--ease-standard); +} + +.previewLocale .previewLocaleActive { + background: var(--primary); + color: var(--active-contrast); +} + +.phoneFrame { + min-height: 0; + overflow: auto; + scrollbar-width: thin; +} + +.phoneScreen { + width: 310px; + min-height: 620px; + margin: 0 auto var(--space-3); + overflow: hidden; + border: 7px solid var(--text-primary); + border-radius: 30px; + background-color: var(--text-primary); + background-position: center; + background-size: cover; + color: var(--active-contrast); + box-shadow: var(--shadow-dialog); + animation: phone-enter var(--motion-slow) var(--ease-emphasized) both; +} + +.phoneHero { + position: relative; + display: grid; + min-height: 112px; + place-items: center; + padding: var(--space-4); + background: linear-gradient(135deg, var(--primary-strong), var(--brand-secondary)); + background-position: center; + background-size: cover; + text-align: center; +} + +.phoneHero strong { + max-width: 220px; + overflow: hidden; + font-size: 17px; + text-overflow: ellipsis; + white-space: nowrap; + text-shadow: 0 2px 8px rgba(15, 23, 42, 0.4); +} + +.phoneBack { + position: absolute; + top: 12px; + left: 12px; + font-size: 26px; +} + +.phoneScreen[dir="rtl"] .phoneBack { + right: 12px; + left: auto; + transform: scaleX(-1); +} + +.phoneCountdown { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 5px; + padding: 10px 16px; +} + +.phoneCountdown span { + display: grid; + height: 30px; + place-items: center; + border: 1px solid rgba(255, 255, 255, 0.4); + border-radius: 6px; + background: rgba(255, 255, 255, 0.1); + font-size: 11px; + font-weight: 750; +} + +.phoneGiftPanel, +.phoneTaskList, +.phoneRanking { + margin: 0 10px 10px; + padding: 10px; + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 10px; + background: rgba(255, 255, 255, 0.08); + backdrop-filter: blur(8px); +} + +.phoneSectionTitle { + display: block; + margin-bottom: 8px; + font-size: 11px; + font-weight: 750; +} + +.phoneGiftRail { + display: flex; + min-height: 56px; + gap: 8px; + overflow: hidden; +} + +.phoneGift, +.phoneGiftEmpty { + display: grid; + min-width: 48px; + place-items: center; + gap: 2px; +} + +.phoneGift img { + width: 38px; + height: 38px; + object-fit: contain; +} + +.phoneGift small { + color: rgba(255, 255, 255, 0.74); + font-size: 9px; +} + +.phoneGiftEmpty { + min-width: 56px; + height: 52px; + border: 1px dashed rgba(255, 255, 255, 0.28); + border-radius: 8px; + color: rgba(255, 255, 255, 0.5); +} + +.phoneTabs { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 6px; + padding: 0 10px 10px; +} + +.phoneTabs button { + display: inline-flex; + height: 30px; + align-items: center; + justify-content: center; + gap: 5px; + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: var(--radius-pill); + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.72); + cursor: pointer; + font-size: 10px; + transition: + background var(--motion-fast) var(--ease-standard), + transform var(--motion-fast) var(--ease-standard); +} + +.phoneTabs button:hover { + transform: translateY(-1px); +} + +.phoneTabs .phoneTabActive { + border-color: var(--primary-border-active); + background: var(--primary); + color: var(--active-contrast); +} + +.phoneTaskList { + display: grid; + gap: 6px; +} + +.phoneTask { + display: flex; + min-height: 44px; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 8px; + border-radius: 7px; + background: rgba(255, 255, 255, 0.07); +} + +.phoneTask > span { + display: grid; + min-width: 0; + gap: 2px; +} + +.phoneTask strong { + overflow: hidden; + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.phoneTask small { + color: rgba(255, 255, 255, 0.6); + font-size: 8px; +} + +.phoneTask button { + height: 24px; + padding: 0 8px; + border: 0; + border-radius: var(--radius-pill); + background: var(--warning); + color: var(--active-contrast); + font-size: 9px; +} + +.phoneEmpty { + display: grid; + min-height: 72px; + place-items: center; + color: rgba(255, 255, 255, 0.5); + font-size: 10px; +} + +.phoneRanking { + display: grid; + gap: 5px; +} + +.phonePodium { + display: flex; + min-height: 116px; + align-items: flex-end; + justify-content: center; + gap: 14px; + padding-bottom: 8px; +} + +.phonePodium > span { + display: grid; + gap: 4px; + place-items: center; +} + +.phonePodium i { + display: grid; + width: 44px; + height: 44px; + place-items: center; + border: 2px solid var(--warning); + border-radius: 50%; + background: rgba(255, 255, 255, 0.12); + font-style: normal; + font-weight: 800; +} + +.phonePodium .phonePodiumFirst { + align-self: flex-start; +} + +.phonePodiumFirst i { + width: 56px; + height: 56px; +} + +.phonePodium small { + font-size: 9px; +} + +.phoneRankRow { + display: grid; + min-height: 34px; + grid-template-columns: 20px 26px minmax(0, 1fr) auto; + align-items: center; + gap: 7px; + padding: 4px 7px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.06); + font-size: 9px; +} + +.phoneRankRow i { + width: 24px; + height: 24px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.18); +} + +.phoneRankRow strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.phoneRankRow small { + color: var(--warning); +} + +.cloneDialogContent { + display: grid; + gap: var(--space-3); + padding-top: var(--space-2) !important; +} + +.cloneSource { + display: grid; + gap: 2px; + padding: var(--space-3); + border: 1px solid var(--border-soft); + border-radius: var(--radius-control); + background: var(--bg-card-strong); +} + +.cloneSource span { + color: var(--text-tertiary); + font-size: 12px; +} + +.versionDrawer { + width: min(980px, calc(100vw - 32px)) !important; +} + +.versionDrawerBody { + display: grid; + min-height: 0; + grid-template-rows: minmax(0, 1fr) auto; + overflow: auto; +} + +.drawerPagination { + display: flex; + justify-content: flex-end; + padding: var(--space-3) 0 0; +} + +@keyframes section-enter { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes phone-enter { + from { + opacity: 0; + transform: translateX(8px) scale(0.985); + } + to { + opacity: 1; + transform: translateX(0) scale(1); + } +} + +@media (max-width: 1180px) { + .editorWorkspace { + grid-template-columns: minmax(560px, 1fr) 310px; + } + + .assetGrid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 900px) { + .editorDrawer { + width: 100vw !important; + padding: var(--space-3) !important; + } + + .editorWorkspace { + display: block; + overflow: auto !important; + } + + .editorMain { + overflow: visible; + } + + .previewPanel { + min-height: 680px; + margin-top: var(--space-4); + padding: var(--space-4) 0 0; + border-top: 1px solid var(--border); + border-left: 0; + } + + .formGridThree, + .localizedGrid, + .rulesGrid { + grid-template-columns: 1fr; + } + + .settingRow { + align-items: flex-start; + flex-direction: column; + } + + .timeRangeField { + width: 100%; + flex-basis: auto; + } +} + +@media (max-width: 620px) { + .assetGrid { + grid-template-columns: 1fr; + } + + .giftEditorRow { + grid-template-columns: 28px minmax(0, 1fr); + } + + .giftEditorRow .rowMoveActions { + grid-column: 2; + } +} + +@media (prefers-reduced-motion: reduce) { + .editorSection, + .phoneScreen { + animation: none; + } + + .giftEditorRow, + .phoneTabs button, + .previewLocale button, + .rankRewardRow, + .settingRow, + .taskTableRow { + transition: none; + } +} diff --git a/src/features/activity-templates/api.ts b/src/features/activity-templates/api.ts new file mode 100644 index 0000000..58cf09e --- /dev/null +++ b/src/features/activity-templates/api.ts @@ -0,0 +1,861 @@ +import { apiRequest } from "@/shared/api/request"; +import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints"; +import type { ApiPage, EntityId, PageQuery } from "@/shared/api/types"; + +export type ActivityTemplateStatus = "archived" | "disabled" | "draft" | "published"; +export type ActivityTemplateLifecycle = "archived" | "disabled" | "draft" | "ended" | "ongoing" | "scheduled"; +export type ActivityTaskType = "daily_visit" | "gift_coin_amount" | "gift_count"; +export type ActivityBoardType = "daily" | "total"; + +export interface ActivityTemplateRewardItemDto { + durationMs: number; + iconUrl: string; + itemType: string; + name: string; + quantity: number; + resourceId: number; + resourceType: string; + sortOrder: number; + walletAssetAmount: number; + walletAssetType: string; +} + +export interface ActivityTemplateLocaleDto { + locale: string; + rules: string; + title: string; +} + +export interface ActivityTemplateGiftDto { + coinPrice: number; + giftId: string; + iconUrl: string; + name: string; + priceVersion: string; + sortOrder: number; +} + +export interface ActivityTemplateTaskDto { + rewardItems?: ActivityTemplateRewardItemDto[]; + rewardName?: string; + rewardResourceGroupId: number; + rewardSnapshotHash?: string; + rewardSnapshotId?: string; + sortOrder: number; + targetValue: number; + taskKey: string; + taskType: ActivityTaskType; +} + +export interface ActivityTemplateRankRewardDto { + boardType: ActivityBoardType; + rankFrom: number; + rankTo: number; + resourceGroupId: number; + rewardItems?: ActivityTemplateRewardItemDto[]; + rewardName?: string; + rewardSnapshotHash?: string; + rewardSnapshotId?: string; + sortOrder: number; +} + +export interface ActivityTemplateAssetDto { + assetKey: string; + locale: string; + mediaType: "image" | "pag" | "svga" | "webp" | string; + sortOrder: number; + url: string; +} + +export interface ActivityTemplateSummaryDto { + allRegions: boolean; + appCode?: string; + activityType: string; + createdAtMs?: number; + createdByAdminId?: number; + endMs: number; + lifecycleStatus: ActivityTemplateLifecycle | string; + name: string; + publishedVersion: number; + regionIds: number[]; + revision: number; + startMs: number; + status: ActivityTemplateStatus | string; + templateCode: string; + templateId: string; + updatedAtMs?: number; + updatedByAdminId?: number; +} + +export interface ActivityTemplateDto extends ActivityTemplateSummaryDto { + archivedAtMs?: number; + assets: ActivityTemplateAssetDto[]; + dailyLeaderboardSize: number; + displayConfigJson: string; + gifts: ActivityTemplateGiftDto[]; + locales: ActivityTemplateLocaleDto[]; + publishedAtMs?: number; + publishedByAdminId?: number; + rankRewards: ActivityTemplateRankRewardDto[]; + tasks: ActivityTemplateTaskDto[]; + totalLeaderboardSize: number; +} + +export interface ActivityTemplateVersionDto { + publishedAtMs: number; + publishedByAdminId: number; + runtimeFromMs: number; + runtimeToMs: number; + snapshot: ActivityTemplateDto; + templateId: string; + versionNo: number; +} + +export interface ActivityTemplateValidationIssueDto { + code: string; + field: string; + message: string; +} + +export interface ActivityTemplateValidationResultDto { + issues: ActivityTemplateValidationIssueDto[]; + valid: boolean; +} + +export interface ActivityTemplateDataOverviewDto { + templateId: string; + templateCode: string; + publishedVersion: number; + participants: number; + visitCount: number; + giftUsers: number; + taskUsers: number; + giftEventCount: number; + giftCount: number; + coinAmount: number; + taskProgress: number; + taskCompleted: number; + taskClaimed: number; +} + +export interface ActivityTemplateDataTrendDto { + statDay: string; + participants: number; + visitCount: number; + giftUsers: number; + taskUsers: number; + giftEventCount: number; + giftCount: number; + coinAmount: number; + taskProgress: number; + taskCompleted: number; + taskClaimed: number; +} + +export interface ActivityTemplateGiftStatDto { + giftId: string; + giftUsers: number; + giftEventCount: number; + giftCount: number; + coinAmount: number; +} + +export interface ActivityTemplateTaskStatDto { + taskKey: string; + taskType: string; + taskUsers: number; + taskProgress: number; + taskCompleted: number; + taskClaimed: number; +} + +export interface ActivityTemplateDataDto { + overview: ActivityTemplateDataOverviewDto; + trend: ActivityTemplateDataTrendDto[]; + gifts: ActivityTemplateGiftStatDto[]; + tasks: ActivityTemplateTaskStatDto[]; + granularity: string; + timezone: string; + startMs: number; + endMs: number; + regionId: number; + dimensionLimit: number; + giftSort: string; + taskSort: string; + serverTimeMs: number; +} + +export interface ActivityTemplateRankPeriodDto { + templateId: string; + templateCode: string; + versionNo: number; + boardType: ActivityBoardType | string; + periodKey: string; + periodStartMs: number; + periodEndMs: number; + settleAfterMs: number; + status: string; + settledAtMs: number; +} + +export interface ActivityTemplateUserDto { + displayUserId: string; + username: string; + avatar: string; +} + +export interface ActivityTemplateLeaderboardEntryDto { + rankNo: number; + userId: number; + score: number; + giftCount: number; + coinAmount: number; + firstScoredAtMs: number; + snapshot: boolean; + user?: ActivityTemplateUserDto; +} + +export interface ActivityTemplateLeaderboardPageDto extends ApiPage { + period: ActivityTemplateRankPeriodDto; +} + +export interface ActivityTemplateRewardJobDto { + rewardJobId: string; + templateId: string; + templateCode: string; + versionNo: number; + boardType: ActivityBoardType | string; + periodKey: string; + rankNo: number; + userId: number; + score: number; + resourceGroupId: number; + rewardItems: ActivityTemplateRewardItemDto[]; + rewardName: string; + rewardSnapshotHash: string; + rewardSnapshotId: string; + walletCommandId: string; + walletGrantId: string; + status: string; + failureReason: string; + attemptCount: number; + maxAttempts: number; + nextRetryAtMs: number; + createdAtMs: number; + updatedAtMs: number; + grantedAtMs: number; + user?: ActivityTemplateUserDto; +} + +export interface ActivityTemplateTaskClaimDto { + attributionAtMs: number; + claimId: string; + earningRegionId: number; + failureReason: string; + grantedAtMs: number; + rewardItems: ActivityTemplateRewardItemDto[]; + rewardName: string; + rewardResourceGroupId: number; + rewardSnapshotHash: string; + rewardSnapshotId: string; + status: string; + taskDay: string; + taskKey: string; + templateCode: string; + templateId: string; + updatedAtMs: number; + createdAtMs: number; + attemptCount: number; + user?: ActivityTemplateUserDto; + userId: number; + versionNo: number; + walletGrantId: string; +} + +export interface ActivityTemplatePayload { + activity_type: "gift_challenge"; + all_regions: boolean; + assets: Array<{ + asset_key: string; + locale: string; + media_type: string; + sort_order: number; + url: string; + }>; + daily_leaderboard_size: number; + display_config_json: string; + end_ms: number; + gifts: Array<{ + coin_price: number; + gift_id: string; + icon_url: string; + name: string; + price_version: string; + sort_order: number; + }>; + locales: Array<{ locale: string; rules: string; title: string }>; + name: string; + rank_rewards: Array<{ + board_type: ActivityBoardType; + rank_from: number; + rank_to: number; + resource_group_id: number; + sort_order: number; + }>; + region_ids: number[]; + start_ms: number; + tasks: Array<{ + reward_resource_group_id: number; + sort_order: number; + target_value: number; + task_key: string; + task_type: ActivityTaskType; + }>; + template_code: string; + total_leaderboard_size: number; +} + +type RawRecord = Record; + +export function listActivityTemplates(query: PageQuery = {}): Promise> { + const endpoint = API_ENDPOINTS.listActivityTemplates; + return apiRequest(apiEndpointPath(API_OPERATIONS.listActivityTemplates), { + method: endpoint.method, + query, + }).then((result) => ({ + items: arrayValue(result.templates ?? result.items).map(normalizeSummary), + page: numberValue(result.page ?? query.page, 1), + pageSize: numberValue(result.page_size ?? result.pageSize ?? query.page_size ?? query.pageSize, 20), + total: numberValue(result.total), + })); +} + +export function createActivityTemplate(payload: ActivityTemplatePayload): Promise { + const endpoint = API_ENDPOINTS.createActivityTemplate; + return apiRequest(apiEndpointPath(API_OPERATIONS.createActivityTemplate), { + body: payload, + method: endpoint.method, + }).then((result) => normalizeTemplate(recordValue(result.template ?? result))); +} + +export function getActivityTemplate(templateId: EntityId): Promise { + const endpoint = API_ENDPOINTS.getActivityTemplate; + return apiRequest(apiEndpointPath(API_OPERATIONS.getActivityTemplate, { template_id: templateId }), { + method: endpoint.method, + }).then((result) => normalizeTemplate(recordValue(result.template ?? result))); +} + +export function updateActivityTemplate( + templateId: EntityId, + template: ActivityTemplatePayload, + expectedRevision: number, +): Promise { + const endpoint = API_ENDPOINTS.updateActivityTemplate; + return apiRequest( + apiEndpointPath(API_OPERATIONS.updateActivityTemplate, { template_id: templateId }), + { + body: { ...template, expected_revision: expectedRevision }, + method: endpoint.method, + }, + ).then((result) => normalizeTemplate(recordValue(result.template ?? result))); +} + +export function setActivityTemplateStatus( + templateId: EntityId, + status: "disabled" | "published", + expectedRevision: number, +): Promise { + const endpoint = API_ENDPOINTS.setActivityTemplateStatus; + return apiRequest( + apiEndpointPath(API_OPERATIONS.setActivityTemplateStatus, { template_id: templateId }), + { + body: { expected_revision: expectedRevision, status }, + method: endpoint.method, + }, + ).then((result) => normalizeTemplate(recordValue(result.template ?? result))); +} + +export function deleteActivityTemplate(templateId: EntityId, expectedRevision: number): Promise { + const endpoint = API_ENDPOINTS.deleteActivityTemplate; + return apiRequest(apiEndpointPath(API_OPERATIONS.deleteActivityTemplate, { template_id: templateId }), { + method: endpoint.method, + query: { revision: expectedRevision }, + }).then((result) => normalizeTemplate(recordValue(result.template ?? result))); +} + +export function cloneActivityTemplate( + templateId: EntityId, + payload: { name: string; source_version: number; template_code: string }, +): Promise { + const endpoint = API_ENDPOINTS.cloneActivityTemplate; + return apiRequest( + apiEndpointPath(API_OPERATIONS.cloneActivityTemplate, { template_id: templateId }), + { body: payload, method: endpoint.method }, + ).then((result) => normalizeTemplate(recordValue(result.template ?? result))); +} + +export function listActivityTemplateVersions( + templateId: EntityId, + query: PageQuery = {}, +): Promise> { + const endpoint = API_ENDPOINTS.listActivityTemplateVersions; + return apiRequest( + apiEndpointPath(API_OPERATIONS.listActivityTemplateVersions, { template_id: templateId }), + { method: endpoint.method, query }, + ).then((result) => ({ + items: arrayValue(result.versions ?? result.items).map(normalizeVersion), + page: numberValue(result.page ?? query.page, 1), + pageSize: numberValue(result.page_size ?? result.pageSize ?? query.page_size ?? query.pageSize, 20), + total: numberValue(result.total), + })); +} + +export function validateActivityTemplate( + template: ActivityTemplatePayload, + forPublish: boolean, +): Promise { + const endpoint = API_ENDPOINTS.validateActivityTemplate; + return apiRequest( + apiEndpointPath(API_OPERATIONS.validateActivityTemplate), + { + body: { for_publish: forPublish, template }, + method: endpoint.method, + }, + ).then((result) => ({ + issues: arrayValue(result.issues).map((item) => { + const issue = recordValue(item); + return { + code: stringValue(issue.code), + field: stringValue(issue.field), + message: stringValue(issue.message), + }; + }), + valid: Boolean(result.valid), + })); +} + +export function getActivityTemplateData( + templateId: EntityId, + query: { end_ms: number; region_id?: EntityId; start_ms: number; version_no: number }, +): Promise { + const endpoint = API_ENDPOINTS.getActivityTemplateData; + return apiRequest(apiEndpointPath(API_OPERATIONS.getActivityTemplateData, { template_id: templateId }), { + method: endpoint.method, + query, + }).then(normalizeActivityTemplateData); +} + +export function exportActivityTemplateData( + templateId: EntityId, + query: { end_ms: number; region_id?: EntityId; start_ms: number; version_no: number }, +): Promise { + const endpoint = API_ENDPOINTS.exportActivityTemplateData; + return apiRequest( + apiEndpointPath(API_OPERATIONS.exportActivityTemplateData, { template_id: templateId }), + { method: endpoint.method, query }, + ).then(normalizeActivityTemplateData); +} + +export function listActivityTemplateLeaderboard( + templateId: EntityId, + query: { + board_type: ActivityBoardType; + page?: number; + page_size?: number; + period_key?: string; + version_no: number; + }, +): Promise { + const endpoint = API_ENDPOINTS.listActivityTemplateLeaderboard; + return apiRequest( + apiEndpointPath(API_OPERATIONS.listActivityTemplateLeaderboard, { template_id: templateId }), + { method: endpoint.method, query }, + ).then((result) => ({ + items: arrayValue(result.items).map(normalizeLeaderboardEntry), + page: numberValue(result.page ?? query.page, 1), + pageSize: numberValue(result.page_size ?? result.pageSize ?? query.page_size, 20), + period: normalizeRankPeriod(result.period), + total: numberValue(result.total), + })); +} + +export function listActivityTemplateRewardJobs( + templateId: EntityId, + query: { + board_type?: ActivityBoardType; + page?: number; + page_size?: number; + period_key?: string; + status?: string; + version_no: number; + }, +): Promise> { + const endpoint = API_ENDPOINTS.listActivityTemplateRewardJobs; + return apiRequest( + apiEndpointPath(API_OPERATIONS.listActivityTemplateRewardJobs, { template_id: templateId }), + { method: endpoint.method, query }, + ).then((result) => ({ + items: arrayValue(result.items).map(normalizeRewardJob), + page: numberValue(result.page ?? query.page, 1), + pageSize: numberValue(result.page_size ?? result.pageSize ?? query.page_size, 20), + total: numberValue(result.total), + })); +} + +export function retryActivityTemplateRewardJob( + templateId: EntityId, + rewardJobId: EntityId, +): Promise { + const endpoint = API_ENDPOINTS.retryActivityTemplateRewardJob; + return apiRequest( + apiEndpointPath(API_OPERATIONS.retryActivityTemplateRewardJob, { + reward_job_id: rewardJobId, + template_id: templateId, + }), + { method: endpoint.method }, + ).then(normalizeRewardJob); +} + +export function listActivityTemplateTaskClaims( + templateId: EntityId, + query: { + page?: number; + page_size?: number; + status?: string; + task_day?: string; + task_key?: string; + user_id?: EntityId; + version_no: number; + }, +): Promise> { + const endpoint = API_ENDPOINTS.listActivityTemplateTaskClaims; + return apiRequest( + apiEndpointPath(API_OPERATIONS.listActivityTemplateTaskClaims, { template_id: templateId }), + { method: endpoint.method, query }, + ).then((result) => ({ + items: arrayValue(result.items).map(normalizeTaskClaim), + page: numberValue(result.page ?? query.page, 1), + pageSize: numberValue(result.page_size ?? result.pageSize ?? query.page_size, 20), + total: numberValue(result.total), + })); +} + +export function retryActivityTemplateTaskClaim( + templateId: EntityId, + claimId: EntityId, +): Promise { + const endpoint = API_ENDPOINTS.retryActivityTemplateTaskClaim; + return apiRequest( + apiEndpointPath(API_OPERATIONS.retryActivityTemplateTaskClaim, { + claim_id: claimId, + template_id: templateId, + }), + { method: endpoint.method }, + ).then(normalizeTaskClaim); +} + +function normalizeSummary(value: unknown): ActivityTemplateSummaryDto { + const item = recordValue(value); + return { + activityType: stringValue(item.activity_type ?? item.activityType, "gift_challenge"), + allRegions: Boolean(item.all_regions ?? item.allRegions), + appCode: stringValue(item.app_code ?? item.appCode), + createdAtMs: numberValue(item.created_at_ms ?? item.createdAtMs), + createdByAdminId: numberValue(item.created_by_admin_id ?? item.createdByAdminId), + endMs: numberValue(item.end_ms ?? item.endMs), + lifecycleStatus: stringValue(item.lifecycle_status ?? item.lifecycleStatus, "draft"), + name: stringValue(item.name), + publishedVersion: numberValue(item.published_version ?? item.publishedVersion), + regionIds: arrayValue(item.region_ids ?? item.regionIds).map((regionId) => numberValue(regionId)), + revision: numberValue(item.revision), + startMs: numberValue(item.start_ms ?? item.startMs), + status: stringValue(item.status, "draft"), + templateCode: stringValue(item.template_code ?? item.templateCode), + templateId: stringValue(item.template_id ?? item.templateId), + updatedAtMs: numberValue(item.updated_at_ms ?? item.updatedAtMs), + updatedByAdminId: numberValue(item.updated_by_admin_id ?? item.updatedByAdminId), + }; +} + +function normalizeTemplate(value: unknown): ActivityTemplateDto { + const item = recordValue(value); + return { + ...normalizeSummary(item), + archivedAtMs: numberValue(item.archived_at_ms ?? item.archivedAtMs), + assets: arrayValue(item.assets).map((value) => { + const asset = recordValue(value); + return { + assetKey: stringValue(asset.asset_key ?? asset.assetKey), + locale: stringValue(asset.locale, "*"), + mediaType: stringValue(asset.media_type ?? asset.mediaType, "image"), + sortOrder: numberValue(asset.sort_order ?? asset.sortOrder), + url: stringValue(asset.url), + }; + }), + dailyLeaderboardSize: numberValue(item.daily_leaderboard_size ?? item.dailyLeaderboardSize, 100), + displayConfigJson: stringValue(item.display_config_json ?? item.displayConfigJson, "{}"), + gifts: arrayValue(item.gifts).map((value) => { + const gift = recordValue(value); + return { + coinPrice: numberValue(gift.coin_price ?? gift.coinPrice), + giftId: stringValue(gift.gift_id ?? gift.giftId), + iconUrl: stringValue(gift.icon_url ?? gift.iconUrl), + name: stringValue(gift.name), + priceVersion: stringValue(gift.price_version ?? gift.priceVersion), + sortOrder: numberValue(gift.sort_order ?? gift.sortOrder), + }; + }), + locales: arrayValue(item.locales).map((value) => { + const locale = recordValue(value); + return { + locale: stringValue(locale.locale), + rules: stringValue(locale.rules), + title: stringValue(locale.title), + }; + }), + publishedAtMs: numberValue(item.published_at_ms ?? item.publishedAtMs), + publishedByAdminId: numberValue(item.published_by_admin_id ?? item.publishedByAdminId), + rankRewards: arrayValue(item.rank_rewards ?? item.rankRewards).map((value) => { + const reward = recordValue(value); + return { + boardType: stringValue(reward.board_type ?? reward.boardType, "daily") as ActivityBoardType, + rankFrom: numberValue(reward.rank_from ?? reward.rankFrom), + rankTo: numberValue(reward.rank_to ?? reward.rankTo), + resourceGroupId: numberValue(reward.resource_group_id ?? reward.resourceGroupId), + rewardItems: arrayValue(reward.reward_items ?? reward.rewardItems).map(normalizeRewardItem), + rewardName: stringValue(reward.reward_name ?? reward.rewardName), + rewardSnapshotHash: stringValue(reward.reward_snapshot_hash ?? reward.rewardSnapshotHash), + rewardSnapshotId: stringValue(reward.reward_snapshot_id ?? reward.rewardSnapshotId), + sortOrder: numberValue(reward.sort_order ?? reward.sortOrder), + }; + }), + tasks: arrayValue(item.tasks).map((value) => { + const task = recordValue(value); + return { + rewardResourceGroupId: numberValue(task.reward_resource_group_id ?? task.rewardResourceGroupId), + rewardItems: arrayValue(task.reward_items ?? task.rewardItems).map(normalizeRewardItem), + rewardName: stringValue(task.reward_name ?? task.rewardName), + rewardSnapshotHash: stringValue(task.reward_snapshot_hash ?? task.rewardSnapshotHash), + rewardSnapshotId: stringValue(task.reward_snapshot_id ?? task.rewardSnapshotId), + sortOrder: numberValue(task.sort_order ?? task.sortOrder), + targetValue: numberValue(task.target_value ?? task.targetValue), + taskKey: stringValue(task.task_key ?? task.taskKey), + taskType: stringValue(task.task_type ?? task.taskType, "daily_visit") as ActivityTaskType, + }; + }), + totalLeaderboardSize: numberValue(item.total_leaderboard_size ?? item.totalLeaderboardSize, 100), + }; +} + +function normalizeVersion(value: unknown): ActivityTemplateVersionDto { + const item = recordValue(value); + return { + publishedAtMs: numberValue(item.published_at_ms ?? item.publishedAtMs), + publishedByAdminId: numberValue(item.published_by_admin_id ?? item.publishedByAdminId), + runtimeFromMs: numberValue(item.runtime_from_ms ?? item.runtimeFromMs), + runtimeToMs: numberValue(item.runtime_to_ms ?? item.runtimeToMs), + snapshot: normalizeTemplate(item.snapshot), + templateId: stringValue(item.template_id ?? item.templateId), + versionNo: numberValue(item.version_no ?? item.versionNo), + }; +} + +function normalizeActivityTemplateData(value: unknown): ActivityTemplateDataDto { + const item = recordValue(value); + const overview = recordValue(item.overview); + return { + overview: { + templateId: stringValue(overview.template_id ?? overview.templateId), + templateCode: stringValue(overview.template_code ?? overview.templateCode), + publishedVersion: numberValue(overview.published_version ?? overview.publishedVersion), + participants: numberValue(overview.participants), + visitCount: numberValue(overview.visit_count ?? overview.visitCount), + giftUsers: numberValue(overview.gift_users ?? overview.giftUsers), + taskUsers: numberValue(overview.task_users ?? overview.taskUsers), + giftEventCount: numberValue(overview.gift_event_count ?? overview.giftEventCount), + giftCount: numberValue(overview.gift_count ?? overview.giftCount), + coinAmount: numberValue(overview.coin_amount ?? overview.coinAmount), + taskProgress: numberValue(overview.task_progress ?? overview.taskProgress), + taskCompleted: numberValue(overview.task_completed ?? overview.taskCompleted), + taskClaimed: numberValue(overview.task_claimed ?? overview.taskClaimed), + }, + trend: arrayValue(item.trend).map((value) => { + const point = recordValue(value); + return { + statDay: stringValue(point.stat_day ?? point.statDay), + participants: numberValue(point.participants), + visitCount: numberValue(point.visit_count ?? point.visitCount), + giftUsers: numberValue(point.gift_users ?? point.giftUsers), + taskUsers: numberValue(point.task_users ?? point.taskUsers), + giftEventCount: numberValue(point.gift_event_count ?? point.giftEventCount), + giftCount: numberValue(point.gift_count ?? point.giftCount), + coinAmount: numberValue(point.coin_amount ?? point.coinAmount), + taskProgress: numberValue(point.task_progress ?? point.taskProgress), + taskCompleted: numberValue(point.task_completed ?? point.taskCompleted), + taskClaimed: numberValue(point.task_claimed ?? point.taskClaimed), + }; + }), + gifts: arrayValue(item.gifts).map((value) => { + const gift = recordValue(value); + return { + giftId: stringValue(gift.gift_id ?? gift.giftId), + giftUsers: numberValue(gift.gift_users ?? gift.giftUsers), + giftEventCount: numberValue(gift.gift_event_count ?? gift.giftEventCount), + giftCount: numberValue(gift.gift_count ?? gift.giftCount), + coinAmount: numberValue(gift.coin_amount ?? gift.coinAmount), + }; + }), + tasks: arrayValue(item.tasks).map((value) => { + const task = recordValue(value); + return { + taskKey: stringValue(task.task_key ?? task.taskKey), + taskType: stringValue(task.task_type ?? task.taskType), + taskUsers: numberValue(task.task_users ?? task.taskUsers), + taskProgress: numberValue(task.task_progress ?? task.taskProgress), + taskCompleted: numberValue(task.task_completed ?? task.taskCompleted), + taskClaimed: numberValue(task.task_claimed ?? task.taskClaimed), + }; + }), + granularity: stringValue(item.granularity, "day"), + timezone: stringValue(item.timezone, "UTC"), + startMs: numberValue(item.start_ms ?? item.startMs), + endMs: numberValue(item.end_ms ?? item.endMs), + regionId: numberValue(item.region_id ?? item.regionId), + dimensionLimit: numberValue(item.dimension_limit ?? item.dimensionLimit), + giftSort: stringValue(item.gift_sort ?? item.giftSort), + taskSort: stringValue(item.task_sort ?? item.taskSort), + serverTimeMs: numberValue(item.server_time_ms ?? item.serverTimeMs), + }; +} + +function normalizeRankPeriod(value: unknown): ActivityTemplateRankPeriodDto { + const item = recordValue(value); + return { + templateId: stringValue(item.template_id ?? item.templateId), + templateCode: stringValue(item.template_code ?? item.templateCode), + versionNo: numberValue(item.version_no ?? item.versionNo), + boardType: stringValue(item.board_type ?? item.boardType), + periodKey: stringValue(item.period_key ?? item.periodKey), + periodStartMs: numberValue(item.period_start_ms ?? item.periodStartMs), + periodEndMs: numberValue(item.period_end_ms ?? item.periodEndMs), + settleAfterMs: numberValue(item.settle_after_ms ?? item.settleAfterMs), + status: stringValue(item.status), + settledAtMs: numberValue(item.settled_at_ms ?? item.settledAtMs), + }; +} + +function normalizeLeaderboardEntry(value: unknown): ActivityTemplateLeaderboardEntryDto { + const item = recordValue(value); + return { + rankNo: numberValue(item.rank_no ?? item.rankNo), + userId: numberValue(item.user_id ?? item.userId), + score: numberValue(item.score), + giftCount: numberValue(item.gift_count ?? item.giftCount), + coinAmount: numberValue(item.coin_amount ?? item.coinAmount), + firstScoredAtMs: numberValue(item.first_scored_at_ms ?? item.firstScoredAtMs), + snapshot: Boolean(item.snapshot), + user: normalizeActivityTemplateUser(item.user), + }; +} + +function normalizeRewardJob(value: unknown): ActivityTemplateRewardJobDto { + const item = recordValue(value); + return { + rewardJobId: stringValue(item.reward_job_id ?? item.rewardJobId), + templateId: stringValue(item.template_id ?? item.templateId), + templateCode: stringValue(item.template_code ?? item.templateCode), + versionNo: numberValue(item.version_no ?? item.versionNo), + boardType: stringValue(item.board_type ?? item.boardType), + periodKey: stringValue(item.period_key ?? item.periodKey), + rankNo: numberValue(item.rank_no ?? item.rankNo), + userId: numberValue(item.user_id ?? item.userId), + score: numberValue(item.score), + resourceGroupId: numberValue(item.resource_group_id ?? item.resourceGroupId), + rewardItems: arrayValue(item.reward_items ?? item.rewardItems).map(normalizeRewardItem), + rewardName: stringValue(item.reward_name ?? item.rewardName), + rewardSnapshotHash: stringValue(item.reward_snapshot_hash ?? item.rewardSnapshotHash), + rewardSnapshotId: stringValue(item.reward_snapshot_id ?? item.rewardSnapshotId), + walletCommandId: stringValue(item.wallet_command_id ?? item.walletCommandId), + walletGrantId: stringValue(item.wallet_grant_id ?? item.walletGrantId), + status: stringValue(item.status), + failureReason: stringValue(item.failure_reason ?? item.failureReason), + attemptCount: numberValue(item.attempt_count ?? item.attemptCount), + maxAttempts: numberValue(item.max_attempts ?? item.maxAttempts), + nextRetryAtMs: numberValue(item.next_retry_at_ms ?? item.nextRetryAtMs), + createdAtMs: numberValue(item.created_at_ms ?? item.createdAtMs), + updatedAtMs: numberValue(item.updated_at_ms ?? item.updatedAtMs), + grantedAtMs: numberValue(item.granted_at_ms ?? item.grantedAtMs), + user: normalizeActivityTemplateUser(item.user), + }; +} + +function normalizeTaskClaim(value: unknown): ActivityTemplateTaskClaimDto { + const item = recordValue(value); + return { + attributionAtMs: numberValue(item.attribution_at_ms ?? item.attributionAtMs), + claimId: stringValue(item.claim_id ?? item.claimId), + earningRegionId: numberValue(item.earning_region_id ?? item.earningRegionId), + failureReason: stringValue(item.failure_reason ?? item.failureReason), + grantedAtMs: numberValue(item.granted_at_ms ?? item.grantedAtMs), + rewardItems: arrayValue(item.reward_items ?? item.rewardItems).map(normalizeRewardItem), + rewardName: stringValue(item.reward_name ?? item.rewardName), + rewardResourceGroupId: numberValue(item.reward_resource_group_id ?? item.rewardResourceGroupId), + rewardSnapshotHash: stringValue(item.reward_snapshot_hash ?? item.rewardSnapshotHash), + rewardSnapshotId: stringValue(item.reward_snapshot_id ?? item.rewardSnapshotId), + status: stringValue(item.status), + taskDay: stringValue(item.task_day ?? item.taskDay), + taskKey: stringValue(item.task_key ?? item.taskKey), + templateCode: stringValue(item.template_code ?? item.templateCode), + templateId: stringValue(item.template_id ?? item.templateId), + updatedAtMs: numberValue(item.updated_at_ms ?? item.updatedAtMs), + createdAtMs: numberValue(item.created_at_ms ?? item.createdAtMs), + attemptCount: numberValue(item.attempt_count ?? item.attemptCount), + user: normalizeActivityTemplateUser(item.user), + userId: numberValue(item.user_id ?? item.userId), + versionNo: numberValue(item.version_no ?? item.versionNo), + walletGrantId: stringValue(item.wallet_grant_id ?? item.walletGrantId), + }; +} + +function normalizeRewardItem(value: unknown): ActivityTemplateRewardItemDto { + const item = recordValue(value); + return { + durationMs: numberValue(item.duration_ms ?? item.durationMs), + iconUrl: stringValue(item.icon_url ?? item.iconUrl), + itemType: stringValue(item.item_type ?? item.itemType), + name: stringValue(item.name), + quantity: numberValue(item.quantity), + resourceId: numberValue(item.resource_id ?? item.resourceId), + resourceType: stringValue(item.resource_type ?? item.resourceType), + sortOrder: numberValue(item.sort_order ?? item.sortOrder), + walletAssetAmount: numberValue(item.wallet_asset_amount ?? item.walletAssetAmount), + walletAssetType: stringValue(item.wallet_asset_type ?? item.walletAssetType), + }; +} + +function normalizeActivityTemplateUser(value: unknown): ActivityTemplateUserDto | undefined { + const item = recordValue(value); + if (!Object.keys(item).length) return undefined; + return { + displayUserId: stringValue(item.display_user_id ?? item.displayUserId), + username: stringValue(item.username), + avatar: stringValue(item.avatar), + }; +} + +function recordValue(value: unknown): RawRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as RawRecord) : {}; +} + +function arrayValue(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function stringValue(value: unknown, fallback = ""): string { + return value === undefined || value === null ? fallback : String(value); +} + +function numberValue(value: unknown, fallback = 0): number { + const number = Number(value); + return Number.isFinite(number) ? number : fallback; +} diff --git a/src/features/activity-templates/components/ActivityPhonePreview.jsx b/src/features/activity-templates/components/ActivityPhonePreview.jsx new file mode 100644 index 0000000..ce6f0a0 --- /dev/null +++ b/src/features/activity-templates/components/ActivityPhonePreview.jsx @@ -0,0 +1,159 @@ +import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined"; +import EmojiEventsOutlined from "@mui/icons-material/EmojiEventsOutlined"; +import TaskAltOutlined from "@mui/icons-material/TaskAltOutlined"; +import { useEffect, useMemo, useState } from "react"; +import styles from "@/features/activity-templates/activity-templates.module.css"; +import { parseDisplayConfig } from "@/features/activity-templates/model.js"; + +export function ActivityPhonePreview({ form, resourceGroups = [] }) { + const [locale, setLocale] = useState("en"); + const config = useMemo(() => parseDisplayConfig(form.displayConfigJson), [form.displayConfigJson]); + const [tab, setTab] = useState(() => (config.default_tab === "leaderboard" ? "leaderboard" : "tasks")); + const configuredDefaultTab = config.default_tab === "leaderboard" ? "leaderboard" : "tasks"; + + useEffect(() => { + setTab(configuredDefaultTab); + }, [configuredDefaultTab]); + + const rtl = locale === "ar"; + const title = locale === "ar" ? form.titleAr || form.titleEn : form.titleEn; + const hero = findAsset(form.assets, "hero", locale) || findAsset(form.assets, "hero", "en"); + const pageBackground = findAsset(form.assets, "page_background", "*"); + const groupsById = new Map(resourceGroups.map((group) => [String(group.groupId), group])); + + return ( + + ); +} + +function findAsset(assets, assetKey, locale) { + return (assets || []).find((asset) => asset.assetKey === assetKey && asset.locale === locale)?.url || ""; +} + +function groupName(group, rtl) { + if (!group) return rtl ? "مكافأة" : "Reward"; + return group.name || group.groupCode || `#${group.groupId}`; +} + +function taskLabel(task, rtl) { + if (rtl) { + if (task.taskType === "daily_visit") return "زيارة صفحة النشاط"; + if (task.taskType === "gift_coin_amount") return `إرسال هدايا بقيمة ${task.targetValue}`; + return `إرسال ${task.targetValue} هدية`; + } + if (task.taskType === "daily_visit") return "Visit activity page"; + if (task.taskType === "gift_coin_amount") return `Send ${task.targetValue} coins`; + return `Send ${task.targetValue} gifts`; +} diff --git a/src/features/activity-templates/components/ActivityPhonePreview.test.jsx b/src/features/activity-templates/components/ActivityPhonePreview.test.jsx new file mode 100644 index 0000000..e27f546 --- /dev/null +++ b/src/features/activity-templates/components/ActivityPhonePreview.test.jsx @@ -0,0 +1,23 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { expect, test } from "vitest"; +import { createEmptyActivityTemplateForm } from "@/features/activity-templates/model.js"; +import { ActivityPhonePreview } from "./ActivityPhonePreview.jsx"; + +test("synchronizes the selected preview tab when display default changes", async () => { + const form = createEmptyActivityTemplateForm("lalu"); + const { rerender } = render(); + + expect(screen.getByRole("button", { name: /Tasks/ })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByRole("button", { name: /Ranking/ })).toHaveAttribute("aria-pressed", "false"); + + rerender( + , + ); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /Ranking/ })).toHaveAttribute("aria-pressed", "true"); + }); +}); diff --git a/src/features/activity-templates/components/ActivityTemplateCloneDialog.jsx b/src/features/activity-templates/components/ActivityTemplateCloneDialog.jsx new file mode 100644 index 0000000..645fe1d --- /dev/null +++ b/src/features/activity-templates/components/ActivityTemplateCloneDialog.jsx @@ -0,0 +1,63 @@ +import ContentCopyOutlined from "@mui/icons-material/ContentCopyOutlined"; +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 TextField from "@mui/material/TextField"; +import { Button } from "@/shared/ui/Button.jsx"; +import styles from "@/features/activity-templates/activity-templates.module.css"; + +export function ActivityTemplateCloneDialog({ form, onChange, onClose, onSubmit, open, saving, source }) { + const validCode = /^[a-z][a-z0-9_-]{2,63}$/.test(form.templateCode); + const valid = validCode && Boolean(form.name.trim()); + return ( + +
+ 复制活动模版 + +
+ {source?.name || "-"} + + {source?.templateCode || source?.templateId || "-"} ·{" "} + {form.sourceVersion ? `版本 v${form.sourceVersion}` : "当前编辑态"} + +
+ onChange({ templateCode: normalizeCode(event.target.value) })} + /> + onChange({ name: event.target.value })} + /> +
+ + + + +
+
+ ); +} + +function normalizeCode(value) { + return String(value || "") + .toLowerCase() + .replace(/[^a-z0-9_-]/g, ""); +} diff --git a/src/features/activity-templates/components/ActivityTemplateDataDrawer.jsx b/src/features/activity-templates/components/ActivityTemplateDataDrawer.jsx new file mode 100644 index 0000000..50eb8cd --- /dev/null +++ b/src/features/activity-templates/components/ActivityTemplateDataDrawer.jsx @@ -0,0 +1,184 @@ +import DownloadOutlined from "@mui/icons-material/DownloadOutlined"; +import RefreshOutlined from "@mui/icons-material/RefreshOutlined"; +import MenuItem from "@mui/material/MenuItem"; +import Tab from "@mui/material/Tab"; +import Tabs from "@mui/material/Tabs"; +import TextField from "@mui/material/TextField"; +import { ActivityTemplateDataOverview } from "@/features/activity-templates/components/ActivityTemplateDataOverview.jsx"; +import { ActivityTemplateLeaderboardPanel } from "@/features/activity-templates/components/ActivityTemplateLeaderboardPanel.jsx"; +import { ActivityTemplateRewardsPanel } from "@/features/activity-templates/components/ActivityTemplateRewardsPanel.jsx"; +import { ActivityTemplateTaskClaimsPanel } from "@/features/activity-templates/components/ActivityTemplateTaskClaimsPanel.jsx"; +import { useActivityTemplateDataCenter } from "@/features/activity-templates/hooks/useActivityTemplateDataCenter.js"; +import { Button } from "@/shared/ui/Button.jsx"; +import { RegionFilterControl } from "@/shared/ui/RegionFilterControl.jsx"; +import { SideDrawer } from "@/shared/ui/SideDrawer.jsx"; +import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx"; +import styles from "@/features/activity-templates/activity-templates.module.css"; + +const tabs = [ + ["overview", "活动数据"], + ["leaderboard", "榜单"], + ["rewards", "榜单奖励"], + ["taskClaims", "任务领奖"], +]; + +export function ActivityTemplateDataDrawer({ abilities, loadingRegions, onClose, open, regionOptions, source }) { + const dataCenter = useActivityTemplateDataCenter({ abilities, regionOptions, source }); + const activeReload = + dataCenter.activeTab === "leaderboard" + ? dataCenter.reloadLeaderboard + : dataCenter.activeTab === "rewards" + ? dataCenter.reloadRewards + : dataCenter.activeTab === "taskClaims" + ? dataCenter.reloadTaskClaims + : dataCenter.reloadData; + + return ( + +
+
+ {source?.templateCode || source?.templateId || "-"} + 发布 v{dataCenter.versionNo || 0} +
+
+ + {abilities.canExport && dataCenter.activeTab === "overview" ? ( + + ) : null} +
+
+
+ dataCenter.changeVersion(event.target.value)} + > + {dataCenter.versionItems.map((item) => ( + + v{item.versionNo} + + ))} + + {dataCenter.activeTab === "overview" ? ( + <> + + + + ) : null} + {dataCenter.versionsError ? ( + + ) : null} +
+ dataCenter.setActiveTab(value)} + > + {tabs.map(([value, label]) => ( + + ))} + +
+ {dataCenter.activeTab === "overview" ? ( + + ) : null} + {dataCenter.activeTab === "leaderboard" ? ( + + ) : null} + {dataCenter.activeTab === "rewards" ? ( + + ) : null} + {dataCenter.activeTab === "taskClaims" ? ( + + ) : null} +
+
+ ); +} diff --git a/src/features/activity-templates/components/ActivityTemplateDataOverview.jsx b/src/features/activity-templates/components/ActivityTemplateDataOverview.jsx new file mode 100644 index 0000000..7c82121 --- /dev/null +++ b/src/features/activity-templates/components/ActivityTemplateDataOverview.jsx @@ -0,0 +1,175 @@ +import { MultiSeriesLineChart } from "@/shared/charts/MultiSeriesLineChart.jsx"; +import { DataState } from "@/shared/ui/DataState.jsx"; +import { DataTable } from "@/shared/ui/DataTable.jsx"; +import { formatActivityMetric } from "@/features/activity-templates/dataModel.js"; +import styles from "@/features/activity-templates/activity-templates.module.css"; + +const metricDefinitions = [ + ["participants", "参与用户", "人"], + ["visitCount", "访问人次", "次"], + ["giftUsers", "送礼用户", "人"], + ["taskUsers", "任务用户", "人"], + ["coinAmount", "活动金币", "coin"], + ["giftEventCount", "送礼事件", "次"], + ["giftCount", "礼物数量", "个"], + ["taskProgress", "任务进度", "点"], + ["taskCompleted", "任务完成", "次"], + ["taskClaimed", "任务领奖", "次"], +]; + +export function ActivityTemplateDataOverview({ data, error, loading, onRetry, template }) { + const giftLabels = new Map((template.gifts || []).map((gift) => [String(gift.giftId), gift])); + const taskLabels = new Map((template.tasks || []).map((task) => [String(task.taskKey), task])); + const giftColumns = [ + { + key: "gift", + label: "礼物", + width: "minmax(180px, 1fr)", + render: (item) => { + const gift = giftLabels.get(String(item.giftId)); + return ( +
+ {gift?.name || item.giftId || "-"} + {item.giftId || "-"} +
+ ); + }, + }, + { key: "users", label: "送礼用户", width: "100px", render: (item) => metric(item.giftUsers) }, + { key: "events", label: "事件", width: "90px", render: (item) => metric(item.giftEventCount) }, + { key: "count", label: "数量", width: "90px", render: (item) => metric(item.giftCount) }, + { key: "coins", label: "金币", width: "110px", render: (item) => metric(item.coinAmount) }, + ]; + const taskColumns = [ + { + key: "task", + label: "任务", + width: "minmax(200px, 1fr)", + render: (item) => { + const task = taskLabels.get(String(item.taskKey)); + return ( +
+ {item.taskKey || "-"} + {taskTypeLabel(task?.taskType || item.taskType)} +
+ ); + }, + }, + { key: "users", label: "任务用户", width: "100px", render: (item) => metric(item.taskUsers) }, + { key: "progress", label: "累计进度", width: "100px", render: (item) => metric(item.taskProgress) }, + { key: "completed", label: "完成", width: "90px", render: (item) => metric(item.taskCompleted) }, + { key: "claimed", label: "领奖", width: "90px", render: (item) => metric(item.taskClaimed) }, + ]; + const trendSeries = [ + { data: data.trend.map((item) => item.participants), key: "participants", name: "参与用户" }, + { + data: data.trend.map((item) => item.visitCount), + key: "visits", + name: "访问人次", + colorToken: "--chart-violet", + }, + { + data: data.trend.map((item) => item.giftUsers), + key: "gift-users", + name: "送礼用户", + colorToken: "--chart-green", + }, + { + data: data.trend.map((item) => item.taskUsers), + key: "task-users", + name: "任务用户", + colorToken: "--chart-orange", + }, + { + data: data.trend.map((item) => item.coinAmount), + key: "coins", + name: "金币", + colorToken: "--chart-red", + yAxisIndex: 1, + }, + ]; + + return ( + +
+
+ {metricDefinitions.map(([key, label, unit]) => ( +
+ {label} + {formatActivityMetric(data.overview[key])} + {unit} +
+ ))} +
+
+
+
+

每日趋势

+ {effectiveWindowLabel(data)} +
+ {data.timezone || "UTC"} · 日粒度 +
+ item.statDay)} + series={trendSeries} + /> +
+
+
+
+
+

礼物排行

+ 按金币、数量降序 +
+ + {data.gifts.length} / {data.dimensionLimit || 100} + +
+ item.giftId} + total={data.gifts.length} + /> +
+
+
+
+

任务排行

+ 按完成、领奖降序 +
+ + {data.tasks.length} / {data.dimensionLimit || 100} + +
+ item.taskKey} + total={data.tasks.length} + /> +
+
+
+
+ ); +} + +function metric(value) { + return {formatActivityMetric(value)}; +} + +function effectiveWindowLabel(data) { + if (!data.startMs || !data.endMs) return "UTC 统计窗口"; + return `${new Date(data.startMs).toISOString().slice(0, 10)} 至 ${new Date(data.endMs - 1).toISOString().slice(0, 10)}`; +} + +function taskTypeLabel(value) { + if (value === "daily_visit") return "每日访问"; + if (value === "gift_count") return "礼物数量"; + if (value === "gift_coin_amount") return "礼物金币"; + return value || "-"; +} diff --git a/src/features/activity-templates/components/ActivityTemplateEditorDrawer.jsx b/src/features/activity-templates/components/ActivityTemplateEditorDrawer.jsx new file mode 100644 index 0000000..886b66d --- /dev/null +++ b/src/features/activity-templates/components/ActivityTemplateEditorDrawer.jsx @@ -0,0 +1,256 @@ +import CloseOutlined from "@mui/icons-material/CloseOutlined"; +import PublishOutlined from "@mui/icons-material/PublishOutlined"; +import SaveOutlined from "@mui/icons-material/SaveOutlined"; +import StopCircleOutlined from "@mui/icons-material/StopCircleOutlined"; +import Alert from "@mui/material/Alert"; +import Drawer from "@mui/material/Drawer"; +import { useEffect, useMemo, useState } from "react"; +import { Button } from "@/shared/ui/Button.jsx"; +import { IconButton } from "@/shared/ui/IconButton.jsx"; +import { PageSkeleton } from "@/shared/ui/PageSkeleton.jsx"; +import styles from "@/features/activity-templates/activity-templates.module.css"; +import { EDITOR_SECTIONS } from "@/features/activity-templates/constants.js"; +import { ActivityTemplateStatusBadge } from "@/features/activity-templates/components/ActivityTemplateStatusBadge.jsx"; +import { ActivityPhonePreview } from "@/features/activity-templates/components/ActivityPhonePreview.jsx"; +import { + BasicSettingsSection, + DisplayConfigSection, + RulesSettingsSection, +} from "@/features/activity-templates/components/BasicAndContentSections.jsx"; +import { + GiftSettingsSection, + TaskSettingsSection, +} from "@/features/activity-templates/components/GiftTaskSections.jsx"; +import { + AssetsSettingsSection, + RankingSettingsSection, +} from "@/features/activity-templates/components/RankingAssetsSections.jsx"; + +export function ActivityTemplateEditorDrawer({ + abilities, + dirty, + form, + gifts, + giftsLoading, + loading, + loadingRegions, + mode, + onClose, + onDisable, + onPublish, + onReload, + onSubmit, + open, + regionOptions, + resourceGroups, + saving, + setForm, + validationIssues, +}) { + const [activeSection, setActiveSection] = useState("basic"); + + useEffect(() => { + if (open) setActiveSection("basic"); + }, [open, mode, form.templateId]); + + const isPublished = form.status === "published"; + const immutableMode = mode === "view" || mode === "version"; + const allowedToEdit = mode === "create" ? abilities.canCreate : mode === "edit" ? abilities.canUpdate : false; + const readOnly = immutableMode || isPublished || !allowedToEdit; + const canSave = !readOnly && !loading && !saving; + const hasRevisionConflict = validationIssues?.some((issue) => issue.code === "revision_conflict"); + const sectionCounts = useMemo( + () => ({ + assets: form.assets.filter((asset) => asset.url).length, + gifts: form.gifts.length, + ranking: form.dailyRankRewards.length + form.totalRankRewards.length, + tasks: form.tasks.length, + }), + [form.assets, form.dailyRankRewards.length, form.gifts.length, form.tasks.length, form.totalRankRewards.length], + ); + + const updateField = (key, value) => setForm((current) => ({ ...current, [key]: value })); + + return ( + +
+
+
+
+

{drawerTitle(mode, form)}

+ {form.templateId ? ( + + ) : null} + {dirty ? 未保存 : null} +
+ + {form.templateId + ? `${form.templateCode || form.templateId} · revision ${form.revision}` + : "新建礼物挑战赛活动模版"} + +
+ + + +
+ + + +
+ {loading ? ( +
+ +
+ ) : ( + <> +
+ {isPublished ? ( + + 停用 + + ) : null + } + severity="info" + > + {mode === "version" + ? "发布版本为不可变快照。" + : "已发布版本只读,停用后可编辑并重新发布。"} + + ) : null} + {validationIssues?.length ? ( + + 重新加载 + + ) : null + } + severity="error" + > +
+ {validationIssues.slice(0, 8).map((issue, index) => ( + + {issue.field ? `${issue.field}:` : ""} + {issue.message} + + ))} +
+
+ ) : null} + {activeSection === "basic" ? ( + + ) : null} + {activeSection === "gifts" ? ( + + ) : null} + {activeSection === "tasks" ? ( + + ) : null} + {activeSection === "ranking" ? ( + + ) : null} + {activeSection === "rules" ? ( + + ) : null} + {activeSection === "assets" ? ( + + ) : null} + {activeSection === "display" ? ( + + ) : null} +
+ + + )} +
+ +
+ + {canSave ? ( + + ) : null} + {canSave && abilities.canPublish ? ( + + ) : null} + {isPublished && !immutableMode && abilities.canPublish ? ( + + ) : null} +
+
+
+ ); +} + +function drawerTitle(mode, form) { + if (mode === "create") return "新增活动模版"; + if (mode === "version") return `发布版本 v${form.publishedVersion || "-"}`; + if (mode === "view") return "查看活动模版"; + return "编辑活动模版"; +} diff --git a/src/features/activity-templates/components/ActivityTemplateEditorDrawer.test.jsx b/src/features/activity-templates/components/ActivityTemplateEditorDrawer.test.jsx new file mode 100644 index 0000000..8b35d97 --- /dev/null +++ b/src/features/activity-templates/components/ActivityTemplateEditorDrawer.test.jsx @@ -0,0 +1,125 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { expect, test, vi } from "vitest"; +import { createEmptyActivityTemplateForm } from "@/features/activity-templates/model.js"; +import { ActivityTemplateEditorDrawer } from "./ActivityTemplateEditorDrawer.jsx"; + +vi.mock("@mui/material/Drawer", () => ({ + default: ({ children, open }) => (open ?
{children}
: null), +})); + +vi.mock("@/features/activity-templates/components/ActivityPhonePreview.jsx", () => ({ + ActivityPhonePreview: () => , +})); + +vi.mock("@/shared/ui/TimeRangeFilter.jsx", () => ({ + TimeRangeFilter: ({ disabled, label }) => , +})); + +vi.mock("@/shared/ui/MultiValueAutocomplete.jsx", () => ({ + MultiValueAutocomplete: ({ disabled, label, required = false }) => ( + + ), +})); + +test("create permission alone keeps create fields and draft action editable", () => { + renderEditor({ + abilities: abilities({ canCreate: true, canUpdate: false }), + form: createEmptyActivityTemplateForm("lalu"), + mode: "create", + }); + + expect(screen.getByRole("textbox", { name: /内部名称/ })).toBeEnabled(); + expect(screen.getByRole("button", { name: "保存草稿" })).toBeEnabled(); +}); + +test("publish-only English content and region fields do not use native required validation", () => { + renderEditor({ form: createEmptyActivityTemplateForm("lalu"), mode: "create" }); + + expect(screen.getByRole("textbox", { name: "活动标题(英语)" })).not.toBeRequired(); + expect(screen.getByRole("textbox", { name: "指定投放区域" })).not.toBeRequired(); + + fireEvent.click(screen.getByRole("button", { name: "规则文案" })); + expect(screen.getByRole("textbox", { name: "活动规则(英语)" })).not.toBeRequired(); +}); + +test("version snapshots never expose a disable action", () => { + const onDisable = vi.fn(); + const form = { + ...createEmptyActivityTemplateForm("lalu"), + lifecycleStatus: "published", + publishedVersion: 4, + revision: 12, + status: "published", + templateCode: "gift_challenge_2026", + templateId: "template-101", + }; + + renderEditor({ form, mode: "version", onDisable }); + + expect(screen.getByText("发布版本为不可变快照。")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /停用/ })).not.toBeInTheDocument(); + expect(onDisable).not.toHaveBeenCalled(); +}); + +test("only exposes reload for an optimistic revision conflict", () => { + const form = { + ...createEmptyActivityTemplateForm("lalu"), + templateCode: "gift_challenge_2026", + templateId: "template-101", + }; + const { rerender } = renderEditor({ + form, + mode: "edit", + validationIssues: [{ code: "save_failed", field: "name", message: "保存失败" }], + }); + + expect(screen.queryByRole("button", { name: "重新加载" })).not.toBeInTheDocument(); + + const props = editorProps({ + form, + mode: "edit", + validationIssues: [{ code: "revision_conflict", field: "revision", message: "版本已变化" }], + }); + rerender(); + expect(screen.getByRole("button", { name: "重新加载" })).toBeEnabled(); +}); + +function renderEditor(patch = {}) { + return render(); +} + +function editorProps(patch = {}) { + return { + abilities: abilities(), + dirty: false, + form: createEmptyActivityTemplateForm("lalu"), + gifts: [], + giftsLoading: false, + loading: false, + loadingRegions: false, + mode: "create", + onClose: vi.fn(), + onDisable: vi.fn(), + onPublish: vi.fn(), + onReload: vi.fn(), + onSubmit: vi.fn((event) => event.preventDefault()), + open: true, + regionOptions: [], + resourceGroups: [], + saving: false, + setForm: vi.fn(), + validationIssues: [], + ...patch, + }; +} + +function abilities(patch = {}) { + return { + canCreate: true, + canDelete: true, + canPublish: true, + canUpdate: true, + canView: true, + ...patch, + }; +} diff --git a/src/features/activity-templates/components/ActivityTemplateLeaderboardPanel.jsx b/src/features/activity-templates/components/ActivityTemplateLeaderboardPanel.jsx new file mode 100644 index 0000000..50a7a37 --- /dev/null +++ b/src/features/activity-templates/components/ActivityTemplateLeaderboardPanel.jsx @@ -0,0 +1,140 @@ +import MenuItem from "@mui/material/MenuItem"; +import Pagination from "@mui/material/Pagination"; +import TextField from "@mui/material/TextField"; +import Avatar from "@mui/material/Avatar"; +import { DataState } from "@/shared/ui/DataState.jsx"; +import { DataTable } from "@/shared/ui/DataTable.jsx"; +import { formatActivityMetric } from "@/features/activity-templates/dataModel.js"; +import { formatMillis } from "@/shared/utils/time.js"; +import styles from "@/features/activity-templates/activity-templates.module.css"; + +export function ActivityTemplateLeaderboardPanel({ + boardType, + data, + error, + loading, + onBoardTypeChange, + onPageChange, + onPeriodChange, + onRetry, + page, + periodKey, + periodOptions, +}) { + const columns = [ + { key: "rank", label: "排名", width: "76px", render: (item) => #{item.rankNo} }, + { + key: "user", + label: "用户", + width: "minmax(210px, .9fr)", + render: (item) => ( +
+ + {(item.user?.username || String(item.userId || "-")).slice(0, 1)} + +
+ {item.user?.username || `用户 #${item.userId || "-"}`} + + {item.user?.displayUserId + ? `${item.user.displayUserId} · ` + : `UID ${item.userId || "-"} · `} + {item.snapshot ? "结算快照" : "实时排名"} + +
+
+ ), + }, + { key: "score", label: "积分", width: "120px", render: (item) => numeric(item.score) }, + { key: "coins", label: "金币", width: "120px", render: (item) => numeric(item.coinAmount) }, + { key: "gifts", label: "礼物数", width: "110px", render: (item) => numeric(item.giftCount) }, + { + key: "first", + label: "首次得分", + width: "180px", + render: (item) => formatMillis(item.firstScoredAtMs), + }, + ]; + const pageCount = Math.max(1, Math.ceil(Number(data.total || 0) / Number(data.pageSize || 20))); + + return ( +
+
+ onBoardTypeChange(event.target.value)} + > + 日榜 + 总榜 + + {boardType === "daily" ? ( + onPeriodChange(event.target.value)} + > + {periodOptions.map((item) => ( + + {item} + + ))} + + ) : null} + +
+ + `${item.rankNo}-${item.userId}`} + total={data.total || 0} + /> + {pageCount > 1 ? ( +
+ onPageChange(value)} + /> +
+ ) : null} +
+
+ ); +} + +function PeriodStatus({ period }) { + if (!period?.periodKey) return null; + return ( +
+ + {periodStatusLabel(period.status)} + + {period.periodKey} + {period.settledAtMs ? ( + 结算 {formatMillis(period.settledAtMs)} + ) : period.settleAfterMs ? ( + 结算水位 {formatMillis(period.settleAfterMs)} + ) : null} +
+ ); +} + +function numeric(value) { + return {formatActivityMetric(value)}; +} + +function periodStatusLabel(value) { + if (value === "pending") return "计分中"; + if (value === "open") return "进行中"; + if (value === "settling") return "结算中"; + if (value === "settled") return "已结算"; + if (value === "cancelled") return "已取消"; + return value || "未知"; +} diff --git a/src/features/activity-templates/components/ActivityTemplateOverlaySkeletons.jsx b/src/features/activity-templates/components/ActivityTemplateOverlaySkeletons.jsx new file mode 100644 index 0000000..026ec31 --- /dev/null +++ b/src/features/activity-templates/components/ActivityTemplateOverlaySkeletons.jsx @@ -0,0 +1,30 @@ +import Dialog from "@mui/material/Dialog"; +import DialogContent from "@mui/material/DialogContent"; +import DialogTitle from "@mui/material/DialogTitle"; +import Skeleton from "@mui/material/Skeleton"; +import { PageSkeleton } from "@/shared/ui/PageSkeleton.jsx"; +import { SideDrawer } from "@/shared/ui/SideDrawer.jsx"; +import styles from "@/features/activity-templates/activity-templates.module.css"; + +export function ActivityTemplateDrawerSkeleton({ className = "", onClose, title, width = "default" }) { + return ( + +
+ +
+
+ ); +} + +export function ActivityTemplateCloneDialogSkeleton({ onClose }) { + return ( + + 复制活动模版 + + + + + + + ); +} diff --git a/src/features/activity-templates/components/ActivityTemplateRewardsPanel.jsx b/src/features/activity-templates/components/ActivityTemplateRewardsPanel.jsx new file mode 100644 index 0000000..40102fc --- /dev/null +++ b/src/features/activity-templates/components/ActivityTemplateRewardsPanel.jsx @@ -0,0 +1,206 @@ +import ReplayOutlined from "@mui/icons-material/ReplayOutlined"; +import Avatar from "@mui/material/Avatar"; +import MenuItem from "@mui/material/MenuItem"; +import Pagination from "@mui/material/Pagination"; +import TextField from "@mui/material/TextField"; +import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx"; +import { DataState } from "@/shared/ui/DataState.jsx"; +import { DataTable } from "@/shared/ui/DataTable.jsx"; +import { StatusSelect } from "@/shared/ui/StatusSelect.jsx"; +import { formatActivityMetric } from "@/features/activity-templates/dataModel.js"; +import { formatMillis } from "@/shared/utils/time.js"; +import styles from "@/features/activity-templates/activity-templates.module.css"; + +const rewardStatusOptions = [ + ["", "全部状态"], + ["pending", "待发放"], + ["running", "发放中"], + ["granted", "已发放"], + ["failed", "失败"], + ["dead", "人工处理"], +]; + +export function ActivityTemplateRewardsPanel({ + abilities, + boardType, + data, + error, + loading, + loadingAction, + onBoardTypeChange, + onPageChange, + onPeriodChange, + onRetry, + onRetryJob, + onStatusChange, + page, + periodKey, + periodOptions, + status, +}) { + const columns = [ + { + key: "job", + label: "奖励任务", + width: "minmax(210px, 1fr)", + render: (item) => ( +
+ {item.rewardJobId || "-"} + + v{item.versionNo} · {item.boardType === "daily" ? "日榜" : "总榜"} {item.periodKey} + +
+ ), + }, + { + key: "user", + label: "用户 / 名次", + width: "150px", + render: (item) => ( +
+ + {(item.user?.username || String(item.userId || "-")).slice(0, 1)} + +
+ {item.user?.username || `#${item.userId || "-"}`} + + {item.user?.displayUserId || `UID ${item.userId || "-"}`} · 排名 #{item.rankNo} ·{" "} + {formatActivityMetric(item.score)} 分 + +
+
+ ), + }, + { + key: "resource", + label: "发布态奖励", + width: "minmax(180px, .75fr)", + render: (item) => ( +
+ {item.rewardName || `资源组 #${item.resourceGroupId || "-"}`} + + {item.rewardItems.length} 项 · {item.rewardSnapshotId || `group ${item.resourceGroupId || "-"}`} + +
+ ), + }, + { + key: "status", + label: "状态 / 尝试", + width: "130px", + render: (item) => ( +
+ + {rewardStatusLabel(item.status)} + + + {item.attemptCount || 0}/{item.maxAttempts || "-"} 次 + {item.nextRetryAtMs > 0 ? ` · 下次 ${formatMillis(item.nextRetryAtMs)}` : ""} + +
+ ), + }, + { + key: "failure", + label: "失败原因", + width: "minmax(200px, .9fr)", + render: (item) => ( + + {item.failureReason || "-"} + + ), + }, + { + key: "time", + label: "更新时间", + width: "180px", + render: (item) => ( +
+ {formatMillis(item.updatedAtMs)} + + {item.grantedAtMs + ? `发放 ${formatMillis(item.grantedAtMs)}` + : `创建 ${formatMillis(item.createdAtMs)}`} + +
+ ), + }, + { + key: "actions", + label: "操作", + width: "72px", + render: (item) => ( + + {abilities.canRetry && ["failed", "dead"].includes(item.status) ? ( + onRetryJob(item)} + > + + + ) : null} + + ), + }, + ]; + const pageCount = Math.max(1, Math.ceil(Number(data.total || 0) / Number(data.pageSize || 20))); + + return ( +
+
+ onBoardTypeChange(event.target.value)} + > + 全部榜单 + 日榜 + 总榜 + + {boardType === "daily" ? ( + onPeriodChange(event.target.value)} + > + 全部周期 + {periodOptions.map((item) => ( + + {item} + + ))} + + ) : null} + +
+ + item.rewardJobId} + total={data.total || 0} + /> + {pageCount > 1 ? ( +
+ onPageChange(value)} + /> +
+ ) : null} +
+
+ ); +} + +function rewardStatusLabel(value) { + return rewardStatusOptions.find(([status]) => status === value)?.[1] || value || "未知"; +} diff --git a/src/features/activity-templates/components/ActivityTemplateRewardsPanel.test.jsx b/src/features/activity-templates/components/ActivityTemplateRewardsPanel.test.jsx new file mode 100644 index 0000000..569278a --- /dev/null +++ b/src/features/activity-templates/components/ActivityTemplateRewardsPanel.test.jsx @@ -0,0 +1,62 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { expect, test, vi } from "vitest"; +import { ActivityTemplateRewardsPanel } from "./ActivityTemplateRewardsPanel.jsx"; + +test("renders dead-letter retry budget and allows an authorized manual retry", () => { + const onRetryJob = vi.fn(); + render( + , + ); + + expect(screen.getAllByText("人工处理")).toHaveLength(2); + expect(screen.getByText("8/8 次")).toBeInTheDocument(); + expect(screen.getByText("Champion reward")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "重试发放" })); + expect(onRetryJob).toHaveBeenCalledWith(expect.objectContaining({ rewardJobId: "reward-101" })); +}); diff --git a/src/features/activity-templates/components/ActivityTemplateStatusBadge.jsx b/src/features/activity-templates/components/ActivityTemplateStatusBadge.jsx new file mode 100644 index 0000000..f82476c --- /dev/null +++ b/src/features/activity-templates/components/ActivityTemplateStatusBadge.jsx @@ -0,0 +1,12 @@ +import styles from "@/features/activity-templates/activity-templates.module.css"; +import { activityTemplateStatusLabel } from "@/features/activity-templates/constants.js"; + +export function ActivityTemplateStatusBadge({ status }) { + const normalized = String(status || "draft").toLowerCase(); + return ( + + + {activityTemplateStatusLabel(normalized)} + + ); +} diff --git a/src/features/activity-templates/components/ActivityTemplateTaskClaimsPanel.jsx b/src/features/activity-templates/components/ActivityTemplateTaskClaimsPanel.jsx new file mode 100644 index 0000000..fcb2543 --- /dev/null +++ b/src/features/activity-templates/components/ActivityTemplateTaskClaimsPanel.jsx @@ -0,0 +1,213 @@ +import ReplayOutlined from "@mui/icons-material/ReplayOutlined"; +import Avatar from "@mui/material/Avatar"; +import MenuItem from "@mui/material/MenuItem"; +import Pagination from "@mui/material/Pagination"; +import TextField from "@mui/material/TextField"; +import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx"; +import { DataState } from "@/shared/ui/DataState.jsx"; +import { DataTable } from "@/shared/ui/DataTable.jsx"; +import { StatusSelect } from "@/shared/ui/StatusSelect.jsx"; +import { formatMillis } from "@/shared/utils/time.js"; +import styles from "@/features/activity-templates/activity-templates.module.css"; + +const claimStatusOptions = [ + ["", "全部状态"], + ["pending", "待发放"], + ["running", "发放中"], + ["granted", "已发放"], + ["failed", "失败"], +]; + +export function ActivityTemplateTaskClaimsPanel({ + abilities, + data, + error, + loading, + loadingAction, + onDayChange, + onPageChange, + onRetry, + onRetryClaim, + onStatusChange, + onTaskKeyChange, + onUserIdChange, + page, + periodOptions, + status, + taskDay, + taskKey, + taskOptions, + userId, +}) { + const columns = [ + { + key: "claim", + label: "领奖记录 / 任务", + width: "minmax(230px, 1fr)", + render: (item) => ( +
+ {item.claimId || "-"} + + v{item.versionNo} · {item.taskDay} · {item.taskKey} + +
+ ), + }, + { + key: "user", + label: "用户 / 归属区域", + width: "190px", + render: (item) => ( +
+ + {(item.user?.username || String(item.userId || "-")).slice(0, 1)} + +
+ {item.user?.username || `#${item.userId || "-"}`} + + {item.user?.displayUserId || `UID ${item.userId || "-"}`} · 区域 #{item.earningRegionId || "-"} + +
+
+ ), + }, + { + key: "reward", + label: "发布态奖励", + width: "minmax(190px, .85fr)", + render: (item) => ( +
+ {item.rewardName || `资源组 #${item.rewardResourceGroupId || "-"}`} + + {item.rewardItems.length} 项 · {item.rewardSnapshotId || `group ${item.rewardResourceGroupId || "-"}`} + +
+ ), + }, + { + key: "status", + label: "状态 / 尝试", + width: "124px", + render: (item) => ( +
+ + {claimStatusLabel(item.status)} + + {item.attemptCount || 0} 次 +
+ ), + }, + { + key: "grant", + label: "钱包发放", + width: "minmax(160px, .7fr)", + render: (item) => ( +
+ {item.walletGrantId || "-"} + {item.grantedAtMs ? formatMillis(item.grantedAtMs) : "尚未发放"} +
+ ), + }, + { + key: "attribution", + label: "业务归因 / 更新", + width: "190px", + render: (item) => ( +
+ {formatMillis(item.attributionAtMs)} + 更新 {formatMillis(item.updatedAtMs || item.createdAtMs)} +
+ ), + }, + { + key: "failure", + label: "失败原因", + width: "minmax(190px, .8fr)", + render: (item) => ( + + {item.failureReason || "-"} + + ), + }, + { + key: "actions", + label: "操作", + width: "72px", + render: (item) => ( + + {abilities.canRetry && item.status === "failed" ? ( + onRetryClaim(item)} + > + + + ) : null} + + ), + }, + ]; + const pageCount = Math.max(1, Math.ceil(Number(data.total || 0) / Number(data.pageSize || 20))); + + return ( +
+
+ onDayChange(event.target.value)} + > + 全部日期 + {periodOptions.map((item) => ( + + {item} + + ))} + + onTaskKeyChange(event.target.value)} + > + 全部任务 + {taskOptions.map((item) => ( + + {item.taskKey} + + ))} + + onUserIdChange(event.target.value)} + /> + +
+ + item.claimId} + total={data.total || 0} + /> + {pageCount > 1 ? ( +
+ onPageChange(value)} /> +
+ ) : null} +
+
+ ); +} + +function claimStatusLabel(value) { + return claimStatusOptions.find(([status]) => status === value)?.[1] || value || "未知"; +} diff --git a/src/features/activity-templates/components/ActivityTemplateTaskClaimsPanel.test.jsx b/src/features/activity-templates/components/ActivityTemplateTaskClaimsPanel.test.jsx new file mode 100644 index 0000000..0191fbf --- /dev/null +++ b/src/features/activity-templates/components/ActivityTemplateTaskClaimsPanel.test.jsx @@ -0,0 +1,65 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { expect, test, vi } from "vitest"; +import { ActivityTemplateTaskClaimsPanel } from "./ActivityTemplateTaskClaimsPanel.jsx"; + +test("renders immutable claim attribution and retries only failed claims", () => { + const onRetryClaim = vi.fn(); + render( + , + ); + + expect(screen.getByText("claim-101")).toBeInTheDocument(); + expect(screen.getByText("Daily task reward")).toBeInTheDocument(); + expect(screen.getByText(/区域 #7/)).toBeInTheDocument(); + expect(screen.getByText("wallet unavailable")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "重试发放" })); + expect(onRetryClaim).toHaveBeenCalledWith(expect.objectContaining({ claimId: "claim-101" })); +}); diff --git a/src/features/activity-templates/components/ActivityTemplateVersionsDrawer.jsx b/src/features/activity-templates/components/ActivityTemplateVersionsDrawer.jsx new file mode 100644 index 0000000..040089d --- /dev/null +++ b/src/features/activity-templates/components/ActivityTemplateVersionsDrawer.jsx @@ -0,0 +1,123 @@ +import ContentCopyOutlined from "@mui/icons-material/ContentCopyOutlined"; +import VisibilityOutlined from "@mui/icons-material/VisibilityOutlined"; +import Pagination from "@mui/material/Pagination"; +import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx"; +import { DataState } from "@/shared/ui/DataState.jsx"; +import { DataTable } from "@/shared/ui/DataTable.jsx"; +import { SideDrawer } from "@/shared/ui/SideDrawer.jsx"; +import { formatMillis } from "@/shared/utils/time.js"; +import styles from "@/features/activity-templates/activity-templates.module.css"; + +export function ActivityTemplateVersionsDrawer({ + abilities, + data, + error, + loading, + onClone, + onClose, + onPageChange, + onPreview, + onRetry, + open, + page, + source, +}) { + const columns = [ + { + key: "version", + label: "版本", + width: "90px", + render: (version) => v{version.versionNo}, + }, + { + key: "published", + label: "发布时间 / 管理员", + width: "minmax(190px, .85fr)", + render: (version) => ( +
+ {formatMillis(version.publishedAtMs)} + 管理员 #{version.publishedByAdminId || "-"} +
+ ), + }, + { + key: "runtime", + label: "实际运行窗口", + width: "minmax(210px, .95fr)", + render: (version) => ( +
+ {formatMillis(version.runtimeFromMs || version.snapshot.startMs)} + 至 {formatRuntimeEnd(version)} +
+ ), + }, + { + key: "config", + label: "配置规模", + width: "minmax(150px, .7fr)", + render: (version) => ( +
+ + {version.snapshot.gifts.length} 礼物 · {version.snapshot.tasks.length} 任务 + + {version.snapshot.rankRewards.length} 个奖励区间 +
+ ), + }, + { + key: "actions", + label: "操作", + width: "104px", + render: (version) => ( + + onPreview(version)}> + + + {abilities.canCreate ? ( + onClone(version)}> + + + ) : null} + + ), + }, + ]; + const pageCount = Math.max(1, Math.ceil(Number(data.total || 0) / Number(data.pageSize || 20))); + + return ( + + + `${version.templateId}-${version.versionNo}`} + total={data.total || 0} + /> + {pageCount > 1 ? ( +
+ onPageChange(nextPage)} + /> +
+ ) : null} +
+
+ ); +} + +function formatRuntimeEnd(version) { + const runtimeToMs = Number(version.runtimeToMs || 0); + if (runtimeToMs > 0) return formatMillis(runtimeToMs); + return `未关闭 · 排期至 ${formatMillis(version.snapshot.endMs)}`; +} diff --git a/src/features/activity-templates/components/BasicAndContentSections.jsx b/src/features/activity-templates/components/BasicAndContentSections.jsx new file mode 100644 index 0000000..4bf723a --- /dev/null +++ b/src/features/activity-templates/components/BasicAndContentSections.jsx @@ -0,0 +1,227 @@ +import MenuItem from "@mui/material/MenuItem"; +import TextField from "@mui/material/TextField"; +import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; +import { JsonEditorField } from "@/shared/ui/JsonEditorField.jsx"; +import { MultiValueAutocomplete } from "@/shared/ui/MultiValueAutocomplete.jsx"; +import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx"; +import styles from "@/features/activity-templates/activity-templates.module.css"; +import { parseDisplayConfig } from "@/features/activity-templates/model.js"; + +export function BasicSettingsSection({ disabled, form, loadingRegions, regionOptions, updateField }) { + const regionLabels = Object.fromEntries(regionOptions.map((option) => [option.value, option.label])); + return ( + +
+ updateField("templateCode", normalizeCode(event.target.value))} + /> + updateField("name", event.target.value)} + /> + +
+
+ updateField("titleEn", event.target.value)} + /> + updateField("titleAr", event.target.value)} + /> +
+
+
+ 活动时间 + UTC 毫秒存储,结束时间不包含在活动周期内 +
+ updateField("timeRange", value)} + /> +
+
+
+ 投放全部区域 + 开启后不再提交指定区域 +
+ { + updateField("allRegions", event.target.checked); + if (event.target.checked) updateField("regionIds", []); + }} + /> +
+ {!form.allRegions ? ( + regionLabels[String(value)] || `区域 #${value}`} + label="指定投放区域" + labels={regionLabels} + loading={loadingRegions} + options={regionOptions.map((option) => option.value)} + value={form.regionIds} + onChange={(value) => updateField("regionIds", value)} + /> + ) : null} +
+ ); +} + +export function RulesSettingsSection({ disabled, form, updateField }) { + return ( + +
+ updateField("rulesEn", event.target.value)} + /> + updateField("rulesAr", event.target.value)} + /> +
+
+ ); +} + +export function DisplayConfigSection({ disabled, form, updateField }) { + const config = parseDisplayConfig(form.displayConfigJson); + const updateConfig = (path, value) => { + const next = structuredClone(config); + let target = next; + path.slice(0, -1).forEach((key) => { + target[key] = target[key] && typeof target[key] === "object" ? target[key] : {}; + target = target[key]; + }); + target[path[path.length - 1]] = value; + updateField("displayConfigJson", JSON.stringify(next, null, 2)); + }; + + return ( + +
+ updateConfig(["default_tab"], event.target.value)} + > + 福利任务 + 荣誉榜单 + + updateConfig(["leaderboard", "default_board"], event.target.value)} + > + 日榜 + 总榜 + + updateConfig(["leaderboard", "page_size"], Number(event.target.value))} + /> + updateConfig(["leaderboard", "score_display_cap"], Number(event.target.value))} + /> + updateConfig(["leaderboard", "unranked_label"], event.target.value)} + /> + updateConfig(["gifts", "interval_ms"], Number(event.target.value))} + /> +
+
+
+ 礼物自动轮播 + 礼物超出客户端容器宽度时自动滚动 +
+ updateConfig(["gifts", "auto_scroll"], event.target.checked)} + /> +
+ updateField("displayConfigJson", value)} + /> +
+ ); +} + +export function EditorSection({ actions, children, title }) { + return ( +
+
+

{title}

+ {actions ?
{actions}
: null} +
+
{children}
+
+ ); +} + +function normalizeCode(value) { + return String(value || "") + .toLowerCase() + .replace(/[^a-z0-9_-]/g, ""); +} diff --git a/src/features/activity-templates/components/GiftTaskSections.jsx b/src/features/activity-templates/components/GiftTaskSections.jsx new file mode 100644 index 0000000..aed2471 --- /dev/null +++ b/src/features/activity-templates/components/GiftTaskSections.jsx @@ -0,0 +1,235 @@ +import AddOutlined from "@mui/icons-material/AddOutlined"; +import ArrowDownwardOutlined from "@mui/icons-material/ArrowDownwardOutlined"; +import ArrowUpwardOutlined from "@mui/icons-material/ArrowUpwardOutlined"; +import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined"; +import IconButton from "@mui/material/IconButton"; +import MenuItem from "@mui/material/MenuItem"; +import TextField from "@mui/material/TextField"; +import Tooltip from "@mui/material/Tooltip"; +import { GiftPreviewSelectField } from "@/features/resources/components/GiftSelectDrawer.jsx"; +import { ResourceGroupSelectField } from "@/shared/ui/ResourceGroupSelectDrawer.jsx"; +import { Button } from "@/shared/ui/Button.jsx"; +import styles from "@/features/activity-templates/activity-templates.module.css"; +import { TASK_TYPE_OPTIONS } from "@/features/activity-templates/constants.js"; +import { createNextActivityTask, giftDtoFromForm, giftFormValue } from "@/features/activity-templates/model.js"; +import { EditorSection } from "@/features/activity-templates/components/BasicAndContentSections.jsx"; + +export function GiftSettingsSection({ disabled, form, gifts, giftsLoading, updateField }) { + const updateGift = (index, nextGift) => { + updateField( + "gifts", + form.gifts.map((gift, giftIndex) => (giftIndex === index ? nextGift : gift)), + ); + }; + const removeGift = (index) => + updateField( + "gifts", + form.gifts.filter((_, giftIndex) => giftIndex !== index), + ); + const moveGift = (index, offset) => updateField("gifts", moveItem(form.gifts, index, index + offset)); + const addGift = () => { + if (form.gifts.length >= 20) return; + updateField("gifts", [...form.gifts, { coinPrice: "", giftId: "", iconUrl: "", name: "", priceVersion: "" }]); + }; + + return ( + = 20} + startIcon={} + onClick={addGift} + > + 添加礼物 + + } + title="活动礼物" + > +
已配置 {form.gifts.length} / 20
+
+ {form.gifts.map((gift, index) => ( +
+ {index + 1} + updateGift(index, giftFormValue(selectedGift))} + /> +
+ moveGift(index, -1)} + > + + + moveGift(index, 1)} + > + + + removeGift(index)}> + + +
+
+ ))} + {!form.gifts.length ?
当前无数据
: null} +
+
+ ); +} + +export function TaskSettingsSection({ disabled, form, resourceGroups, updateField }) { + const updateTask = (index, patch) => { + updateField( + "tasks", + form.tasks.map((task, taskIndex) => (taskIndex === index ? { ...task, ...patch } : task)), + ); + }; + const removeTask = (index) => + updateField( + "tasks", + form.tasks.filter((_, taskIndex) => taskIndex !== index), + ); + const moveTask = (index, offset) => updateField("tasks", moveItem(form.tasks, index, index + offset)); + const addTask = () => { + if (form.tasks.length >= 30) return; + updateField("tasks", [...form.tasks, createNextActivityTask(form.tasks, "gift_count")]); + }; + + return ( + = 30} + startIcon={} + onClick={addTask} + > + 添加任务 + + } + title="每日任务" + > +
每日 00:00 UTC 重置 · 已配置 {form.tasks.length} / 30
+
+
+ 排序 + 任务键 + 任务类型 + 目标值 + 奖励资源组 + 操作 +
+ {form.tasks.map((task, index) => ( +
+ {index + 1} + updateTask(index, { taskKey: normalizeKey(event.target.value) })} + /> + + updateTask(index, { + targetValue: event.target.value === "daily_visit" ? "1" : task.targetValue, + taskType: event.target.value, + }) + } + > + {TASK_TYPE_OPTIONS.map(([value, label]) => ( + item.taskType === "daily_visit") + } + key={value} + value={value} + > + {label} + + ))} + + updateTask(index, { targetValue: event.target.value })} + /> + updateTask(index, { rewardResourceGroupId: value })} + /> +
+ moveTask(index, -1)} + > + + + moveTask(index, 1)} + > + + + removeTask(index)}> + + +
+
+ ))} + {!form.tasks.length ?
当前无数据
: null} +
+
+ ); +} + +function RowIconButton({ children, disabled, label, onClick }) { + return ( + + + + {children} + + + + ); +} + +function moveItem(items, fromIndex, toIndex) { + if (toIndex < 0 || toIndex >= items.length) return items; + const next = [...items]; + const [item] = next.splice(fromIndex, 1); + next.splice(toIndex, 0, item); + return next; +} + +function normalizeKey(value) { + return String(value || "") + .toLowerCase() + .replace(/[^a-z0-9_-]/g, ""); +} diff --git a/src/features/activity-templates/components/RankingAssetsSections.jsx b/src/features/activity-templates/components/RankingAssetsSections.jsx new file mode 100644 index 0000000..ce02e20 --- /dev/null +++ b/src/features/activity-templates/components/RankingAssetsSections.jsx @@ -0,0 +1,169 @@ +import AddOutlined from "@mui/icons-material/AddOutlined"; +import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined"; +import IconButton from "@mui/material/IconButton"; +import TextField from "@mui/material/TextField"; +import Tooltip from "@mui/material/Tooltip"; +import { Button } from "@/shared/ui/Button.jsx"; +import { ResourceGroupSelectField } from "@/shared/ui/ResourceGroupSelectDrawer.jsx"; +import { UploadField } from "@/shared/ui/UploadField.jsx"; +import styles from "@/features/activity-templates/activity-templates.module.css"; +import { createRankReward, mediaTypeFromURL } from "@/features/activity-templates/model.js"; +import { EditorSection } from "@/features/activity-templates/components/BasicAndContentSections.jsx"; + +export function RankingSettingsSection({ disabled, form, resourceGroups, updateField }) { + return ( + + updateField("dailyLeaderboardSize", value)} + onRewardsChange={(value) => updateField("dailyRankRewards", value)} + /> + updateField("totalLeaderboardSize", value)} + onRewardsChange={(value) => updateField("totalRankRewards", value)} + /> + + ); +} + +export function AssetsSettingsSection({ disabled, form, updateField }) { + const updateAsset = (index, url) => { + updateField( + "assets", + form.assets.map((asset, assetIndex) => + assetIndex === index ? { ...asset, mediaType: mediaTypeFromURL(url), url } : asset, + ), + ); + }; + + return ( + +
+ {form.assets.map((asset, index) => ( +
+ updateAsset(index, url)} + /> +
+ {asset.assetKey} + {asset.locale === "*" ? "共享" : asset.locale.toUpperCase()} +
+
+ ))} +
+
+ ); +} + +function RankBoardEditor({ + boardLabel, + disabled, + leaderboardSize, + onLeaderboardSizeChange, + onRewardsChange, + resourceGroups, + rewards, +}) { + const updateReward = (index, patch) => { + onRewardsChange( + rewards.map((reward, rewardIndex) => (rewardIndex === index ? { ...reward, ...patch } : reward)), + ); + }; + const addReward = () => { + const lastRank = rewards.reduce((max, reward) => Math.max(max, Number(reward.rankTo || 0)), 0) + 1; + if (lastRank > Number(leaderboardSize || 0)) return; + onRewardsChange([...rewards, createRankReward(lastRank)]); + }; + const removeReward = (index) => onRewardsChange(rewards.filter((_, rewardIndex) => rewardIndex !== index)); + + return ( +
+
+
+ {boardLabel} + {boardLabel === "日榜" ? "每日 00:00 UTC 结算" : "活动结束结算"} +
+
+ onLeaderboardSizeChange(event.target.value)} + /> + +
+
+
+
+ 起始名次 + 结束名次 + 奖励资源组 + 操作 +
+ {rewards.map((reward, index) => ( +
+ updateReward(index, { rankFrom: event.target.value })} + /> + updateReward(index, { rankTo: event.target.value })} + /> + updateReward(index, { resourceGroupId: value })} + /> + + + removeReward(index)} + > + + + + +
+ ))} + {!rewards.length ?
当前无数据
: null} +
+
+ ); +} diff --git a/src/features/activity-templates/constants.js b/src/features/activity-templates/constants.js new file mode 100644 index 0000000..49b257e --- /dev/null +++ b/src/features/activity-templates/constants.js @@ -0,0 +1,58 @@ +export const ACTIVITY_TEMPLATE_STATUS_OPTIONS = [ + ["", "全部状态"], + ["draft", "草稿"], + ["scheduled", "待开始"], + ["ongoing", "进行中"], + ["ended", "已结束"], + ["disabled", "已停用"], + ["archived", "已归档"], +]; + +export const TASK_TYPE_OPTIONS = [ + ["daily_visit", "每日进入活动"], + ["gift_count", "累计送活动礼物"], + ["gift_coin_amount", "累计送礼金币"], +]; + +export const EDITOR_SECTIONS = [ + { key: "basic", label: "基础与投放" }, + { key: "gifts", label: "活动礼物" }, + { key: "tasks", label: "每日任务" }, + { key: "ranking", label: "榜单奖励" }, + { key: "rules", label: "规则文案" }, + { key: "assets", label: "页面素材" }, + { key: "display", label: "展示配置" }, +]; + +export const ASSET_SLOTS = [ + { assetKey: "hero", label: "英语头图", locale: "en" }, + { assetKey: "hero", label: "阿语头图", locale: "ar" }, + { assetKey: "page_background", label: "页面背景", locale: "*" }, + { assetKey: "countdown_background", label: "倒计时背景", locale: "*" }, + { assetKey: "gift_panel_background", label: "礼物区背景", locale: "*" }, + { assetKey: "gift_selected", label: "礼物选中态", locale: "*" }, + { assetKey: "gift_unselected", label: "礼物未选中态", locale: "*" }, + { assetKey: "task_panel_background", label: "任务区背景", locale: "*" }, + { assetKey: "rank_background", label: "榜单背景", locale: "*" }, + { assetKey: "top1_frame", label: "Top1 头像装饰", locale: "*" }, + { assetKey: "top2_frame", label: "Top2 头像装饰", locale: "*" }, + { assetKey: "top3_frame", label: "Top3 头像装饰", locale: "*" }, +]; + +export const DEFAULT_DISPLAY_CONFIG = { + schema_version: 1, + default_tab: "tasks", + countdown: { style: "blocks" }, + gifts: { auto_scroll: true, interval_ms: 3000 }, + leaderboard: { default_board: "daily", page_size: 20, score_display_cap: 999999999, unranked_label: "100+" }, + rtl_locales: ["ar"], +}; + +export function activityTemplateStatusLabel(value) { + const status = String(value || "").toLowerCase(); + return ACTIVITY_TEMPLATE_STATUS_OPTIONS.find(([key]) => key === status)?.[1] || status || "-"; +} + +export function taskTypeLabel(value) { + return TASK_TYPE_OPTIONS.find(([key]) => key === value)?.[1] || value || "-"; +} diff --git a/src/features/activity-templates/dataModel.js b/src/features/activity-templates/dataModel.js new file mode 100644 index 0000000..b739bd6 --- /dev/null +++ b/src/features/activity-templates/dataModel.js @@ -0,0 +1,193 @@ +const DAY_MS = 24 * 60 * 60 * 1000; +const DEFAULT_WINDOW_DAYS = 30; +const numberFormatter = new Intl.NumberFormat("zh-CN"); + +export function activityTemplateForRuntime(version, fallbackTemplate = {}) { + const snapshot = version?.snapshot || fallbackTemplate || {}; + const runtimeFromMs = positiveNumber(version?.runtimeFromMs); + const runtimeToMs = positiveNumber(version?.runtimeToMs); + const plannedEndMs = positiveNumber(snapshot?.endMs); + + // 发布版本的计划窗口不会因提前停用而变化;数据中心必须以 owner 记录的实际运行窗口查询, + // 否则历史版本会把未运行日期纳入趋势和日榜。尚在运行的版本 runtimeToMs=0,仍回退计划结束时间。 + return { + ...snapshot, + startMs: runtimeFromMs || positiveNumber(snapshot?.startMs), + // 自然结束后管理员再执行停用时,runtimeToMs 可能是命令时间;统计窗口仍不能越过计划结束边界。 + endMs: runtimeToMs && plannedEndMs ? Math.min(runtimeToMs, plannedEndMs) : runtimeToMs || plannedEndMs, + }; +} + +export function createActivityDataInitialRange(template, now = Date.now()) { + const configuredStart = positiveNumber(template?.startMs) || now - (DEFAULT_WINDOW_DAYS - 1) * DAY_MS; + const configuredEnd = positiveNumber(template?.endMs) || now; + const rawEnd = + now <= configuredStart + ? Math.min(configuredEnd, configuredStart + DEFAULT_WINDOW_DAYS * DAY_MS) + : Math.min(configuredEnd, now); + const safeEnd = rawEnd > configuredStart ? rawEnd : Math.min(configuredEnd, configuredStart + DAY_MS); + const endExclusive = utcDayCeil(safeEnd); + const startInclusive = Math.max(utcDayFloor(configuredStart), endExclusive - DEFAULT_WINDOW_DAYS * DAY_MS); + + // TimeRangeFilter(withTime=false) 按浏览器本地日历渲染。把统计侧 UTC 日键投影为 + // 同名的本地日期,避免 UTC 零点在 UTC+8 显示成上午 08:00 后再被选择器截到错误日期。 + return { + startMs: utcDayAsLocalCalendarMillis(startInclusive, false), + endMs: utcDayAsLocalCalendarMillis(endExclusive - DAY_MS, true), + }; +} + +export function canOpenActivityTemplateData(item, abilities) { + return Boolean(abilities?.canData && Number(item?.publishedVersion || 0) > 0); +} + +export function normalizeActivityDataUtcRange(range) { + const startMs = positiveNumber(range?.startMs); + const endMs = positiveNumber(range?.endMs); + if (!startMs || !endMs || endMs <= startMs) return null; + // 选择器值代表用户看到的本地日历年月日,而不是 epoch 所落的 UTC 日。 + // 上海 Jul 2 00:00 的 epoch 位于 Jul 1 UTC;必须取本地 Y/M/D 再构造 Jul 2 00:00Z。 + const startDate = new Date(startMs); + const endDate = new Date(endMs); + const normalized = { + start_ms: Date.UTC(startDate.getFullYear(), startDate.getMonth(), startDate.getDate()), + end_ms: Date.UTC(endDate.getFullYear(), endDate.getMonth(), endDate.getDate() + 1), + }; + return normalized.end_ms > normalized.start_ms ? normalized : null; +} + +export function activityDailyPeriodOptions(template, now = Date.now()) { + const startMs = utcDayFloor(positiveNumber(template?.startMs) || now); + const configuredEnd = positiveNumber(template?.endMs) || now; + const endMs = utcDayCeil(configuredEnd); + const options = []; + for (let dayMs = startMs; dayMs < endMs && options.length < 366; dayMs += DAY_MS) { + options.push(utcDayKey(dayMs)); + } + return options.reverse(); +} + +export function defaultActivityDailyPeriod(template, now = Date.now()) { + const options = activityDailyPeriodOptions(template, now); + const today = utcDayKey(now); + return options.find((item) => item <= today) || options.at(-1) || today; +} + +export function createActivityTemplateDataCsv(data) { + const overview = data?.overview || {}; + const lines = [ + ["活动概览"], + ["指标", "数值"], + ["参与用户", overview.participants], + ["访问人次", overview.visitCount], + ["送礼用户", overview.giftUsers], + ["任务用户", overview.taskUsers], + ["送礼事件", overview.giftEventCount], + ["礼物数量", overview.giftCount], + ["金币金额", overview.coinAmount], + ["任务进度", overview.taskProgress], + ["任务完成", overview.taskCompleted], + ["任务领奖", overview.taskClaimed], + [], + ["每日趋势"], + [ + "UTC 日期", + "参与用户", + "访问人次", + "送礼用户", + "任务用户", + "送礼事件", + "礼物数量", + "金币金额", + "任务进度", + "任务完成", + "任务领奖", + ], + ...(data?.trend || []).map((item) => [ + item.statDay, + item.participants, + item.visitCount, + item.giftUsers, + item.taskUsers, + item.giftEventCount, + item.giftCount, + item.coinAmount, + item.taskProgress, + item.taskCompleted, + item.taskClaimed, + ]), + [], + ["礼物排行"], + ["礼物 ID", "送礼用户", "送礼事件", "礼物数量", "金币金额"], + ...(data?.gifts || []).map((item) => [ + item.giftId, + item.giftUsers, + item.giftEventCount, + item.giftCount, + item.coinAmount, + ]), + [], + ["任务排行"], + ["任务 Key", "任务类型", "任务用户", "累计进度", "完成人次", "领奖人次"], + ...(data?.tasks || []).map((item) => [ + item.taskKey, + item.taskType, + item.taskUsers, + item.taskProgress, + item.taskCompleted, + item.taskClaimed, + ]), + ]; + return lines.map((row) => row.map(csvCell).join(",")).join("\n"); +} + +export function downloadActivityTemplateDataCsv(data, filename) { + const blob = new Blob([`\uFEFF${createActivityTemplateDataCsv(data)}`], { type: "text/csv;charset=utf-8" }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + link.click(); + window.URL.revokeObjectURL(url); +} + +export function formatActivityMetric(value) { + return numberFormatter.format(Number(value) || 0); +} + +function csvCell(value) { + const text = value === undefined || value === null ? "" : String(value); + return /[",\n\r]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text; +} + +function positiveNumber(value) { + const number = Number(value); + return Number.isFinite(number) && number > 0 ? number : 0; +} + +function utcDayFloor(value) { + const date = new Date(value); + return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()); +} + +function utcDayCeil(value) { + const floor = utcDayFloor(value); + return floor === value ? floor : floor + DAY_MS; +} + +function utcDayKey(value) { + return new Date(utcDayFloor(value)).toISOString().slice(0, 10); +} + +function utcDayAsLocalCalendarMillis(value, endOfDay) { + const utcDate = new Date(value); + return new Date( + utcDate.getUTCFullYear(), + utcDate.getUTCMonth(), + utcDate.getUTCDate(), + endOfDay ? 23 : 0, + endOfDay ? 59 : 0, + endOfDay ? 59 : 0, + endOfDay ? 999 : 0, + ).getTime(); +} diff --git a/src/features/activity-templates/dataModel.test.js b/src/features/activity-templates/dataModel.test.js new file mode 100644 index 0000000..cdf7f59 --- /dev/null +++ b/src/features/activity-templates/dataModel.test.js @@ -0,0 +1,145 @@ +import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; +import { + activityDailyPeriodOptions, + activityTemplateForRuntime, + canOpenActivityTemplateData, + createActivityDataInitialRange, + createActivityTemplateDataCsv, + defaultActivityDailyPeriod, + downloadActivityTemplateDataCsv, + normalizeActivityDataUtcRange, +} from "@/features/activity-templates/dataModel.js"; + +describe("activity template data model", () => { + const originalTimeZone = globalThis.process.env.TZ; + + beforeAll(() => { + globalThis.process.env.TZ = "Asia/Shanghai"; + }); + + afterAll(() => { + if (originalTimeZone) globalThis.process.env.TZ = originalTimeZone; + else delete globalThis.process.env.TZ; + }); + + test("only exposes data center for a published version with data permission", () => { + expect(canOpenActivityTemplateData({ publishedVersion: 1 }, { canData: true })).toBe(true); + expect(canOpenActivityTemplateData({ publishedVersion: 0 }, { canData: true })).toBe(false); + expect(canOpenActivityTemplateData({ publishedVersion: 2 }, { canData: false })).toBe(false); + }); + + test("maps an Asia/Shanghai three-day calendar selection to the same three UTC day keys", () => { + const range = normalizeActivityDataUtcRange({ + startMs: Date.parse("2026-07-02T00:00:00+08:00"), + endMs: Date.parse("2026-07-04T23:59:59.999+08:00"), + }); + + expect(range).toEqual({ + start_ms: Date.parse("2026-07-02T00:00:00Z"), + end_ms: Date.parse("2026-07-05T00:00:00Z"), + }); + }); + + test("defaults to the latest thirty-day activity window and builds UTC periods", () => { + const template = { + startMs: Date.parse("2026-01-01T00:00:00Z"), + endMs: Date.parse("2026-03-01T00:00:00Z"), + }; + const now = Date.parse("2026-02-20T12:00:00Z"); + const range = createActivityDataInitialRange(template, now); + const normalizedRange = normalizeActivityDataUtcRange(range); + + expect(normalizedRange.end_ms - normalizedRange.start_ms).toBe(30 * 24 * 60 * 60 * 1000); + expect(new Date(normalizedRange.end_ms).toISOString()).toBe("2026-02-21T00:00:00.000Z"); + expect(defaultActivityDailyPeriod(template, now)).toBe("2026-02-20"); + expect(activityDailyPeriodOptions(template, now)[0]).toBe("2026-02-28"); + }); + + test("initializes an exact UTC end boundary with the preceding calendar day", () => { + const template = { + startMs: Date.parse("2026-07-02T12:00:00Z"), + endMs: Date.parse("2026-07-05T00:00:00Z"), + }; + const range = createActivityDataInitialRange(template, Date.parse("2026-07-10T00:00:00Z")); + + expect(normalizeActivityDataUtcRange(range)).toEqual({ + start_ms: Date.parse("2026-07-02T00:00:00Z"), + end_ms: Date.parse("2026-07-05T00:00:00Z"), + }); + }); + + test("uses the immutable version runtime window for historical data and daily periods", () => { + const template = activityTemplateForRuntime({ + runtimeFromMs: Date.parse("2026-01-15T12:00:00Z"), + runtimeToMs: Date.parse("2026-02-01T00:00:00Z"), + snapshot: { + startMs: Date.parse("2026-01-01T00:00:00Z"), + endMs: Date.parse("2026-03-01T00:00:00Z"), + }, + }); + + expect(normalizeActivityDataUtcRange(createActivityDataInitialRange(template, Date.parse("2026-04-01T00:00:00Z")))).toEqual({ + start_ms: Date.parse("2026-01-15T00:00:00Z"), + end_ms: Date.parse("2026-02-01T00:00:00Z"), + }); + expect(activityDailyPeriodOptions(template)[0]).toBe("2026-01-31"); + }); + + test("keeps the planned end while the selected version is still running", () => { + const template = activityTemplateForRuntime({ + runtimeFromMs: Date.parse("2026-07-10T08:00:00Z"), + runtimeToMs: 0, + snapshot: { + startMs: Date.parse("2026-07-01T00:00:00Z"), + endMs: Date.parse("2026-07-31T00:00:00Z"), + }, + }); + + expect(template.startMs).toBe(Date.parse("2026-07-10T08:00:00Z")); + expect(template.endMs).toBe(Date.parse("2026-07-31T00:00:00Z")); + }); + + test("does not extend statistics past the planned end when a naturally ended version is disabled later", () => { + const template = activityTemplateForRuntime({ + runtimeFromMs: Date.parse("2026-07-01T00:00:00Z"), + runtimeToMs: Date.parse("2026-08-03T09:00:00Z"), + snapshot: { + startMs: Date.parse("2026-07-01T00:00:00Z"), + endMs: Date.parse("2026-08-01T00:00:00Z"), + }, + }); + + expect(template.endMs).toBe(Date.parse("2026-08-01T00:00:00Z")); + }); + + test("creates a sectioned CSV from the actual aggregate response", () => { + const csv = createActivityTemplateDataCsv({ + overview: { participants: 2, visitCount: 3, giftUsers: 1 }, + trend: [{ statDay: "2026-07-01", participants: 2, visitCount: 3 }], + gifts: [{ giftId: 'gift,"1"', giftUsers: 1 }], + tasks: [{ taskKey: "visit", taskType: "daily_visit", taskUsers: 2 }], + }); + + expect(csv).toContain("活动概览"); + expect(csv).toContain("每日趋势"); + expect(csv).toContain("访问人次,3"); + expect(csv).toContain('"gift,""1"""'); + expect(csv).toContain("visit,daily_visit,2"); + }); + + test("downloads CSV with a BOM for spreadsheet compatibility", () => { + const click = vi.fn(); + const originalCreateElement = document.createElement.bind(document); + vi.spyOn(document, "createElement").mockImplementation((tagName, options) => { + if (tagName === "a") return { click }; + return originalCreateElement(tagName, options); + }); + vi.spyOn(window.URL, "createObjectURL").mockReturnValue("blob:data"); + vi.spyOn(window.URL, "revokeObjectURL").mockImplementation(() => {}); + + downloadActivityTemplateDataCsv({ overview: {}, trend: [], gifts: [], tasks: [] }, "activity.csv"); + + expect(click).toHaveBeenCalledOnce(); + expect(window.URL.revokeObjectURL).toHaveBeenCalledWith("blob:data"); + }); +}); diff --git a/src/features/activity-templates/hooks/useActivityTemplateDataCenter.js b/src/features/activity-templates/hooks/useActivityTemplateDataCenter.js new file mode 100644 index 0000000..1041f41 --- /dev/null +++ b/src/features/activity-templates/hooks/useActivityTemplateDataCenter.js @@ -0,0 +1,449 @@ +import { useCallback, useDeferredValue, useEffect, useMemo, useState } from "react"; +import { useAppScope } from "@/app/app-scope/AppScopeProvider.jsx"; +import { + exportActivityTemplateData, + getActivityTemplateData, + listActivityTemplateLeaderboard, + listActivityTemplateRewardJobs, + listActivityTemplateTaskClaims, + listActivityTemplateVersions, + retryActivityTemplateRewardJob, + retryActivityTemplateTaskClaim, +} from "@/features/activity-templates/api"; +import { + activityDailyPeriodOptions, + activityTemplateForRuntime, + createActivityDataInitialRange, + defaultActivityDailyPeriod, + downloadActivityTemplateDataCsv, + normalizeActivityDataUtcRange, +} from "@/features/activity-templates/dataModel.js"; +import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js"; +import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx"; +import { useToast } from "@/shared/ui/ToastProvider.jsx"; + +const PAGE_SIZE = 20; +const VERSION_PAGE_SIZE = 100; +const VERSION_LIMIT = 1000; +const emptyVersions = { items: [], page: 1, pageSize: VERSION_PAGE_SIZE, total: 0 }; +const emptyData = { + overview: {}, + trend: [], + gifts: [], + tasks: [], + granularity: "day", + timezone: "UTC", + startMs: 0, + endMs: 0, + regionId: 0, +}; +const emptyLeaderboard = { items: [], page: 1, pageSize: PAGE_SIZE, period: {}, total: 0 }; +const emptyRewards = { items: [], page: 1, pageSize: PAGE_SIZE, total: 0 }; +const emptyTaskClaims = { items: [], page: 1, pageSize: PAGE_SIZE, total: 0 }; + +export function useActivityTemplateDataCenter({ abilities, regionOptions, source }) { + const { appCode } = useAppScope(); + const confirm = useConfirm(); + const { showToast } = useToast(); + const [activeTab, setActiveTab] = useState("overview"); + const [versionNo, setVersionNo] = useState(0); + const [regionId, setRegionId] = useState(""); + const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" }); + const [leaderboardBoardType, setLeaderboardBoardType] = useState("daily"); + const [leaderboardPeriodKey, setLeaderboardPeriodKey] = useState(""); + const [leaderboardPage, setLeaderboardPage] = useState(1); + const [rewardBoardType, setRewardBoardType] = useState(""); + const [rewardPeriodKey, setRewardPeriodKey] = useState(""); + const [rewardStatus, setRewardStatus] = useState(""); + const [rewardPage, setRewardPage] = useState(1); + const [taskClaimDay, setTaskClaimDay] = useState(""); + const [taskClaimKey, setTaskClaimKey] = useState(""); + const [taskClaimUserId, setTaskClaimUserId] = useState(""); + const [taskClaimStatus, setTaskClaimStatus] = useState(""); + const [taskClaimPage, setTaskClaimPage] = useState(1); + const [loadingAction, setLoadingAction] = useState(""); + const deferredTaskClaimUserId = useDeferredValue(taskClaimUserId); + + useEffect(() => { + if (!source?.templateId) return; + const nextVersion = Number(source.publishedVersion || 0); + setActiveTab("overview"); + setVersionNo(nextVersion); + setRegionId(""); + setTimeRange(createActivityDataInitialRange(source)); + setLeaderboardBoardType("daily"); + setLeaderboardPeriodKey(defaultActivityDailyPeriod(source)); + setLeaderboardPage(1); + setRewardBoardType(""); + setRewardPeriodKey(""); + setRewardStatus(""); + setRewardPage(1); + setTaskClaimDay(""); + setTaskClaimKey(""); + setTaskClaimUserId(""); + setTaskClaimStatus(""); + setTaskClaimPage(1); + setLoadingAction(""); + }, [source]); + + const versionsQueryFn = useCallback(() => loadAllVersions(source?.templateId), [source?.templateId]); + const { + data: versions = emptyVersions, + error: versionsError, + loading: versionsLoading, + reload: reloadVersions, + } = useAdminQuery(versionsQueryFn, { + enabled: Boolean(source?.templateId), + errorMessage: "加载发布版本失败", + initialData: emptyVersions, + queryKey: ["activity-template-data-versions", appCode, source?.templateId], + staleTime: 60 * 1000, + }); + + const versionItems = useMemo(() => { + const items = [...(versions.items || [])]; + const currentVersion = Number(source?.publishedVersion || 0); + if (currentVersion > 0 && !items.some((item) => Number(item.versionNo) === currentVersion)) { + items.push({ snapshot: source, templateId: source.templateId, versionNo: currentVersion }); + } + return items.sort((left, right) => Number(right.versionNo) - Number(left.versionNo)); + }, [source, versions.items]); + const selectedVersion = useMemo( + () => versionItems.find((item) => Number(item.versionNo) === Number(versionNo)), + [versionItems, versionNo], + ); + const selectedTemplate = useMemo( + () => activityTemplateForRuntime(selectedVersion, source), + [selectedVersion, source], + ); + const selectedRuntimeStartMs = selectedTemplate.startMs; + const selectedRuntimeEndMs = selectedTemplate.endMs; + useEffect(() => { + if (!source?.templateId || versionNo <= 0) return; + if (!Number(selectedVersion?.runtimeFromMs || 0) && !Number(selectedVersion?.runtimeToMs || 0)) return; + // 版本列表异步返回后再把初始筛选从计划窗口校准到实际运行窗口;依赖只含窗口原值, + // 用户随后手动调整时间不会被后台 refetch 或对象引用变化覆盖。 + const runtimeWindow = { endMs: selectedRuntimeEndMs, startMs: selectedRuntimeStartMs }; + setTimeRange(createActivityDataInitialRange(runtimeWindow)); + setLeaderboardPeriodKey(defaultActivityDailyPeriod(runtimeWindow)); + setLeaderboardPage(1); + }, [ + selectedRuntimeEndMs, + selectedRuntimeStartMs, + selectedVersion?.runtimeFromMs, + selectedVersion?.runtimeToMs, + source?.templateId, + versionNo, + ]); + const periodOptions = useMemo(() => activityDailyPeriodOptions(selectedTemplate), [selectedTemplate]); + const scopedRegionOptions = useMemo(() => { + if (selectedTemplate.allRegions) return regionOptions; + const allowed = new Set((selectedTemplate.regionIds || []).map(String)); + return regionOptions.filter((item) => allowed.has(String(item.value))); + }, [regionOptions, selectedTemplate.allRegions, selectedTemplate.regionIds]); + + const normalizedRange = useMemo(() => normalizeActivityDataUtcRange(timeRange), [timeRange]); + const dataParams = useMemo( + () => + normalizedRange && versionNo > 0 + ? { + ...normalizedRange, + region_id: regionId || undefined, + version_no: versionNo, + } + : null, + [normalizedRange, regionId, versionNo], + ); + const dataQueryFn = useCallback( + () => getActivityTemplateData(source?.templateId, dataParams), + [dataParams, source?.templateId], + ); + const { + data = emptyData, + error: dataError, + loading: dataLoading, + reload: reloadData, + } = useAdminQuery(dataQueryFn, { + enabled: Boolean(source?.templateId && abilities.canData && dataParams), + errorMessage: "加载活动统计失败", + initialData: emptyData, + keepPreviousData: true, + queryKey: ["activity-template-data", appCode, source?.templateId, dataParams], + }); + + const leaderboardParams = useMemo( + () => ({ + board_type: leaderboardBoardType, + page: leaderboardPage, + page_size: PAGE_SIZE, + period_key: leaderboardBoardType === "total" ? "total" : leaderboardPeriodKey, + version_no: versionNo, + }), + [leaderboardBoardType, leaderboardPage, leaderboardPeriodKey, versionNo], + ); + const leaderboardQueryFn = useCallback( + () => listActivityTemplateLeaderboard(source?.templateId, leaderboardParams), + [leaderboardParams, source?.templateId], + ); + const { + data: leaderboard = emptyLeaderboard, + error: leaderboardError, + loading: leaderboardLoading, + reload: reloadLeaderboard, + } = useAdminQuery(leaderboardQueryFn, { + enabled: Boolean( + source?.templateId && + abilities.canData && + versionNo > 0 && + activeTab === "leaderboard" && + (leaderboardBoardType === "total" || leaderboardPeriodKey), + ), + errorMessage: "加载活动榜单失败", + initialData: emptyLeaderboard, + keepPreviousData: true, + queryKey: ["activity-template-leaderboard", appCode, source?.templateId, leaderboardParams], + }); + + const rewardParams = useMemo( + () => ({ + board_type: rewardBoardType || undefined, + page: rewardPage, + page_size: PAGE_SIZE, + period_key: rewardBoardType === "total" ? "total" : rewardPeriodKey || undefined, + status: rewardStatus || undefined, + version_no: versionNo, + }), + [rewardBoardType, rewardPage, rewardPeriodKey, rewardStatus, versionNo], + ); + const rewardQueryFn = useCallback( + () => listActivityTemplateRewardJobs(source?.templateId, rewardParams), + [rewardParams, source?.templateId], + ); + const { + data: rewards = emptyRewards, + error: rewardsError, + loading: rewardsLoading, + reload: reloadRewards, + } = useAdminQuery(rewardQueryFn, { + enabled: Boolean(source?.templateId && abilities.canData && versionNo > 0 && activeTab === "rewards"), + errorMessage: "加载奖励发放记录失败", + initialData: emptyRewards, + keepPreviousData: true, + queryKey: ["activity-template-rewards", appCode, source?.templateId, rewardParams], + }); + + const taskClaimParams = useMemo( + () => ({ + page: taskClaimPage, + page_size: PAGE_SIZE, + status: taskClaimStatus || undefined, + task_day: taskClaimDay || undefined, + task_key: taskClaimKey || undefined, + user_id: deferredTaskClaimUserId || undefined, + version_no: versionNo, + }), + [deferredTaskClaimUserId, taskClaimDay, taskClaimKey, taskClaimPage, taskClaimStatus, versionNo], + ); + const taskClaimsQueryFn = useCallback( + () => listActivityTemplateTaskClaims(source?.templateId, taskClaimParams), + [source?.templateId, taskClaimParams], + ); + const { + data: taskClaims = emptyTaskClaims, + error: taskClaimsError, + loading: taskClaimsLoading, + reload: reloadTaskClaims, + } = useAdminQuery(taskClaimsQueryFn, { + enabled: Boolean(source?.templateId && abilities.canData && versionNo > 0 && activeTab === "taskClaims"), + errorMessage: "加载任务领奖记录失败", + initialData: emptyTaskClaims, + keepPreviousData: true, + queryKey: ["activity-template-task-claims", appCode, source?.templateId, taskClaimParams], + }); + + const changeVersion = (nextVersion) => { + const normalizedVersion = Number(nextVersion); + const nextVersionItem = versionItems.find((item) => Number(item.versionNo) === normalizedVersion); + const nextTemplate = activityTemplateForRuntime(nextVersionItem, source); + setVersionNo(normalizedVersion); + setRegionId(""); + setTimeRange(createActivityDataInitialRange(nextTemplate)); + setLeaderboardPeriodKey(defaultActivityDailyPeriod(nextTemplate)); + setLeaderboardPage(1); + setRewardPeriodKey(""); + setRewardPage(1); + setTaskClaimDay(""); + setTaskClaimKey(""); + setTaskClaimUserId(""); + setTaskClaimPage(1); + }; + const changeLeaderboardBoardType = (value) => { + setLeaderboardBoardType(value); + setLeaderboardPage(1); + if (value === "daily" && !leaderboardPeriodKey) { + setLeaderboardPeriodKey(defaultActivityDailyPeriod(selectedTemplate)); + } + }; + const changeRewardBoardType = (value) => { + setRewardBoardType(value); + setRewardPeriodKey(""); + setRewardPage(1); + }; + const exportData = async () => { + if (!dataParams || !source?.templateId || loadingAction) return; + setLoadingAction("export"); + try { + // 导出端点复用统计服务的受限聚合查询,必须使用它的实际响应生成文件, + // 不能从当前 UI 表格拼接,否则 Top100、UTC 窗口和区域回显可能与后端审计记录不一致。 + const exported = await exportActivityTemplateData(source.templateId, dataParams); + const dateStamp = new Date().toISOString().slice(0, 10); + downloadActivityTemplateDataCsv( + exported, + `activity-template-${source.templateCode || source.templateId}-v${versionNo}-${dateStamp}.csv`, + ); + showToast("活动数据已导出", "success"); + } catch (error) { + showToast(error.message || "导出活动数据失败", "error"); + } finally { + setLoadingAction(""); + } + }; + const retryReward = async (job) => { + if (!source?.templateId || !["failed", "dead"].includes(job.status) || loadingAction) return; + const ok = await confirm({ + confirmText: "重新发放", + message: `奖励任务 ${job.rewardJobId} 将使用原钱包命令幂等重试。`, + title: "重试奖励发放", + }); + if (!ok) return; + setLoadingAction(`retry:${job.rewardJobId}`); + try { + await retryActivityTemplateRewardJob(source.templateId, job.rewardJobId); + showToast("奖励重试已提交", "success"); + await reloadRewards(); + } catch (error) { + showToast(error.message || "奖励重试失败", "error"); + } finally { + setLoadingAction(""); + } + }; + const retryTaskClaim = async (claim) => { + if (!source?.templateId || claim.status !== "failed" || loadingAction) return; + const ok = await confirm({ + confirmText: "重新发放", + message: `任务 ${claim.taskKey} 的领奖记录 ${claim.claimId} 将使用原钱包命令幂等重试。`, + title: "重试任务奖励", + }); + if (!ok) return; + setLoadingAction(`retry-claim:${claim.claimId}`); + try { + await retryActivityTemplateTaskClaim(source.templateId, claim.claimId); + showToast("任务奖励重试已提交", "success"); + await reloadTaskClaims(); + } catch (error) { + showToast(error.message || "任务奖励重试失败", "error"); + } finally { + setLoadingAction(""); + } + }; + + return { + activeTab, + changeLeaderboardBoardType, + changeRewardBoardType, + changeVersion, + data, + dataError, + dataLoading, + dataParams, + exportData, + leaderboard, + leaderboardBoardType, + leaderboardError, + leaderboardLoading, + leaderboardPage, + leaderboardPeriodKey, + loadingAction, + periodOptions, + regionId, + reloadData, + reloadLeaderboard, + reloadRewards, + reloadTaskClaims, + reloadVersions, + retryReward, + retryTaskClaim, + rewardBoardType, + rewardPage, + rewardPeriodKey, + rewards, + rewardsError, + rewardsLoading, + rewardStatus, + scopedRegionOptions, + selectedTemplate, + selectedVersion, + setActiveTab, + setLeaderboardPage, + setLeaderboardPeriodKey: (value) => { + setLeaderboardPeriodKey(value); + setLeaderboardPage(1); + }, + setRegionId, + setRewardPage, + setRewardPeriodKey: (value) => { + setRewardPeriodKey(value); + setRewardPage(1); + }, + setRewardStatus: (value) => { + setRewardStatus(value); + setRewardPage(1); + }, + setTimeRange, + setTaskClaimDay: (value) => { + setTaskClaimDay(value); + setTaskClaimPage(1); + }, + setTaskClaimKey: (value) => { + setTaskClaimKey(value); + setTaskClaimPage(1); + }, + setTaskClaimPage, + setTaskClaimStatus: (value) => { + setTaskClaimStatus(value); + setTaskClaimPage(1); + }, + setTaskClaimUserId: (value) => { + setTaskClaimUserId(String(value || "").replace(/\D/g, "")); + setTaskClaimPage(1); + }, + taskClaimDay, + taskClaimKey, + taskClaimPage, + taskClaims, + taskClaimsError, + taskClaimsLoading, + taskClaimStatus, + taskClaimUserId, + timeRange, + versionItems, + versionNo, + versionsError, + versionsLoading, + }; +} + +async function loadAllVersions(templateId) { + if (!templateId) return emptyVersions; + const items = []; + let page = 1; + let total; + do { + const result = await listActivityTemplateVersions(templateId, { page, page_size: VERSION_PAGE_SIZE }); + items.push(...(result.items || [])); + total = Number(result.total || items.length); + if (!(result.items || []).length) break; + page += 1; + } while (items.length < total && items.length < VERSION_LIMIT); + return { items: items.slice(0, VERSION_LIMIT), page: 1, pageSize: VERSION_PAGE_SIZE, total }; +} diff --git a/src/features/activity-templates/hooks/useActivityTemplatesPage.js b/src/features/activity-templates/hooks/useActivityTemplatesPage.js new file mode 100644 index 0000000..a294d64 --- /dev/null +++ b/src/features/activity-templates/hooks/useActivityTemplatesPage.js @@ -0,0 +1,600 @@ +import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; +import { useAppScope } from "@/app/app-scope/AppScopeProvider.jsx"; +import { listGifts, listResourceGroups } from "@/features/resources/api"; +import { + cloneActivityTemplate, + createActivityTemplate, + deleteActivityTemplate, + getActivityTemplate, + listActivityTemplates, + listActivityTemplateVersions, + setActivityTemplateStatus, + updateActivityTemplate, + validateActivityTemplate, +} from "@/features/activity-templates/api"; +import { useActivityTemplateAbilities } from "@/features/activity-templates/permissions.js"; +import { + activityTemplateFormFromDto, + activityTemplatePayloadFromForm, + createEmptyActivityTemplateForm, +} from "@/features/activity-templates/model.js"; +import { activityTemplateFormSchema, validateActivityTemplateForPublish } from "@/features/activity-templates/schema"; +import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js"; +import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js"; +import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx"; +import { useToast } from "@/shared/ui/ToastProvider.jsx"; + +const defaultPageSize = 20; +const referencePageSize = 100; +const referenceOptionLimit = 1000; +const emptyPage = { items: [], page: 1, pageSize: defaultPageSize, total: 0 }; +const emptyVersions = { items: [], page: 1, pageSize: 20, total: 0 }; + +export function useActivityTemplatesPage() { + const { appCode } = useAppScope(); + const activeAppCodeRef = useRef(appCode); + const previousAppCodeRef = useRef(appCode); + const abilities = useActivityTemplateAbilities(); + const confirm = useConfirm(); + const { showToast } = useToast(); + const { loadingRegions, regionOptions } = useRegionOptions(); + const [keyword, setKeywordState] = useState(""); + const deferredKeyword = useDeferredValue(keyword.trim()); + const [status, setStatusState] = useState(""); + const [regionId, setRegionIdState] = useState(""); + const [timeRange, setTimeRangeState] = useState({ endMs: "", startMs: "" }); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(defaultPageSize); + const [loadingAction, setLoadingAction] = useState(""); + const [dataSource, setDataSource] = useState(null); + const [editorMode, setEditorMode] = useState(""); + + const filters = useMemo( + () => ({ + end_ms: timeRange.endMs || undefined, + keyword: deferredKeyword || undefined, + page, + page_size: pageSize, + region_id: regionId || undefined, + start_ms: timeRange.startMs || undefined, + status: status || undefined, + }), + [deferredKeyword, page, pageSize, regionId, status, timeRange.endMs, timeRange.startMs], + ); + const listQueryFn = useCallback(() => listActivityTemplates(filters), [filters]); + const { + data = emptyPage, + error, + loading, + reload, + } = useAdminQuery(listQueryFn, { + enabled: Boolean(appCode && abilities.canView), + errorMessage: "加载活动模版失败", + initialData: emptyPage, + keepPreviousData: true, + queryKey: ["activity-templates", appCode, filters], + }); + + const giftQueryFn = useCallback(() => loadActiveReferenceOptions(listGifts), []); + const { data: giftPage = emptyPage, loading: giftsLoading } = useAdminQuery(giftQueryFn, { + // 礼物/资源组是可编辑表单的候选项,不属于列表或只读详情依赖。按需加载既降低首屏扇出, + // 也避免只有 activity-template:view 的运营角色在浏览列表时收到无关跨模块 403。 + enabled: Boolean(appCode && (editorMode === "create" || editorMode === "edit")), + errorMessage: "加载礼物失败", + initialData: emptyPage, + queryKey: ["activity-templates", "gifts", appCode], + staleTime: 5 * 60 * 1000, + }); + const resourceGroupQueryFn = useCallback(() => loadActiveReferenceOptions(listResourceGroups), []); + const { data: resourceGroupPage = emptyPage } = useAdminQuery(resourceGroupQueryFn, { + enabled: Boolean(appCode && (editorMode === "create" || editorMode === "edit")), + errorMessage: "加载奖励资源组失败", + initialData: emptyPage, + queryKey: ["activity-templates", "resource-groups", appCode], + staleTime: 5 * 60 * 1000, + }); + + const [editorLoading, setEditorLoading] = useState(false); + const [form, setForm] = useState(() => createEmptyActivityTemplateForm(appCode)); + const [editorBaseline, setEditorBaseline] = useState(() => + formFingerprint(createEmptyActivityTemplateForm(appCode)), + ); + const [validationIssues, setValidationIssues] = useState([]); + const editorRequestRef = useRef(0); + const editorDirty = useMemo( + () => (editorMode === "create" || editorMode === "edit") && formFingerprint(form) !== editorBaseline, + [editorBaseline, editorMode, form], + ); + + const resetEditorState = useCallback(() => { + const emptyForm = createEmptyActivityTemplateForm(appCode); + editorRequestRef.current += 1; + setEditorMode(""); + setEditorLoading(false); + setValidationIssues([]); + setForm(emptyForm); + setEditorBaseline(formFingerprint(emptyForm)); + }, [appCode]); + + const closeEditor = useCallback(async () => { + if (loadingAction.startsWith("save") || loadingAction.startsWith("status:")) return; + if (editorDirty) { + const ok = await confirm({ + confirmText: "放弃更改", + message: "当前活动模版存在未保存更改。", + title: "关闭活动模版编辑", + tone: "danger", + }); + if (!ok) return; + } + resetEditorState(); + }, [confirm, editorDirty, loadingAction, resetEditorState]); + + const openCreate = () => { + const emptyForm = createEmptyActivityTemplateForm(appCode); + setValidationIssues([]); + setForm(emptyForm); + setEditorBaseline(formFingerprint(emptyForm)); + setEditorMode("create"); + }; + + const loadEditorTemplate = useCallback( + async (templateId, mode = "edit") => { + const requestID = ++editorRequestRef.current; + setEditorLoading(true); + setValidationIssues([]); + setEditorMode(mode); + try { + const template = await getActivityTemplate(templateId); + if (requestID !== editorRequestRef.current || activeAppCodeRef.current !== appCode) return; + const nextForm = activityTemplateFormFromDto(template, appCode); + setForm(nextForm); + setEditorBaseline(formFingerprint(nextForm)); + } catch (err) { + if (requestID !== editorRequestRef.current) return; + showToast(err.message || "加载活动模版失败", "error"); + setEditorMode(""); + } finally { + if (requestID === editorRequestRef.current) setEditorLoading(false); + } + }, + [appCode, showToast], + ); + + const openEdit = (item) => { + // published 对应运行侧不可变快照,编辑入口必须降级为查看;停用成功后再由返回 DTO 打开编辑态。 + void loadEditorTemplate(item.templateId, item.status === "published" ? "view" : "edit"); + }; + const openView = (item) => void loadEditorTemplate(item.templateId, "view"); + const reloadEditor = async () => { + if (!form.templateId) return; + if (editorDirty) { + const ok = await confirm({ + confirmText: "重新加载", + message: "重新加载将丢弃当前未保存更改。", + title: "重新加载活动模版", + tone: "danger", + }); + if (!ok) return; + } + // revision 冲突不能自动覆盖。用户确认放弃本地更改后,再从 owner service + // 读取最新 revision,避免使用列表缓存或局部合并伪造服务端真实状态。 + await loadEditorTemplate(form.templateId, editorMode === "view" ? "view" : "edit"); + }; + + const persistForm = async ({ publish }) => { + const parsed = activityTemplateFormSchema.safeParse(form); + if (!parsed.success) { + const issues = parsed.error.issues.map(toDisplayIssue); + setValidationIssues(issues); + showToast(issues[0]?.message || "表单校验失败", "error"); + return null; + } + if (publish) { + const localPublishIssues = validateActivityTemplateForPublish(parsed.data).map((issue) => ({ + code: "publish_required", + field: issue.path.join("."), + message: issue.message, + })); + if (localPublishIssues.length) { + setValidationIssues(localPublishIssues); + showToast(localPublishIssues[0].message, "error"); + return null; + } + } + const payload = activityTemplatePayloadFromForm(parsed.data); + const operationAppCode = appCode; + setLoadingAction(publish ? "save-publish" : "save"); + setValidationIssues([]); + try { + if (publish) { + // owner service 返回全部字段问题,先校验可尽早暴露静态错误;最终发布仍可能因排期竞态失败。 + const validation = await validateActivityTemplate(payload, true); + if (activeAppCodeRef.current !== operationAppCode) return null; + if (!validation.valid) { + setValidationIssues(validation.issues); + showToast(validation.issues[0]?.message || "发布校验失败", "error"); + return null; + } + } + if (activeAppCodeRef.current !== operationAppCode) return null; + const saved = form.templateId + ? await updateActivityTemplate(form.templateId, payload, Number(form.revision)) + : await createActivityTemplate(payload); + if (activeAppCodeRef.current !== operationAppCode) return saved; + + // 保存与发布是两个独立 owner-service 命令。先把保存结果(尤其 template_id/revision)落回表单, + // 即使随后的发布因排期重叠或竞态失败,也不会在重试时重复 create 或携带旧 revision update。 + const savedForm = activityTemplateFormFromDto(saved, operationAppCode); + setForm(savedForm); + setEditorBaseline(formFingerprint(savedForm)); + setEditorMode("edit"); + + if (!publish) { + showToast("活动模版已保存", "success"); + resetEditorState(); + await reload(); + return saved; + } + + try { + const published = await setActivityTemplateStatus(saved.templateId, "published", saved.revision); + if (activeAppCodeRef.current !== operationAppCode) return published; + showToast("活动模版已发布", "success"); + resetEditorState(); + await reload(); + return published; + } catch (publishError) { + if (activeAppCodeRef.current !== operationAppCode) return saved; + const message = publishError.message || "发布失败"; + setValidationIssues([ + { + code: "publish_failed_after_save", + field: "status", + message: `草稿已保存,但发布失败:${message}`, + }, + ]); + showToast("草稿已保存,但发布失败", "error"); + await reload(); + return saved; + } + } catch (err) { + if (activeAppCodeRef.current !== operationAppCode) return null; + // 乐观锁冲突不能自动覆盖:保留当前表单并提供显式重新加载,让运营人员决定如何合并配置。 + const conflict = /revision|conflict|版本|已更新/i.test(err.message || ""); + setValidationIssues([ + { + code: conflict ? "revision_conflict" : "save_failed", + field: conflict ? "revision" : "", + message: conflict ? "模版已被其他管理员更新,请重新加载后再提交" : err.message || "保存失败", + }, + ]); + showToast(conflict ? "模版版本已变化" : err.message || "保存失败", "error"); + return null; + } finally { + setLoadingAction(""); + } + }; + + const submitForm = (event) => { + event.preventDefault(); + return persistForm({ publish: false }); + }; + + const saveAndPublish = async () => { + const ok = await confirm({ + confirmText: "保存并发布", + message: "发布后配置进入只读状态,需先停用才能再次编辑。", + title: "发布活动模版", + }); + if (ok) return persistForm({ publish: true }); + return null; + }; + + const changeStatus = async (item, nextStatus, options = {}) => { + const isPublish = nextStatus === "published"; + const ok = + options.skipConfirm || + (await confirm({ + confirmText: isPublish ? "发布" : "停用", + message: isPublish + ? `${item.name} 将按活动时间和投放区域生效。` + : `${item.name} 停用后客户端不再读取该运行配置。`, + title: isPublish ? "发布活动模版" : "停用活动模版", + tone: isPublish ? "primary" : "danger", + })); + if (!ok) return null; + setLoadingAction(`status:${item.templateId}`); + try { + const updated = await setActivityTemplateStatus(item.templateId, nextStatus, Number(item.revision)); + showToast(isPublish ? "活动模版已发布" : "活动模版已停用", "success"); + await reload(); + return updated; + } catch (err) { + showToast(err.message || "状态更新失败", "error"); + return null; + } finally { + setLoadingAction(""); + } + }; + + const disableFromEditor = async () => { + if (!form.templateId || editorMode === "version") return; + const updated = await changeStatus( + { name: form.name, revision: form.revision, templateId: form.templateId }, + "disabled", + ); + if (updated) { + const nextForm = activityTemplateFormFromDto(updated, appCode); + setForm(nextForm); + setEditorBaseline(formFingerprint(nextForm)); + setEditorMode("edit"); + setValidationIssues([]); + } + }; + + const archiveTemplate = async (item) => { + const ok = await confirm({ + confirmText: "归档", + message: `${item.name} 归档后不再出现在默认活动列表中。`, + title: "归档活动模版", + tone: "danger", + }); + if (!ok) return; + setLoadingAction(`archive:${item.templateId}`); + try { + await deleteActivityTemplate(item.templateId, Number(item.revision)); + showToast("活动模版已归档", "success"); + await reload(); + } catch (err) { + showToast(err.message || "归档失败", "error"); + } finally { + setLoadingAction(""); + } + }; + + const [cloneSource, setCloneSource] = useState(null); + const [cloneForm, setCloneForm] = useState({ name: "", sourceVersion: 0, templateCode: "" }); + const openClone = (item, version = null) => { + const source = version?.snapshot || item; + setCloneSource(item); + setCloneForm({ + name: `${source.name || item.name} 副本`, + sourceVersion: Number(version?.versionNo || 0), + templateCode: nextCopyCode(source.templateCode || item.templateCode), + }); + }; + const closeClone = () => { + if (loadingAction === "clone") return; + setCloneSource(null); + }; + const updateCloneForm = (patch) => setCloneForm((current) => ({ ...current, ...patch })); + const submitClone = async (event) => { + event.preventDefault(); + if (!cloneSource) return; + setLoadingAction("clone"); + try { + await cloneActivityTemplate(cloneSource.templateId, { + name: cloneForm.name.trim(), + source_version: Number(cloneForm.sourceVersion || 0), + template_code: cloneForm.templateCode.trim(), + }); + setCloneSource(null); + showToast("活动模版已复制为草稿", "success"); + await reload(); + } catch (err) { + showToast(err.message || "复制失败", "error"); + } finally { + setLoadingAction(""); + } + }; + + const [versionSource, setVersionSource] = useState(null); + const [versionPage, setVersionPage] = useState(1); + const versionQueryFn = useCallback( + () => listActivityTemplateVersions(versionSource?.templateId, { page: versionPage, page_size: 20 }), + [versionPage, versionSource?.templateId], + ); + const { + data: versions = emptyVersions, + error: versionsError, + loading: versionsLoading, + reload: reloadVersions, + } = useAdminQuery(versionQueryFn, { + enabled: Boolean(versionSource?.templateId), + errorMessage: "加载发布版本失败", + initialData: emptyVersions, + keepPreviousData: true, + queryKey: ["activity-template-versions", appCode, versionSource?.templateId, versionPage], + }); + + useEffect(() => { + activeAppCodeRef.current = appCode; + if (previousAppCodeRef.current === appCode) return; + previousAppCodeRef.current = appCode; + const emptyForm = createEmptyActivityTemplateForm(appCode); + // App header 已先切换请求租户,旧 App 的 overlay 不能继续提交;同时使旧详情请求失效, + // 防止其晚到响应在新 App 下重新打开旧模版表单。 + editorRequestRef.current += 1; + setEditorMode(""); + setEditorLoading(false); + setForm(emptyForm); + setEditorBaseline(formFingerprint(emptyForm)); + setValidationIssues([]); + setCloneSource(null); + setCloneForm({ name: "", sourceVersion: 0, templateCode: "" }); + setVersionSource(null); + setVersionPage(1); + setDataSource(null); + setLoadingAction(""); + }, [appCode]); + + const openVersions = (item) => { + setVersionPage(1); + setVersionSource(item); + }; + const closeVersions = () => setVersionSource(null); + const previewVersion = (version) => { + const nextForm = { + ...activityTemplateFormFromDto(version.snapshot, appCode), + publishedVersion: version.versionNo, + status: "published", + }; + setForm(nextForm); + setEditorBaseline(formFingerprint(nextForm)); + setVersionSource(null); + setValidationIssues([]); + setEditorMode("version"); + }; + const cloneVersion = (version) => { + const source = versionSource; + setVersionSource(null); + openClone(source, version); + }; + const openData = (item) => setDataSource(item); + const closeData = () => setDataSource(null); + + const resetFilters = () => { + setKeywordState(""); + setStatusState(""); + setRegionIdState(""); + setTimeRangeState({ endMs: "", startMs: "" }); + setPage(1); + }; + const setKeyword = (value) => { + setKeywordState(value); + setPage(1); + }; + const setStatus = (value) => { + setStatusState(value); + setPage(1); + }; + const setRegionId = (value) => { + setRegionIdState(value); + setPage(1); + }; + const setTimeRange = (value) => { + setTimeRangeState(value); + setPage(1); + }; + const changePageSize = (value) => { + setPageSize(value); + setPage(1); + }; + + return { + abilities, + archiveTemplate, + changePageSize, + changeStatus, + cloneForm, + cloneSource, + cloneVersion, + closeClone, + closeData, + closeEditor, + closeVersions, + data, + dataSource, + disableFromEditor, + editorLoading, + editorDirty, + editorMode, + error, + form, + gifts: giftPage.items || [], + giftsLoading, + keyword, + loading, + loadingAction, + loadingRegions, + openClone, + openCreate, + openData, + openEdit, + openVersions, + openView, + page, + pageSize, + previewVersion, + regionId, + regionOptions, + reload, + reloadEditor, + reloadVersions, + resetFilters, + resourceGroups: resourceGroupPage.items || [], + saveAndPublish, + setForm, + setKeyword, + setPage, + setRegionId, + setStatus, + setTimeRange, + setVersionPage, + status, + submitClone, + submitForm, + timeRange, + updateCloneForm, + validationIssues, + versionPage, + versionSource, + versions, + versionsError, + versionsLoading, + }; +} + +function toDisplayIssue(issue) { + return { + code: issue.code || "invalid", + field: issue.path.join("."), + message: issue.message, + }; +} + +export function nextCopyCode(value, now = Date.now(), entropy = Math.random()) { + const base = String(value || "activity") + .toLowerCase() + .replace(/[^a-z0-9_-]/g, "_") + .replace(/^([^a-z])+/, "a_") + .slice(0, 40); + const timestamp = new Date(now).toISOString().slice(2, 19).replace(/[-T:]/g, ""); + const random = Math.floor(Math.max(0, Math.min(0.999999, entropy)) * 36 ** 4) + .toString(36) + .padStart(4, "0"); + return `${base}_copy_${timestamp}_${random}`.slice(0, 64); +} + +export function formFingerprint(value) { + return JSON.stringify(value); +} + +export async function loadActiveReferenceOptions(fetchPage, maxItems = referenceOptionLimit) { + const safeLimit = Math.max(1, Math.min(referenceOptionLimit, Number(maxItems) || referenceOptionLimit)); + const items = []; + let page = 1; + let ownerTotal = 0; + + // wallet-service 将 page_size 上限固定为 100。逐页读取到 total 或本模块的安全上限, + // 让礼物/资源组抽屉可以搜索完整候选项,同时避免用错误的 300 条回显推导下一页。 + while (items.length < safeLimit) { + const result = await fetchPage({ page, page_size: referencePageSize, status: "active" }); + const pageItems = Array.isArray(result?.items) ? result.items : []; + ownerTotal = Math.max(ownerTotal, Number(result?.total) || 0); + items.push(...pageItems.slice(0, safeLimit - items.length)); + + const reachedOwnerTotal = ownerTotal > 0 && items.length >= ownerTotal; + const reachedLastPage = pageItems.length < referencePageSize; + if (reachedOwnerTotal || reachedLastPage || items.length >= safeLimit) break; + page += 1; + } + + return { + items, + page: 1, + pageSize: referencePageSize, + total: ownerTotal || items.length, + }; +} diff --git a/src/features/activity-templates/hooks/useActivityTemplatesPage.test.jsx b/src/features/activity-templates/hooks/useActivityTemplatesPage.test.jsx new file mode 100644 index 0000000..be75460 --- /dev/null +++ b/src/features/activity-templates/hooks/useActivityTemplatesPage.test.jsx @@ -0,0 +1,288 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import { createEmptyActivityTemplateForm } from "@/features/activity-templates/model.js"; + +const mocks = vi.hoisted(() => ({ + abilities: { + canCreate: true, + canData: true, + canDelete: true, + canExport: true, + canPublish: true, + canRetry: true, + canUpdate: true, + canView: true, + }, + appCode: "lalu", + cloneActivityTemplate: vi.fn(), + confirm: vi.fn(), + createActivityTemplate: vi.fn(), + deleteActivityTemplate: vi.fn(), + getActivityTemplate: vi.fn(), + listActivityTemplates: vi.fn(), + listActivityTemplateVersions: vi.fn(), + listGifts: vi.fn(), + listResourceGroups: vi.fn(), + reload: vi.fn(), + setActivityTemplateStatus: vi.fn(), + showToast: vi.fn(), + updateActivityTemplate: vi.fn(), + validateActivityTemplate: vi.fn(), +})); + +vi.mock("@/app/app-scope/AppScopeProvider.jsx", () => ({ + useAppScope: () => ({ appCode: mocks.appCode }), +})); + +vi.mock("@/features/activity-templates/api", () => ({ + cloneActivityTemplate: mocks.cloneActivityTemplate, + createActivityTemplate: mocks.createActivityTemplate, + deleteActivityTemplate: mocks.deleteActivityTemplate, + getActivityTemplate: mocks.getActivityTemplate, + listActivityTemplates: mocks.listActivityTemplates, + listActivityTemplateVersions: mocks.listActivityTemplateVersions, + setActivityTemplateStatus: mocks.setActivityTemplateStatus, + updateActivityTemplate: mocks.updateActivityTemplate, + validateActivityTemplate: mocks.validateActivityTemplate, +})); + +vi.mock("@/features/activity-templates/permissions.js", () => ({ + useActivityTemplateAbilities: () => mocks.abilities, +})); + +vi.mock("@/features/resources/api", () => ({ + listGifts: mocks.listGifts, + listResourceGroups: mocks.listResourceGroups, +})); + +vi.mock("@/shared/hooks/useAdminQuery.js", () => ({ + useAdminQuery: () => ({ + data: { items: [], page: 1, pageSize: 20, total: 0 }, + error: "", + loading: false, + reload: mocks.reload, + }), +})); + +vi.mock("@/shared/hooks/useRegionOptions.js", () => ({ + useRegionOptions: () => ({ loadingRegions: false, regionOptions: [] }), +})); + +vi.mock("@/shared/ui/ConfirmProvider.jsx", () => ({ + useConfirm: () => mocks.confirm, +})); + +vi.mock("@/shared/ui/ToastProvider.jsx", () => ({ + useToast: () => ({ showToast: mocks.showToast }), +})); + +import { loadActiveReferenceOptions, nextCopyCode, useActivityTemplatesPage } from "./useActivityTemplatesPage.js"; + +beforeEach(() => { + mocks.appCode = "lalu"; + mocks.confirm.mockResolvedValue(true); + mocks.reload.mockResolvedValue(undefined); + mocks.validateActivityTemplate.mockResolvedValue({ issues: [], valid: true }); +}); + +afterEach(() => { + mocks.abilities.canUpdate = true; + vi.clearAllMocks(); +}); + +test("keeps the saved identity and revision when the second publish command fails", async () => { + const saved = savedTemplateFixture(); + mocks.createActivityTemplate.mockResolvedValue(saved); + mocks.setActivityTemplateStatus.mockRejectedValue(new Error("activity period overlaps another template")); + const { result } = renderHook(() => useActivityTemplatesPage()); + + act(() => result.current.openCreate()); + act(() => result.current.setForm(completePublishForm())); + await act(async () => { + await result.current.saveAndPublish(); + }); + + expect(mocks.createActivityTemplate).toHaveBeenCalledTimes(1); + expect(mocks.setActivityTemplateStatus).toHaveBeenCalledWith("template-101", "published", 11); + expect(result.current.editorMode).toBe("edit"); + expect(result.current.form.templateId).toBe("template-101"); + expect(result.current.form.revision).toBe(11); + expect(result.current.editorDirty).toBe(false); + expect(result.current.validationIssues[0].message).toContain("草稿已保存,但发布失败"); + expect(mocks.showToast).toHaveBeenCalledWith("草稿已保存,但发布失败", "error"); +}); + +test("runs owner validation when a creator can publish without update permission", async () => { + mocks.abilities.canUpdate = false; + const saved = savedTemplateFixture(); + mocks.createActivityTemplate.mockResolvedValue(saved); + mocks.setActivityTemplateStatus.mockResolvedValue({ ...saved, status: "published" }); + const { result } = renderHook(() => useActivityTemplatesPage()); + + act(() => result.current.openCreate()); + act(() => result.current.setForm(completePublishForm())); + await act(async () => { + await result.current.saveAndPublish(); + }); + + expect(mocks.validateActivityTemplate).toHaveBeenCalledTimes(1); + expect(mocks.createActivityTemplate).toHaveBeenCalledTimes(1); + expect(mocks.setActivityTemplateStatus).toHaveBeenCalledTimes(1); +}); + +test("asks before discarding a dirty editor and keeps it open when cancelled", async () => { + const { result } = renderHook(() => useActivityTemplatesPage()); + act(() => result.current.openCreate()); + act(() => result.current.setForm((current) => ({ ...current, name: "Changed" }))); + expect(result.current.editorDirty).toBe(true); + + mocks.confirm.mockResolvedValueOnce(false); + await act(async () => result.current.closeEditor()); + expect(result.current.editorMode).toBe("create"); + + mocks.confirm.mockResolvedValueOnce(true); + await act(async () => result.current.closeEditor()); + expect(result.current.editorMode).toBe(""); + expect(result.current.editorDirty).toBe(false); +}); + +test("asks before reloading a revision conflict over dirty local changes", async () => { + const template = savedTemplateFixture(); + mocks.getActivityTemplate.mockResolvedValue(template); + const { result } = renderHook(() => useActivityTemplatesPage()); + + act(() => result.current.openEdit(template)); + await waitFor(() => expect(result.current.form.templateId).toBe("template-101")); + act(() => result.current.setForm((current) => ({ ...current, name: "Local merge" }))); + expect(result.current.editorDirty).toBe(true); + + mocks.confirm.mockResolvedValueOnce(false); + await act(async () => result.current.reloadEditor()); + expect(mocks.getActivityTemplate).toHaveBeenCalledTimes(1); + expect(result.current.form.name).toBe("Local merge"); + + mocks.confirm.mockResolvedValueOnce(true); + await act(async () => result.current.reloadEditor()); + expect(mocks.getActivityTemplate).toHaveBeenCalledTimes(2); + expect(result.current.form.name).toBe(template.name); + expect(result.current.editorDirty).toBe(false); +}); + +test("clears every overlay and stale form when App scope changes", async () => { + const { result, rerender } = renderHook(() => useActivityTemplatesPage()); + act(() => result.current.openCreate()); + act(() => result.current.setForm((current) => ({ ...current, name: "Old App draft" }))); + expect(result.current.editorMode).toBe("create"); + + mocks.appCode = "huwaa"; + rerender(); + + await waitFor(() => expect(result.current.editorMode).toBe("")); + expect(result.current.form.appCode).toBe("huwaa"); + expect(result.current.form.name).toBe(""); + expect(result.current.cloneSource).toBeNull(); + expect(result.current.versionSource).toBeNull(); +}); + +test("copy code uses second-level time and entropy", () => { + const first = nextCopyCode("gift_challenge", Date.UTC(2026, 6, 14, 10, 20, 30), 0.1); + const nextSecond = nextCopyCode("gift_challenge", Date.UTC(2026, 6, 14, 10, 20, 31), 0.1); + const nextEntropy = nextCopyCode("gift_challenge", Date.UTC(2026, 6, 14, 10, 20, 30), 0.2); + + expect(first).not.toBe(nextSecond); + expect(first).not.toBe(nextEntropy); + expect(first.length).toBeLessThanOrEqual(64); +}); + +test("loads active gift and resource-group options in owner-service page-size chunks", async () => { + const fetchPage = vi + .fn() + .mockResolvedValueOnce({ items: Array.from({ length: 100 }, (_, index) => ({ id: index + 1 })), total: 150 }) + .mockResolvedValueOnce({ items: Array.from({ length: 50 }, (_, index) => ({ id: index + 101 })), total: 150 }); + + const result = await loadActiveReferenceOptions(fetchPage); + + expect(fetchPage).toHaveBeenNthCalledWith(1, { page: 1, page_size: 100, status: "active" }); + expect(fetchPage).toHaveBeenNthCalledWith(2, { page: 2, page_size: 100, status: "active" }); + expect(fetchPage).toHaveBeenCalledTimes(2); + expect(result).toMatchObject({ page: 1, pageSize: 100, total: 150 }); + expect(result.items).toHaveLength(150); +}); + +function completePublishForm() { + const form = createEmptyActivityTemplateForm("lalu"); + form.allRegions = true; + form.name = "Gift Challenge"; + form.templateCode = "gift_challenge_2026"; + form.timeRange = { endMs: 1_800_086_400_000, startMs: 1_800_000_000_000 }; + form.titleEn = "Gift Challenge"; + form.rulesEn = "Rules"; + form.gifts = [ + { + coinPrice: "1999", + giftId: "gift-1", + iconUrl: "https://cdn.example.com/gift.webp", + name: "Gift", + priceVersion: "v1", + }, + ]; + form.tasks[0].rewardResourceGroupId = "71"; + form.dailyRankRewards.forEach((reward, index) => { + reward.resourceGroupId = String(80 + index); + }); + form.totalRankRewards.forEach((reward, index) => { + reward.resourceGroupId = String(90 + index); + }); + return form; +} + +function savedTemplateFixture() { + const form = completePublishForm(); + return { + activityType: "gift_challenge", + allRegions: true, + appCode: "lalu", + assets: [], + dailyLeaderboardSize: 100, + displayConfigJson: form.displayConfigJson, + endMs: Number(form.timeRange.endMs), + gifts: form.gifts.map((gift, index) => ({ ...gift, coinPrice: Number(gift.coinPrice), sortOrder: index + 1 })), + lifecycleStatus: "draft", + locales: [ + { locale: "en", rules: form.rulesEn, title: form.titleEn }, + { locale: "ar", rules: "", title: "" }, + ], + name: form.name, + publishedVersion: 0, + rankRewards: [ + ...form.dailyRankRewards.map((reward, index) => ({ + boardType: "daily", + rankFrom: Number(reward.rankFrom), + rankTo: Number(reward.rankTo), + resourceGroupId: Number(reward.resourceGroupId), + sortOrder: index + 1, + })), + ...form.totalRankRewards.map((reward, index) => ({ + boardType: "total", + rankFrom: Number(reward.rankFrom), + rankTo: Number(reward.rankTo), + resourceGroupId: Number(reward.resourceGroupId), + sortOrder: index + 1, + })), + ], + regionIds: [], + revision: 11, + startMs: Number(form.timeRange.startMs), + status: "draft", + tasks: form.tasks.map((task, index) => ({ + rewardResourceGroupId: Number(task.rewardResourceGroupId), + sortOrder: index + 1, + targetValue: Number(task.targetValue), + taskKey: task.taskKey, + taskType: task.taskType, + })), + templateCode: form.templateCode, + templateId: "template-101", + totalLeaderboardSize: 100, + }; +} diff --git a/src/features/activity-templates/model.js b/src/features/activity-templates/model.js new file mode 100644 index 0000000..e8a4089 --- /dev/null +++ b/src/features/activity-templates/model.js @@ -0,0 +1,262 @@ +import { ASSET_SLOTS, DEFAULT_DISPLAY_CONFIG } from "@/features/activity-templates/constants.js"; + +export function createEmptyActivityTemplateForm(appCode = "") { + return { + activityType: "gift_challenge", + allRegions: false, + appCode, + assets: ASSET_SLOTS.map((slot) => ({ ...slot, mediaType: "image", url: "" })), + dailyLeaderboardSize: "100", + dailyRankRewards: defaultDailyRankRewards(), + displayConfigJson: JSON.stringify(DEFAULT_DISPLAY_CONFIG, null, 2), + gifts: [], + name: "", + lifecycleStatus: "draft", + publishedVersion: 0, + regionIds: [], + revision: 0, + rulesAr: "", + rulesEn: "", + status: "draft", + tasks: [createActivityTask("daily_visit", 0)], + templateCode: "", + templateId: "", + timeRange: { endMs: "", startMs: "" }, + titleAr: "", + titleEn: "", + totalLeaderboardSize: "100", + totalRankRewards: defaultTotalRankRewards(), + }; +} + +export function activityTemplateFormFromDto(template, appCode = "") { + const english = (template?.locales || []).find((locale) => locale.locale === "en") || {}; + const arabic = (template?.locales || []).find((locale) => locale.locale === "ar") || {}; + const rankRewards = template?.rankRewards || []; + const assets = ASSET_SLOTS.map((slot) => { + const asset = (template?.assets || []).find( + (item) => item.assetKey === slot.assetKey && item.locale === slot.locale, + ); + return { + ...slot, + mediaType: asset?.mediaType || "image", + url: asset?.url || "", + }; + }); + const extraAssets = (template?.assets || []) + .filter((item) => !ASSET_SLOTS.some((slot) => slot.assetKey === item.assetKey && slot.locale === item.locale)) + .map((item) => ({ + assetKey: item.assetKey, + label: `${item.assetKey} · ${item.locale}`, + locale: item.locale, + mediaType: item.mediaType || "image", + url: item.url, + })); + + return { + activityType: template?.activityType || "gift_challenge", + allRegions: Boolean(template?.allRegions), + appCode: template?.appCode || appCode, + assets: [...assets, ...extraAssets], + dailyLeaderboardSize: String(template?.dailyLeaderboardSize || 100), + dailyRankRewards: rewardsForBoard(rankRewards, "daily", defaultDailyRankRewards()), + displayConfigJson: prettyJson(template?.displayConfigJson), + gifts: (template?.gifts || []).map((gift) => ({ + coinPrice: String(gift.coinPrice || ""), + giftId: String(gift.giftId || ""), + iconUrl: gift.iconUrl || "", + name: gift.name || "", + priceVersion: gift.priceVersion || "", + })), + name: template?.name || "", + lifecycleStatus: template?.lifecycleStatus || template?.status || "draft", + publishedVersion: Number(template?.publishedVersion || 0), + regionIds: (template?.regionIds || []).map(String), + revision: Number(template?.revision || 0), + rulesAr: arabic.rules || "", + rulesEn: english.rules || "", + status: template?.status || "draft", + tasks: (template?.tasks || []).map((task) => ({ + rewardResourceGroupId: task.rewardResourceGroupId ? String(task.rewardResourceGroupId) : "", + targetValue: String(task.targetValue || ""), + taskKey: task.taskKey || "", + taskType: task.taskType || "daily_visit", + })), + templateCode: template?.templateCode || "", + templateId: template?.templateId || "", + timeRange: { + endMs: template?.endMs || "", + startMs: template?.startMs || "", + }, + titleAr: arabic.title || "", + titleEn: english.title || "", + totalLeaderboardSize: String(template?.totalLeaderboardSize || 100), + totalRankRewards: rewardsForBoard(rankRewards, "total", defaultTotalRankRewards()), + }; +} + +export function activityTemplatePayloadFromForm(form) { + return { + activity_type: "gift_challenge", + all_regions: Boolean(form.allRegions), + assets: (form.assets || []) + .filter((asset) => asset.url) + .map((asset, index) => ({ + asset_key: asset.assetKey, + locale: asset.locale || "*", + media_type: asset.mediaType || mediaTypeFromURL(asset.url), + sort_order: index + 1, + url: asset.url, + })), + daily_leaderboard_size: Number(form.dailyLeaderboardSize), + display_config_json: compactJson(form.displayConfigJson), + end_ms: Number(form.timeRange.endMs || 0), + gifts: (form.gifts || []).map((gift, index) => ({ + coin_price: Number(gift.coinPrice || 0), + gift_id: String(gift.giftId), + icon_url: gift.iconUrl || "", + name: gift.name || "", + price_version: gift.priceVersion || "", + sort_order: index + 1, + })), + locales: [ + { locale: "en", rules: form.rulesEn.trim(), title: form.titleEn.trim() }, + { locale: "ar", rules: form.rulesAr.trim(), title: form.titleAr.trim() }, + ].filter((locale) => locale.locale === "en" || locale.rules || locale.title), + name: form.name.trim(), + rank_rewards: [ + ...rankRewardPayloads(form.dailyRankRewards, "daily"), + ...rankRewardPayloads(form.totalRankRewards, "total"), + ], + region_ids: form.allRegions ? [] : (form.regionIds || []).map(Number), + start_ms: Number(form.timeRange.startMs || 0), + tasks: (form.tasks || []).map((task, index) => ({ + reward_resource_group_id: Number(task.rewardResourceGroupId || 0), + sort_order: index + 1, + target_value: Number(task.targetValue || 0), + task_key: task.taskKey.trim(), + task_type: task.taskType, + })), + template_code: form.templateCode.trim().toLowerCase(), + total_leaderboard_size: Number(form.totalLeaderboardSize), + }; +} + +export function giftFormValue(gift) { + const resource = gift?.resource || {}; + return { + coinPrice: String(gift?.coinPrice ?? gift?.giftPriceCoin ?? resource.coinPrice ?? ""), + giftId: String(gift?.giftId || ""), + iconUrl: resource.previewUrl || resource.assetUrl || gift?.giftIconUrl || "", + name: gift?.name || gift?.giftName || resource.name || "", + priceVersion: gift?.priceVersion || String(gift?.updatedAtMs || resource.updatedAtMs || "v1"), + }; +} + +export function giftDtoFromForm(gift) { + return { + coinPrice: Number(gift?.coinPrice || 0), + giftId: String(gift?.giftId || ""), + name: gift?.name || "", + priceVersion: gift?.priceVersion || "", + resource: { + assetUrl: gift?.iconUrl || "", + coinPrice: Number(gift?.coinPrice || 0), + name: gift?.name || "", + previewUrl: gift?.iconUrl || "", + }, + }; +} + +export function createActivityTask(taskType = "gift_count", index = 0) { + return { + rewardResourceGroupId: "", + targetValue: taskType === "daily_visit" ? "1" : "1", + taskKey: `${taskType}_${index + 1}`, + taskType, + }; +} + +export function createNextActivityTask(tasks, taskType = "gift_count") { + const usedKeys = new Set((tasks || []).map((task) => String(task.taskKey || "").trim())); + let sequence = 1; + while (usedKeys.has(`${taskType}_${sequence}`)) { + sequence += 1; + } + return createActivityTask(taskType, sequence - 1); +} + +export function createRankReward(rankFrom = 1, rankTo = rankFrom) { + return { rankFrom: String(rankFrom), rankTo: String(rankTo), resourceGroupId: "" }; +} + +export function mediaTypeFromURL(value) { + const url = String(value || "") + .toLowerCase() + .split(/[?#]/)[0]; + if (url.endsWith(".svga")) return "svga"; + if (url.endsWith(".pag")) return "pag"; + if (url.endsWith(".webp")) return "webp"; + return "image"; +} + +export function parseDisplayConfig(value) { + try { + const parsed = JSON.parse(value || "{}"); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : DEFAULT_DISPLAY_CONFIG; + } catch { + return DEFAULT_DISPLAY_CONFIG; + } +} + +function rewardsForBoard(rewards, boardType, fallback) { + const items = rewards + .filter((reward) => reward.boardType === boardType) + .sort((left, right) => left.rankFrom - right.rankFrom) + .map((reward) => ({ + rankFrom: String(reward.rankFrom), + rankTo: String(reward.rankTo), + resourceGroupId: reward.resourceGroupId ? String(reward.resourceGroupId) : "", + })); + return items.length ? items : fallback; +} + +function rankRewardPayloads(rewards, boardType) { + return (rewards || []).map((reward, index) => ({ + board_type: boardType, + rank_from: Number(reward.rankFrom), + rank_to: Number(reward.rankTo), + resource_group_id: Number(reward.resourceGroupId || 0), + sort_order: index + 1, + })); +} + +function defaultDailyRankRewards() { + return [createRankReward(1), createRankReward(2), createRankReward(3)]; +} + +function defaultTotalRankRewards() { + return [ + createRankReward(1), + createRankReward(2), + createRankReward(3), + createRankReward(4, 6), + createRankReward(7, 10), + ]; +} + +function prettyJson(value) { + try { + return JSON.stringify(JSON.parse(value || "{}"), null, 2); + } catch { + return value || "{}"; + } +} + +function compactJson(value) { + const parsed = JSON.parse(value || "{}"); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("展示配置必须是 JSON 对象"); + } + return JSON.stringify(parsed); +} diff --git a/src/features/activity-templates/model.test.js b/src/features/activity-templates/model.test.js new file mode 100644 index 0000000..4a4b05e --- /dev/null +++ b/src/features/activity-templates/model.test.js @@ -0,0 +1,157 @@ +import { describe, expect, test } from "vitest"; +import { + activityTemplatePayloadFromForm, + activityTemplateFormFromDto, + createEmptyActivityTemplateForm, + createNextActivityTask, + giftFormValue, +} from "@/features/activity-templates/model.js"; +import { activityTemplateFormSchema, validateActivityTemplateForPublish } from "@/features/activity-templates/schema"; + +describe("activity template form", () => { + test("builds the owner-service snake_case aggregate without reward duration fields", () => { + const form = createEmptyActivityTemplateForm("lalu"); + form.templateCode = "gift_challenge_2026"; + form.name = "Gift challenge July"; + form.allRegions = false; + form.regionIds = ["1001", "1002"]; + form.timeRange = { startMs: 1_800_000_000_000, endMs: 1_800_086_400_000 }; + form.titleEn = "Gift Challenge"; + form.rulesEn = "Rule"; + form.gifts = [ + { + coinPrice: "1999", + giftId: "gift-1", + iconUrl: "https://cdn.example.com/gift.webp", + name: "Rose", + priceVersion: "price-v2", + }, + ]; + form.tasks = [ + { + rewardResourceGroupId: "88", + targetValue: "3", + taskKey: "gift_count_3", + taskType: "gift_count", + }, + ]; + form.dailyRankRewards[0].resourceGroupId = "91"; + form.dailyRankRewards[1].resourceGroupId = "92"; + form.dailyRankRewards[2].resourceGroupId = "93"; + form.totalRankRewards.forEach((reward, index) => { + reward.resourceGroupId = String(101 + index); + }); + + const parsed = activityTemplateFormSchema.parse(form); + const payload = activityTemplatePayloadFromForm(parsed); + + expect(payload.template_code).toBe("gift_challenge_2026"); + expect(payload.region_ids).toEqual([1001, 1002]); + expect(payload.gifts[0]).toMatchObject({ + coin_price: 1999, + gift_id: "gift-1", + price_version: "price-v2", + sort_order: 1, + }); + expect(payload.tasks[0]).toEqual({ + reward_resource_group_id: 88, + sort_order: 1, + target_value: 3, + task_key: "gift_count_3", + task_type: "gift_count", + }); + expect(payload.tasks[0]).not.toHaveProperty("reward_valid_days"); + expect(payload.rank_rewards[0]).not.toHaveProperty("reward_valid_days"); + expect(payload).not.toHaveProperty("app_code"); + expect(payload).not.toHaveProperty("status"); + expect(payload).not.toHaveProperty("template_id"); + }); + + test("rejects overlapping leaderboard ranges and invalid daily-visit targets", () => { + const form = createEmptyActivityTemplateForm(); + form.templateCode = "gift_challenge"; + form.name = "Challenge"; + form.tasks[0].targetValue = "2"; + form.dailyRankRewards[1] = { ...form.dailyRankRewards[1], rankFrom: "1", rankTo: "2" }; + + const result = activityTemplateFormSchema.safeParse(form); + + expect(result.success).toBe(false); + expect(result.error.issues.map((issue) => issue.message)).toEqual( + expect.arrayContaining(["每日进入活动任务的目标固定为 1", "奖励名次区间不能重叠"]), + ); + }); + + test("rejects duplicate daily-visit reward tasks", () => { + const form = createEmptyActivityTemplateForm(); + form.templateCode = "gift_challenge"; + form.name = "Challenge"; + form.tasks.push({ + rewardResourceGroupId: "", + targetValue: "1", + taskKey: "daily_visit_bonus", + taskType: "daily_visit", + }); + + const result = activityTemplateFormSchema.safeParse(form); + + expect(result.success).toBe(false); + expect(result.error.issues.map((issue) => issue.message)).toContain("每日进入活动任务最多配置 1 个"); + }); + + test("publish validation requires delivery, English content, gifts, tasks and rewards", () => { + const form = createEmptyActivityTemplateForm(); + form.templateCode = "gift_challenge"; + form.name = "Challenge"; + const parsed = activityTemplateFormSchema.parse(form); + + const issues = validateActivityTemplateForPublish(parsed); + + expect(issues.map((issue) => issue.message)).toEqual( + expect.arrayContaining([ + "发布前必须配置有效活动时间", + "发布前必须选择投放区域", + "发布前必须填写英语标题", + "发布前至少配置 1 个礼物", + ]), + ); + }); + + test("captures gift display snapshots from the selected gift", () => { + expect( + giftFormValue({ + coinPrice: 500, + giftId: "g-1", + name: "Star", + priceVersion: "v9", + resource: { previewUrl: "https://cdn.example.com/star.png" }, + }), + ).toEqual({ + coinPrice: "500", + giftId: "g-1", + iconUrl: "https://cdn.example.com/star.png", + name: "Star", + priceVersion: "v9", + }); + }); + + test("preserves lifecycle and published-version metadata in editor form", () => { + const form = activityTemplateFormFromDto({ + lifecycleStatus: "ongoing", + publishedVersion: 7, + status: "published", + }); + + expect(form.lifecycleStatus).toBe("ongoing"); + expect(form.publishedVersion).toBe(7); + }); + + test("allocates the first unused task key after rows are removed", () => { + const task = createNextActivityTask( + [{ taskKey: "gift_count_1" }, { taskKey: "gift_count_3" }, { taskKey: "daily_visit_1" }], + "gift_count", + ); + + expect(task.taskKey).toBe("gift_count_2"); + }); +}); diff --git a/src/features/activity-templates/pages/ActivityTemplatesPage.jsx b/src/features/activity-templates/pages/ActivityTemplatesPage.jsx new file mode 100644 index 0000000..c41cc6c --- /dev/null +++ b/src/features/activity-templates/pages/ActivityTemplatesPage.jsx @@ -0,0 +1,395 @@ +import AddOutlined from "@mui/icons-material/AddOutlined"; +import ArchiveOutlined from "@mui/icons-material/ArchiveOutlined"; +import ContentCopyOutlined from "@mui/icons-material/ContentCopyOutlined"; +import EditOutlined from "@mui/icons-material/EditOutlined"; +import HistoryOutlined from "@mui/icons-material/HistoryOutlined"; +import QueryStatsOutlined from "@mui/icons-material/QueryStatsOutlined"; +import PublishOutlined from "@mui/icons-material/PublishOutlined"; +import RefreshOutlined from "@mui/icons-material/RefreshOutlined"; +import StopCircleOutlined from "@mui/icons-material/StopCircleOutlined"; +import VisibilityOutlined from "@mui/icons-material/VisibilityOutlined"; +import TablePagination from "@mui/material/TablePagination"; +import { lazy, Suspense } from "react"; +import { + ActivityTemplateCloneDialogSkeleton, + ActivityTemplateDrawerSkeleton, +} from "@/features/activity-templates/components/ActivityTemplateOverlaySkeletons.jsx"; +import { ActivityTemplateStatusBadge } from "@/features/activity-templates/components/ActivityTemplateStatusBadge.jsx"; +import { ACTIVITY_TEMPLATE_STATUS_OPTIONS } from "@/features/activity-templates/constants.js"; +import { canOpenActivityTemplateData } from "@/features/activity-templates/dataModel.js"; +import { useActivityTemplatesPage } from "@/features/activity-templates/hooks/useActivityTemplatesPage.js"; +import styles from "@/features/activity-templates/activity-templates.module.css"; +import { + AdminActionIconButton, + AdminFilterResetButton, + AdminFilterSelect, + AdminListBody, + AdminListPage, + AdminListToolbar, + AdminRowActions, + AdminSearchBox, +} from "@/shared/ui/AdminListLayout.jsx"; +import { Button } from "@/shared/ui/Button.jsx"; +import { DataState } from "@/shared/ui/DataState.jsx"; +import { DataTable } from "@/shared/ui/DataTable.jsx"; +import { RegionFilterControl } from "@/shared/ui/RegionFilterControl.jsx"; +import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx"; +import { formatMillis } from "@/shared/utils/time.js"; + +const ActivityTemplateCloneDialog = lazy(() => + import("@/features/activity-templates/components/ActivityTemplateCloneDialog.jsx").then((module) => ({ + default: module.ActivityTemplateCloneDialog, + })), +); +const ActivityTemplateDataDrawer = lazy(() => + import("@/features/activity-templates/components/ActivityTemplateDataDrawer.jsx").then((module) => ({ + default: module.ActivityTemplateDataDrawer, + })), +); +const ActivityTemplateEditorDrawer = lazy(() => + import("@/features/activity-templates/components/ActivityTemplateEditorDrawer.jsx").then((module) => ({ + default: module.ActivityTemplateEditorDrawer, + })), +); +const ActivityTemplateVersionsDrawer = lazy(() => + import("@/features/activity-templates/components/ActivityTemplateVersionsDrawer.jsx").then((module) => ({ + default: module.ActivityTemplateVersionsDrawer, + })), +); + +export function ActivityTemplatesPage() { + const page = useActivityTemplatesPage(); + const regionLabels = new Map( + page.regionOptions.map((option) => [String(option.value), option.name || option.label]), + ); + const columns = [ + { + key: "template", + label: "活动模版", + width: "minmax(280px, 1.25fr)", + render: (item) => ( +
+ + + {item.name || "-"} + {item.templateCode || item.templateId} + +
+ ), + }, + { + key: "status", + label: "状态", + width: "120px", + render: (item) => , + }, + { + key: "period", + label: "活动周期", + width: "minmax(210px, .95fr)", + render: (item) => ( +
+ {formatMillis(item.startMs)} + 至 {formatMillis(item.endMs)} +
+ ), + }, + { + key: "scope", + label: "投放区域", + width: "minmax(170px, .7fr)", + render: (item) => ( +
+ {item.allRegions ? "全部区域" : regionScopeLabel(item.regionIds, regionLabels)} + {item.allRegions ? "全量投放" : `${item.regionIds.length} 个区域`} +
+ ), + }, + { + key: "version", + label: "版本", + width: "130px", + render: (item) => ( +
+ 发布 v{item.publishedVersion || 0} + revision {item.revision || 0} +
+ ), + }, + { + key: "updated", + label: "最近更新", + width: "minmax(180px, .75fr)", + render: (item) => ( +
+ {formatMillis(item.updatedAtMs)} + 管理员 #{item.updatedByAdminId || "-"} +
+ ), + }, + { + key: "actions", + label: "操作", + width: "280px", + render: (item) => { + const actionLoading = page.loadingAction.includes(item.templateId); + const editable = item.status === "draft" || item.status === "disabled"; + const canArchive = editable || item.lifecycleStatus === "ended"; + return ( + + {editable && page.abilities.canUpdate ? ( + page.openEdit(item)} + > + + + ) : ( + page.openView(item)} + > + + + )} + {page.abilities.canCreate ? ( + page.openClone(item)} + > + + + ) : null} + {item.publishedVersion > 0 ? ( + page.openVersions(item)} + > + + + ) : null} + {canOpenActivityTemplateData(item, page.abilities) ? ( + page.openData(item)} + > + + + ) : null} + {page.abilities.canPublish && editable ? ( + page.changeStatus(item, "published")} + > + + + ) : null} + {page.abilities.canPublish && + item.status === "published" && + item.lifecycleStatus !== "ended" ? ( + page.changeStatus(item, "disabled")} + > + + + ) : null} + {page.abilities.canDelete && canArchive ? ( + page.archiveTemplate(item)} + > + + + ) : null} + + ); + }, + }, + ]; + + return ( + + + + {page.abilities.canCreate ? ( + + ) : null} + + } + filters={ + <> + + + + + + + } + /> + + + item.templateId} + total={page.data.total || 0} + /> + `${from}-${to} / ${count}`} + labelRowsPerPage="每页" + page={Math.max(0, page.page - 1)} + rowsPerPage={page.pageSize} + rowsPerPageOptions={[20, 50, 100]} + onPageChange={(_, nextPage) => page.setPage(nextPage + 1)} + onRowsPerPageChange={(event) => page.changePageSize(Number(event.target.value))} + /> + + + {page.editorMode ? ( + + } + > + + + ) : null} + {page.cloneSource ? ( + }> + + + ) : null} + {page.versionSource ? ( + + } + > + + + ) : null} + {page.dataSource ? ( + + } + > + + + ) : null} + + ); +} + +function regionScopeLabel(regionIds, regionLabels) { + const names = regionIds.map((regionId) => regionLabels.get(String(regionId)) || `#${regionId}`); + if (names.length <= 2) return names.join("、") || "未配置"; + return `${names.slice(0, 2).join("、")} 等 ${names.length} 个`; +} diff --git a/src/features/activity-templates/pages/ActivityTemplatesPage.test.jsx b/src/features/activity-templates/pages/ActivityTemplatesPage.test.jsx new file mode 100644 index 0000000..a4cfd3e --- /dev/null +++ b/src/features/activity-templates/pages/ActivityTemplatesPage.test.jsx @@ -0,0 +1,193 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, expect, test, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ dataMounts: 0, page: null })); + +vi.mock("@mui/material/TablePagination", () => ({ default: () =>
})); + +vi.mock("@/features/activity-templates/hooks/useActivityTemplatesPage.js", () => ({ + useActivityTemplatesPage: () => mocks.page, +})); + +vi.mock("@/features/activity-templates/components/ActivityTemplateStatusBadge.jsx", () => ({ + ActivityTemplateStatusBadge: () => , +})); + +vi.mock("@/features/activity-templates/components/ActivityTemplateOverlaySkeletons.jsx", () => ({ + ActivityTemplateCloneDialogSkeleton: () =>
, + ActivityTemplateDrawerSkeleton: () =>
, +})); + +vi.mock("@/features/activity-templates/components/ActivityTemplateCloneDialog.jsx", () => ({ + ActivityTemplateCloneDialog: () =>
, +})); + +vi.mock("@/features/activity-templates/components/ActivityTemplateEditorDrawer.jsx", () => ({ + ActivityTemplateEditorDrawer: () =>
, +})); + +vi.mock("@/features/activity-templates/components/ActivityTemplateVersionsDrawer.jsx", () => ({ + ActivityTemplateVersionsDrawer: () =>
, +})); + +vi.mock("@/features/activity-templates/components/ActivityTemplateDataDrawer.jsx", () => ({ + ActivityTemplateDataDrawer: ({ source }) => ( +
{ + if (node && !node.dataset.mountCounted) { + node.dataset.mountCounted = "true"; + mocks.dataMounts += 1; + } + }} + > + {source.templateId} +
+ ), +})); + +vi.mock("@/shared/ui/AdminListLayout.jsx", () => ({ + AdminActionIconButton: ({ children, label, onClick }) => ( + + ), + AdminFilterResetButton: ({ onClick }) => ( + + ), + AdminFilterSelect: () =>
, + AdminListBody: ({ children }) =>
{children}
, + AdminListPage: ({ children }) =>
{children}
, + AdminListToolbar: ({ actions, filters }) => ( +
+ {filters} + {actions} +
+ ), + AdminRowActions: ({ children }) =>
{children}
, + AdminSearchBox: () => , +})); + +vi.mock("@/shared/ui/Button.jsx", () => ({ + Button: ({ children, startIcon, ...props }) => ( + + ), +})); + +vi.mock("@/shared/ui/DataState.jsx", () => ({ DataState: ({ children }) => children })); +vi.mock("@/shared/ui/DataTable.jsx", () => ({ DataTable: () =>
})); +vi.mock("@/shared/ui/RegionFilterControl.jsx", () => ({ RegionFilterControl: () =>
})); +vi.mock("@/shared/ui/TimeRangeFilter.jsx", () => ({ TimeRangeFilter: () =>
})); + +import { ActivityTemplatesPage } from "./ActivityTemplatesPage.jsx"; + +beforeEach(() => { + mocks.dataMounts = 0; + mocks.page = createPageState(); +}); + +test("keeps lazy overlays unmounted until their state opens", async () => { + const view = render(); + + expect(screen.queryByTestId("editor-overlay")).not.toBeInTheDocument(); + expect(screen.queryByTestId("clone-overlay")).not.toBeInTheDocument(); + expect(screen.queryByTestId("versions-overlay")).not.toBeInTheDocument(); + expect(screen.queryByTestId("data-overlay")).not.toBeInTheDocument(); + + mocks.page.editorMode = "create"; + view.rerender(); + expect(await screen.findByTestId("editor-overlay")).toBeInTheDocument(); +}); + +test("remounts the data center when the source template identity changes", async () => { + mocks.page.dataSource = { publishedVersion: 2, templateId: "template-a" }; + const view = render(); + + expect(await screen.findByText("template-a")).toBeInTheDocument(); + expect(mocks.dataMounts).toBe(1); + + mocks.page.dataSource = { publishedVersion: 1, templateId: "template-b" }; + view.rerender(); + expect(await screen.findByText("template-b")).toBeInTheDocument(); + expect(mocks.dataMounts).toBe(2); +}); + +function createPageState() { + const handler = vi.fn(); + return { + abilities: { + canCreate: true, + canData: true, + canDelete: true, + canExport: true, + canPublish: true, + canRetry: true, + canUpdate: true, + canView: true, + }, + archiveTemplate: handler, + changePageSize: handler, + changeStatus: handler, + cloneForm: { name: "", sourceVersion: 0, templateCode: "" }, + cloneSource: null, + cloneVersion: handler, + closeClone: handler, + closeData: handler, + closeEditor: handler, + closeVersions: handler, + data: { items: [], total: 0 }, + dataSource: null, + disableFromEditor: handler, + editorDirty: false, + editorLoading: false, + editorMode: "", + error: "", + form: { templateId: "" }, + gifts: [], + giftsLoading: false, + keyword: "", + loading: false, + loadingAction: "", + loadingRegions: false, + openClone: handler, + openCreate: handler, + openData: handler, + openEdit: handler, + openVersions: handler, + openView: handler, + page: 1, + pageSize: 20, + previewVersion: handler, + regionId: "", + regionOptions: [], + reload: handler, + reloadEditor: handler, + reloadVersions: handler, + resetFilters: handler, + resourceGroups: [], + saveAndPublish: handler, + setForm: handler, + setKeyword: handler, + setPage: handler, + setRegionId: handler, + setStatus: handler, + setTimeRange: handler, + setVersionPage: handler, + status: "", + submitClone: handler, + submitForm: handler, + timeRange: { endMs: "", startMs: "" }, + updateCloneForm: handler, + validationIssues: [], + versionPage: 1, + versionSource: null, + versions: { items: [], pageSize: 20, total: 0 }, + versionsError: "", + versionsLoading: false, + }; +} diff --git a/src/features/activity-templates/permissions.js b/src/features/activity-templates/permissions.js new file mode 100644 index 0000000..0a71609 --- /dev/null +++ b/src/features/activity-templates/permissions.js @@ -0,0 +1,17 @@ +import { useAuth } from "@/app/auth/AuthProvider.jsx"; +import { PERMISSIONS } from "@/app/permissions"; + +export function useActivityTemplateAbilities() { + const { can } = useAuth(); + + return { + canView: can(PERMISSIONS.activityTemplateView), + canCreate: can(PERMISSIONS.activityTemplateCreate), + canUpdate: can(PERMISSIONS.activityTemplateUpdate), + canPublish: can(PERMISSIONS.activityTemplatePublish), + canDelete: can(PERMISSIONS.activityTemplateDelete), + canData: can(PERMISSIONS.activityTemplateData), + canExport: can(PERMISSIONS.activityTemplateExport), + canRetry: can(PERMISSIONS.activityTemplateRetry), + }; +} diff --git a/src/features/activity-templates/routes.js b/src/features/activity-templates/routes.js new file mode 100644 index 0000000..c872f80 --- /dev/null +++ b/src/features/activity-templates/routes.js @@ -0,0 +1,12 @@ +import { MENU_CODES, PERMISSIONS } from "@/app/permissions"; + +export const activityTemplatesRoutes = [ + { + label: "活动模版", + loader: () => import("./pages/ActivityTemplatesPage.jsx").then((module) => module.ActivityTemplatesPage), + menuCode: MENU_CODES.activityTemplate, + pageKey: "activity-templates", + path: "/activities/templates", + permission: PERMISSIONS.activityTemplateView, + }, +]; diff --git a/src/features/activity-templates/schema.ts b/src/features/activity-templates/schema.ts new file mode 100644 index 0000000..24e861c --- /dev/null +++ b/src/features/activity-templates/schema.ts @@ -0,0 +1,310 @@ +import { z } from "zod"; + +const numberInput = z.union([z.string(), z.number()]); +const taskTypeSchema = z.enum(["daily_visit", "gift_count", "gift_coin_amount"]); + +const giftSchema = z.object({ + coinPrice: numberInput, + giftId: z.string().trim().min(1, "请选择礼物"), + iconUrl: z.string().trim().max(512, "礼物图标地址不能超过 512 个字符"), + name: z.string().trim().max(128, "礼物名称不能超过 128 个字符"), + priceVersion: z.string().trim().max(64, "价格版本不能超过 64 个字符"), +}); + +const taskSchema = z.object({ + rewardResourceGroupId: numberInput, + targetValue: numberInput, + taskKey: z + .string() + .trim() + .regex(/^[a-z][a-z0-9_-]{1,63}$/, "任务键需以小写字母开头,仅可使用小写字母、数字、_、-"), + taskType: taskTypeSchema, +}); + +const rankRewardSchema = z.object({ + rankFrom: numberInput, + rankTo: numberInput, + resourceGroupId: numberInput, +}); + +const assetSchema = z.object({ + assetKey: z + .string() + .trim() + .regex(/^[a-z][a-z0-9_.-]{1,63}$/, "素材键格式错误"), + label: z.string(), + locale: z.string(), + mediaType: z.enum(["image", "webp", "svga", "pag"]), + url: z.string().trim().max(1024, "素材地址不能超过 1024 个字符"), +}); + +export const activityTemplateFormSchema = z + .object({ + activityType: z.literal("gift_challenge"), + allRegions: z.boolean(), + assets: z.array(assetSchema).max(100, "素材最多配置 100 项"), + dailyLeaderboardSize: numberInput, + dailyRankRewards: z.array(rankRewardSchema), + displayConfigJson: z.string().max(64 * 1024, "展示配置不能超过 64KB"), + gifts: z.array(giftSchema).max(20, "礼物最多配置 20 个"), + name: z.string().trim().min(1, "请输入内部名称").max(128, "内部名称不能超过 128 个字符"), + regionIds: z.array(z.union([z.string(), z.number()])).max(100, "区域最多选择 100 个"), + revision: numberInput.optional(), + rulesAr: z.string().max(32 * 1024, "阿语规则不能超过 32KB"), + rulesEn: z.string().max(32 * 1024, "英语规则不能超过 32KB"), + tasks: z.array(taskSchema).max(30, "每日任务最多配置 30 项"), + templateCode: z + .string() + .trim() + .regex(/^[a-z][a-z0-9_-]{2,63}$/, "模版编码需以小写字母开头,长度 3-64,仅可使用小写字母、数字、_、-"), + templateId: z.string().optional(), + timeRange: z.object({ + endMs: z.union([z.string(), z.number()]), + startMs: z.union([z.string(), z.number()]), + }), + titleAr: z.string().trim().max(160, "阿语标题不能超过 160 个字符"), + titleEn: z.string().trim().max(160, "英语标题不能超过 160 个字符"), + totalLeaderboardSize: numberInput, + totalRankRewards: z.array(rankRewardSchema), + }) + .superRefine((value, context) => { + const startMs = numberValue(value.timeRange.startMs); + const endMs = numberValue(value.timeRange.endMs); + if ((startMs > 0 || endMs > 0) && (startMs <= 0 || endMs <= startMs)) { + context.addIssue({ code: "custom", message: "活动结束时间必须晚于开始时间", path: ["timeRange"] }); + } + if (startMs > 0 && endMs - startMs > 366 * 24 * 60 * 60 * 1000) { + context.addIssue({ code: "custom", message: "单次活动周期不能超过 366 天", path: ["timeRange"] }); + } + if (value.allRegions && value.regionIds.length) { + context.addIssue({ code: "custom", message: "全部区域与指定区域不能同时配置", path: ["regionIds"] }); + } + requireIntegerRange( + context, + ["dailyLeaderboardSize"], + value.dailyLeaderboardSize, + 3, + 1000, + "日榜上榜人数必须是 3-1000 的整数", + ); + requireIntegerRange( + context, + ["totalLeaderboardSize"], + value.totalLeaderboardSize, + 3, + 1000, + "总榜上榜人数必须是 3-1000 的整数", + ); + validateUnique( + context, + value.gifts.map((gift) => gift.giftId), + ["gifts"], + "同一礼物不能重复配置", + ); + validateUnique( + context, + value.tasks.map((task) => task.taskKey), + ["tasks"], + "任务键不能重复", + ); + if (value.tasks.filter((task) => task.taskType === "daily_visit").length > 1) { + context.addIssue({ + code: "custom", + message: "每日进入活动任务最多配置 1 个", + path: ["tasks"], + }); + } + value.tasks.forEach((task, index) => { + requirePositiveInteger(context, ["tasks", index, "targetValue"], task.targetValue, "任务目标必须是正整数"); + if (task.taskType === "daily_visit" && numberValue(task.targetValue) !== 1) { + context.addIssue({ + code: "custom", + message: "每日进入活动任务的目标固定为 1", + path: ["tasks", index, "targetValue"], + }); + } + validateOptionalPositiveInteger( + context, + ["tasks", index, "rewardResourceGroupId"], + task.rewardResourceGroupId, + "请选择有效奖励资源组", + ); + }); + validateRankRewards( + context, + value.dailyRankRewards, + numberValue(value.dailyLeaderboardSize), + "dailyRankRewards", + ); + validateRankRewards( + context, + value.totalRankRewards, + numberValue(value.totalLeaderboardSize), + "totalRankRewards", + ); + try { + const parsed = JSON.parse(value.displayConfigJson || "{}"); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("展示配置必须是 JSON 对象"); + } + } catch (error) { + context.addIssue({ + code: "custom", + message: error instanceof Error ? error.message : "展示配置 JSON 格式错误", + path: ["displayConfigJson"], + }); + } + }); + +export type ActivityTemplateForm = z.infer; + +export function validateActivityTemplateForPublish(form: ActivityTemplateForm) { + const issues: Array<{ message: string; path: Array }> = []; + const push = (path: Array, message: string) => issues.push({ message, path }); + const startMs = numberValue(form.timeRange.startMs); + const endMs = numberValue(form.timeRange.endMs); + if (startMs <= 0 || endMs <= startMs) { + push(["timeRange"], "发布前必须配置有效活动时间"); + } + if (!form.allRegions && !form.regionIds.length) { + push(["regionIds"], "发布前必须选择投放区域"); + } + if (!form.titleEn.trim()) { + push(["titleEn"], "发布前必须填写英语标题"); + } + if (!form.rulesEn.trim()) { + push(["rulesEn"], "发布前必须填写英语规则"); + } + if (!form.gifts.length) { + push(["gifts"], "发布前至少配置 1 个礼物"); + } + form.gifts.forEach((gift, index) => { + if (!gift.name || !gift.iconUrl || numberValue(gift.coinPrice) <= 0 || !gift.priceVersion) { + push(["gifts", index], "礼物缺少名称、图标、金币价格或价格版本快照"); + } + }); + if (!form.tasks.length) { + push(["tasks"], "发布前至少配置 1 个每日任务"); + } + form.tasks.forEach((task, index) => { + if (numberValue(task.rewardResourceGroupId) <= 0) { + push(["tasks", index, "rewardResourceGroupId"], "发布前必须选择任务奖励资源组"); + } + }); + for (const [key, rewards] of [ + ["dailyRankRewards", form.dailyRankRewards], + ["totalRankRewards", form.totalRankRewards], + ] as const) { + if (!rewards.length) { + push([key], key === "dailyRankRewards" ? "发布前必须配置日榜奖励" : "发布前必须配置总榜奖励"); + } + rewards.forEach((reward, index) => { + if (numberValue(reward.resourceGroupId) <= 0) { + push([key, index, "resourceGroupId"], "发布前必须选择榜单奖励资源组"); + } + }); + } + return issues; +} + +function validateRankRewards( + context: z.RefinementCtx, + rewards: Array<{ rankFrom: string | number; rankTo: string | number; resourceGroupId: string | number }>, + leaderboardSize: number, + pathKey: string, +) { + const sorted = rewards + .map((reward, index) => ({ + ...reward, + index, + from: numberValue(reward.rankFrom), + to: numberValue(reward.rankTo), + })) + .sort((left, right) => left.from - right.from); + let previousTo = 0; + sorted.forEach((reward) => { + if ( + !Number.isInteger(reward.from) || + !Number.isInteger(reward.to) || + reward.from <= 0 || + reward.to < reward.from + ) { + context.addIssue({ + code: "custom", + message: "奖励名次必须是有效闭区间", + path: [pathKey, reward.index, "rankFrom"], + }); + } + if (leaderboardSize > 0 && reward.to > leaderboardSize) { + context.addIssue({ + code: "custom", + message: "奖励名次不能超过上榜人数", + path: [pathKey, reward.index, "rankTo"], + }); + } + if (previousTo > 0 && reward.from <= previousTo) { + context.addIssue({ + code: "custom", + message: "奖励名次区间不能重叠", + path: [pathKey, reward.index, "rankFrom"], + }); + } + previousTo = Math.max(previousTo, reward.to); + validateOptionalPositiveInteger( + context, + [pathKey, reward.index, "resourceGroupId"], + reward.resourceGroupId, + "请选择有效奖励资源组", + ); + }); +} + +function validateUnique(context: z.RefinementCtx, values: string[], path: string[], message: string) { + const normalized = values.map((value) => value.trim()).filter(Boolean); + if (new Set(normalized).size !== normalized.length) { + context.addIssue({ code: "custom", message, path }); + } +} + +function requirePositiveInteger( + context: z.RefinementCtx, + path: Array, + value: unknown, + message: string, +) { + const number = numberValue(value); + if (!Number.isInteger(number) || number <= 0) { + context.addIssue({ code: "custom", message, path }); + } +} + +function validateOptionalPositiveInteger( + context: z.RefinementCtx, + path: Array, + value: unknown, + message: string, +) { + if (String(value ?? "").trim() === "") { + return; + } + requirePositiveInteger(context, path, value, message); +} + +function requireIntegerRange( + context: z.RefinementCtx, + path: string[], + value: unknown, + min: number, + max: number, + message: string, +) { + const number = numberValue(value); + if (!Number.isInteger(number) || number < min || number > max) { + context.addIssue({ code: "custom", message, path }); + } +} + +function numberValue(value: unknown) { + const number = Number(value); + return Number.isFinite(number) ? number : 0; +} diff --git a/src/features/roles/hooks/useRolesPage.js b/src/features/roles/hooks/useRolesPage.js index 4c985a4..9fc6878 100644 --- a/src/features/roles/hooks/useRolesPage.js +++ b/src/features/roles/hooks/useRolesPage.js @@ -39,6 +39,7 @@ const permissionGroupDefinitions = [ { key: "activities", prefixes: [ + "activity-template", "achievement", "agency-opening", "cp-config", @@ -56,7 +57,7 @@ const permissionGroupDefinitions = [ "vip-config", "weekly-star", ], - title: "活动管理", + title: "运营活动", }, { key: "games", prefixes: ["game"], title: "游戏管理" }, { key: "countries", prefixes: ["country"], title: "国家管理" }, diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index dc61526..ba3782a 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -37,8 +37,10 @@ export const API_OPERATIONS = { cancelJob: "cancelJob", cancelRoomPin: "cancelRoomPin", changePassword: "changePassword", + cloneActivityTemplate: "cloneActivityTemplate", closeAgency: "closeAgency", createAchievementDefinition: "createAchievementDefinition", + createActivityTemplate: "createActivityTemplate", createAgency: "createAgency", createAppVersion: "createAppVersion", createBanner: "createBanner", @@ -82,6 +84,7 @@ export const API_OPERATIONS = { dashboardUserProfileOverview: "dashboardUserProfileOverview", debitCoinSellerStock: "debitCoinSellerStock", deleteAchievementDefinition: "deleteAchievementDefinition", + deleteActivityTemplate: "deleteActivityTemplate", deleteAgency: "deleteAgency", deleteAppVersion: "deleteAppVersion", deleteBanner: "deleteBanner", @@ -117,6 +120,7 @@ export const API_OPERATIONS = { enableResourceGroup: "enableResourceGroup", enableResourceShopItem: "enableResourceShopItem", expireRoomRpsChallenge: "expireRoomRpsChallenge", + exportActivityTemplateData: "exportActivityTemplateData", exportCoinSellerLedger: "exportCoinSellerLedger", exportLoginLogs: "exportLoginLogs", exportOperationLogs: "exportOperationLogs", @@ -124,6 +128,8 @@ export const API_OPERATIONS = { exportUsers: "exportUsers", generateDiceRobots: "generateDiceRobots", generatePrettyIds: "generatePrettyIds", + getActivityTemplate: "getActivityTemplate", + getActivityTemplateData: "getActivityTemplateData", getCoinSellerSalaryRates: "getCoinSellerSalaryRates", getCountry: "getCountry", getCPConfig: "getCPConfig", @@ -175,6 +181,11 @@ export const API_OPERATIONS = { grantVip: "grantVip", grantVipTrialCard: "grantVipTrialCard", listAchievementDefinitions: "listAchievementDefinitions", + listActivityTemplateLeaderboard: "listActivityTemplateLeaderboard", + listActivityTemplateRewardJobs: "listActivityTemplateRewardJobs", + listActivityTemplates: "listActivityTemplates", + listActivityTemplateTaskClaims: "listActivityTemplateTaskClaims", + listActivityTemplateVersions: "listActivityTemplateVersions", listAgencies: "listAgencies", listApps: "listApps", listAppVersions: "listAppVersions", @@ -290,6 +301,8 @@ export const API_OPERATIONS = { replaceUserAppScopes: "replaceUserAppScopes", replaceUserFinanceScopes: "replaceUserFinanceScopes", resetUserPassword: "resetUserPassword", + retryActivityTemplateRewardJob: "retryActivityTemplateRewardJob", + retryActivityTemplateTaskClaim: "retryActivityTemplateTaskClaim", retryRedPacketRefund: "retryRedPacketRefund", retryRoomRpsSettlement: "retryRoomRpsSettlement", retryRoomTurnoverRewardSettlement: "retryRoomTurnoverRewardSettlement", @@ -297,6 +310,7 @@ export const API_OPERATIONS = { savePolicyTemplate: "savePolicyTemplate", search: "search", selfGameStatisticsOverview: "selfGameStatisticsOverview", + setActivityTemplateStatus: "setActivityTemplateStatus", setAgencyJoinEnabled: "setAgencyJoinEnabled", setAgencyStatus: "setAgencyStatus", setBDLeaderStatus: "setBDLeaderStatus", @@ -317,6 +331,7 @@ export const API_OPERATIONS = { syncThirdPartyPaymentMethods: "syncThirdPartyPaymentMethods", syncThirdPartyPaymentRates: "syncThirdPartyPaymentRates", updateAchievementDefinition: "updateAchievementDefinition", + updateActivityTemplate: "updateActivityTemplate", updateAppVersion: "updateAppVersion", updateBanner: "updateBanner", updateCatalog: "updateCatalog", @@ -378,6 +393,7 @@ export const API_OPERATIONS = { upsertResourceShopItems: "upsertResourceShopItems", upsertRule: "upsertRule", upsertWheelConfig: "upsertWheelConfig", + validateActivityTemplate: "validateActivityTemplate", verifyFinanceCoinSellerRechargeOrder: "verifyFinanceCoinSellerRechargeOrder", verifyFinanceCoinSellerRechargeReceipt: "verifyFinanceCoinSellerRechargeReceipt" } as const; @@ -551,6 +567,13 @@ export const API_ENDPOINTS: Record = { operationId: API_OPERATIONS.changePassword, path: "/v1/auth/change-password" }, + cloneActivityTemplate: { + method: "POST", + operationId: API_OPERATIONS.cloneActivityTemplate, + path: "/v1/admin/activities/templates/{template_id}/clone", + permission: "activity-template:create", + permissions: ["activity-template:create"] + }, closeAgency: { method: "POST", operationId: API_OPERATIONS.closeAgency, @@ -565,6 +588,13 @@ export const API_ENDPOINTS: Record = { permission: "achievement:create", permissions: ["achievement:create"] }, + createActivityTemplate: { + method: "POST", + operationId: API_OPERATIONS.createActivityTemplate, + path: "/v1/admin/activities/templates", + permission: "activity-template:create", + permissions: ["activity-template:create"] + }, createAgency: { method: "POST", operationId: API_OPERATIONS.createAgency, @@ -866,6 +896,13 @@ export const API_ENDPOINTS: Record = { permission: "achievement:update", permissions: ["achievement:update"] }, + deleteActivityTemplate: { + method: "DELETE", + operationId: API_OPERATIONS.deleteActivityTemplate, + path: "/v1/admin/activities/templates/{template_id}", + permission: "activity-template:delete", + permissions: ["activity-template:delete"] + }, deleteAgency: { method: "POST", operationId: API_OPERATIONS.deleteAgency, @@ -1111,6 +1148,13 @@ export const API_ENDPOINTS: Record = { permission: "game:update", permissions: ["game:update"] }, + exportActivityTemplateData: { + method: "GET", + operationId: API_OPERATIONS.exportActivityTemplateData, + path: "/v1/admin/activities/templates/{template_id}/data/export", + permission: "activity-template:export", + permissions: ["activity-template:export"] + }, exportCoinSellerLedger: { method: "GET", operationId: API_OPERATIONS.exportCoinSellerLedger, @@ -1160,6 +1204,20 @@ export const API_ENDPOINTS: Record = { permission: "pretty-id:generate", permissions: ["pretty-id:generate"] }, + getActivityTemplate: { + method: "GET", + operationId: API_OPERATIONS.getActivityTemplate, + path: "/v1/admin/activities/templates/{template_id}", + permission: "activity-template:view", + permissions: ["activity-template:view"] + }, + getActivityTemplateData: { + method: "GET", + operationId: API_OPERATIONS.getActivityTemplateData, + path: "/v1/admin/activities/templates/{template_id}/data", + permission: "activity-template:data", + permissions: ["activity-template:data"] + }, getCoinSellerSalaryRates: { method: "GET", operationId: API_OPERATIONS.getCoinSellerSalaryRates, @@ -1517,6 +1575,41 @@ export const API_ENDPOINTS: Record = { permission: "achievement:view", permissions: ["achievement:view"] }, + listActivityTemplateLeaderboard: { + method: "GET", + operationId: API_OPERATIONS.listActivityTemplateLeaderboard, + path: "/v1/admin/activities/templates/{template_id}/leaderboard", + permission: "activity-template:data", + permissions: ["activity-template:data"] + }, + listActivityTemplateRewardJobs: { + method: "GET", + operationId: API_OPERATIONS.listActivityTemplateRewardJobs, + path: "/v1/admin/activities/templates/{template_id}/rewards", + permission: "activity-template:data", + permissions: ["activity-template:data"] + }, + listActivityTemplates: { + method: "GET", + operationId: API_OPERATIONS.listActivityTemplates, + path: "/v1/admin/activities/templates", + permission: "activity-template:view", + permissions: ["activity-template:view"] + }, + listActivityTemplateTaskClaims: { + method: "GET", + operationId: API_OPERATIONS.listActivityTemplateTaskClaims, + path: "/v1/admin/activities/templates/{template_id}/task-claims", + permission: "activity-template:data", + permissions: ["activity-template:data"] + }, + listActivityTemplateVersions: { + method: "GET", + operationId: API_OPERATIONS.listActivityTemplateVersions, + path: "/v1/admin/activities/templates/{template_id}/versions", + permission: "activity-template:view", + permissions: ["activity-template:view"] + }, listAgencies: { method: "GET", operationId: API_OPERATIONS.listAgencies, @@ -2308,6 +2401,20 @@ export const API_ENDPOINTS: Record = { permission: "user:reset-password", permissions: ["user:reset-password"] }, + retryActivityTemplateRewardJob: { + method: "POST", + operationId: API_OPERATIONS.retryActivityTemplateRewardJob, + path: "/v1/admin/activities/templates/{template_id}/rewards/{reward_job_id}/retry", + permission: "activity-template:retry", + permissions: ["activity-template:retry"] + }, + retryActivityTemplateTaskClaim: { + method: "POST", + operationId: API_OPERATIONS.retryActivityTemplateTaskClaim, + path: "/v1/admin/activities/templates/{template_id}/task-claims/{claim_id}/retry", + permission: "activity-template:retry", + permissions: ["activity-template:retry"] + }, retryRedPacketRefund: { method: "POST", operationId: API_OPERATIONS.retryRedPacketRefund, @@ -2355,6 +2462,13 @@ export const API_ENDPOINTS: Record = { permission: "overview:view", permissions: ["overview:view"] }, + setActivityTemplateStatus: { + method: "PATCH", + operationId: API_OPERATIONS.setActivityTemplateStatus, + path: "/v1/admin/activities/templates/{template_id}/status", + permission: "activity-template:publish", + permissions: ["activity-template:publish"] + }, setAgencyJoinEnabled: { method: "POST", operationId: API_OPERATIONS.setAgencyJoinEnabled, @@ -2495,6 +2609,13 @@ export const API_ENDPOINTS: Record = { permission: "achievement:update", permissions: ["achievement:update"] }, + updateActivityTemplate: { + method: "PUT", + operationId: API_OPERATIONS.updateActivityTemplate, + path: "/v1/admin/activities/templates/{template_id}", + permission: "activity-template:update", + permissions: ["activity-template:update"] + }, updateAppVersion: { method: "PUT", operationId: API_OPERATIONS.updateAppVersion, @@ -2922,6 +3043,13 @@ export const API_ENDPOINTS: Record = { permission: "wheel:update", permissions: ["wheel:update"] }, + validateActivityTemplate: { + method: "POST", + operationId: API_OPERATIONS.validateActivityTemplate, + path: "/v1/admin/activities/templates/validate", + permission: "activity-template:update", + permissions: ["activity-template:create","activity-template:update","activity-template:publish"] + }, verifyFinanceCoinSellerRechargeOrder: { method: "POST", operationId: API_OPERATIONS.verifyFinanceCoinSellerRechargeOrder, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 4457815..bc8034e 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -4396,6 +4396,238 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/activities/templates": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listActivityTemplates"]; + put?: never; + post: operations["createActivityTemplate"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/activities/templates/validate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["validateActivityTemplate"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/activities/templates/{template_id}": { + parameters: { + query?: never; + header?: never; + path: { + template_id: string; + }; + cookie?: never; + }; + get: operations["getActivityTemplate"]; + put: operations["updateActivityTemplate"]; + post?: never; + delete: operations["deleteActivityTemplate"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/activities/templates/{template_id}/status": { + parameters: { + query?: never; + header?: never; + path: { + template_id: string; + }; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch: operations["setActivityTemplateStatus"]; + trace?: never; + }; + "/admin/activities/templates/{template_id}/clone": { + parameters: { + query?: never; + header?: never; + path: { + template_id: string; + }; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["cloneActivityTemplate"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/activities/templates/{template_id}/versions": { + parameters: { + query?: never; + header?: never; + path: { + template_id: string; + }; + cookie?: never; + }; + get: operations["listActivityTemplateVersions"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/activities/templates/{template_id}/data": { + parameters: { + query?: never; + header?: never; + path: { + template_id: string; + }; + cookie?: never; + }; + get: operations["getActivityTemplateData"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/activities/templates/{template_id}/data/export": { + parameters: { + query?: never; + header?: never; + path: { + template_id: string; + }; + cookie?: never; + }; + get: operations["exportActivityTemplateData"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/activities/templates/{template_id}/leaderboard": { + parameters: { + query?: never; + header?: never; + path: { + template_id: string; + }; + cookie?: never; + }; + get: operations["listActivityTemplateLeaderboard"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/activities/templates/{template_id}/rewards": { + parameters: { + query?: never; + header?: never; + path: { + template_id: string; + }; + cookie?: never; + }; + get: operations["listActivityTemplateRewardJobs"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/activities/templates/{template_id}/rewards/{reward_job_id}/retry": { + parameters: { + query?: never; + header?: never; + path: { + template_id: string; + reward_job_id: string; + }; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["retryActivityTemplateRewardJob"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/activities/templates/{template_id}/task-claims": { + parameters: { + query?: never; + header?: never; + path: { + template_id: string; + }; + cookie?: never; + }; + get: operations["listActivityTemplateTaskClaims"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/activities/templates/{template_id}/task-claims/{claim_id}/retry": { + parameters: { + query?: never; + header?: never; + path: { + template_id: string; + claim_id: string; + }; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["retryActivityTemplateTaskClaim"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -6020,6 +6252,465 @@ export interface components { mode: "all" | "selected" | "none"; appCodes: string[]; }; + ActivityTemplateLocale: { + locale: string; + title: string; + rules: string; + }; + ActivityTemplateGift: { + gift_id: string; + /** Format: int32 */ + sort_order: number; + name: string; + icon_url: string; + /** Format: int64 */ + coin_price: number; + price_version: string; + }; + ActivityTemplateRewardItem: { + item_type?: string; + /** Format: int64 */ + resource_id?: number; + resource_type?: string; + name?: string; + icon_url?: string; + /** Format: int64 */ + quantity?: number; + /** Format: int64 */ + duration_ms?: number; + wallet_asset_type?: string; + /** Format: int64 */ + wallet_asset_amount?: number; + /** Format: int32 */ + sort_order?: number; + }; + ActivityTemplateTask: { + task_key: string; + /** @enum {string} */ + task_type: "daily_visit" | "gift_count" | "gift_coin_amount"; + /** Format: int64 */ + target_value: number; + /** Format: int64 */ + reward_resource_group_id: number; + reward_snapshot_id?: string; + reward_snapshot_hash?: string; + reward_name?: string; + reward_items?: components["schemas"]["ActivityTemplateRewardItem"][]; + /** Format: int32 */ + sort_order: number; + }; + ActivityTemplateRankReward: { + /** @enum {string} */ + board_type: "daily" | "total"; + /** Format: int32 */ + rank_from: number; + /** Format: int32 */ + rank_to: number; + /** Format: int64 */ + resource_group_id: number; + reward_snapshot_id?: string; + reward_snapshot_hash?: string; + reward_name?: string; + reward_items?: components["schemas"]["ActivityTemplateRewardItem"][]; + /** Format: int32 */ + sort_order: number; + }; + ActivityTemplateAsset: { + asset_key: string; + locale: string; + url: string; + /** @enum {string} */ + media_type: "image" | "webp" | "svga" | "pag"; + /** Format: int32 */ + sort_order: number; + }; + ActivityTemplateFields: { + template_code: string; + name: string; + /** @constant */ + activity_type: "gift_challenge"; + /** Format: int64 */ + start_ms: number; + /** Format: int64 */ + end_ms: number; + all_regions: boolean; + region_ids: number[]; + locales: components["schemas"]["ActivityTemplateLocale"][]; + gifts: components["schemas"]["ActivityTemplateGift"][]; + tasks: components["schemas"]["ActivityTemplateTask"][]; + /** Format: int32 */ + daily_leaderboard_size: number; + /** Format: int32 */ + total_leaderboard_size: number; + rank_rewards: components["schemas"]["ActivityTemplateRankReward"][]; + assets: components["schemas"]["ActivityTemplateAsset"][]; + display_config_json: string; + }; + ActivityTemplateInput: components["schemas"]["ActivityTemplateFields"]; + ActivityTemplateUpdateInput: components["schemas"]["ActivityTemplateFields"] & { + /** Format: int64 */ + expected_revision: number; + }; + ActivityTemplateStatusInput: { + /** @enum {string} */ + status: "published" | "disabled"; + /** Format: int64 */ + expected_revision: number; + }; + ActivityTemplateCloneInput: { + /** Format: int64 */ + source_version: number; + template_code: string; + name: string; + }; + ActivityTemplateValidationInput: { + template: components["schemas"]["ActivityTemplateInput"]; + for_publish: boolean; + }; + ActivityTemplateSummary: { + app_code: string; + template_id: string; + template_code: string; + name: string; + /** @constant */ + activity_type: "gift_challenge"; + /** @enum {string} */ + status: "draft" | "published" | "disabled" | "archived"; + /** @enum {string} */ + lifecycle_status: "draft" | "scheduled" | "ongoing" | "ended" | "disabled" | "archived"; + /** Format: int64 */ + start_ms: number; + /** Format: int64 */ + end_ms: number; + all_regions: boolean; + region_ids: number[]; + /** Format: int64 */ + revision: number; + /** Format: int64 */ + published_version: number; + /** Format: int64 */ + created_by_admin_id: number; + /** Format: int64 */ + updated_by_admin_id: number; + /** Format: int64 */ + created_at_ms: number; + /** Format: int64 */ + updated_at_ms: number; + }; + ActivityTemplate: components["schemas"]["ActivityTemplateSummary"] & { + locales: components["schemas"]["ActivityTemplateLocale"][]; + gifts: components["schemas"]["ActivityTemplateGift"][]; + tasks: components["schemas"]["ActivityTemplateTask"][]; + /** Format: int32 */ + daily_leaderboard_size: number; + /** Format: int32 */ + total_leaderboard_size: number; + rank_rewards: components["schemas"]["ActivityTemplateRankReward"][]; + assets: components["schemas"]["ActivityTemplateAsset"][]; + display_config_json: string; + /** Format: int64 */ + published_by_admin_id: number; + /** Format: int64 */ + published_at_ms: number; + /** Format: int64 */ + archived_at_ms: number; + }; + ActivityTemplatePage: { + items: components["schemas"]["ActivityTemplateSummary"][]; + page: number; + page_size: number; + /** Format: int64 */ + total: number; + /** Format: int64 */ + server_time_ms: number; + }; + ActivityTemplateVersion: { + template_id: string; + /** Format: int64 */ + version_no: number; + snapshot: components["schemas"]["ActivityTemplate"]; + /** Format: int64 */ + published_by_admin_id: number; + /** Format: int64 */ + published_at_ms: number; + /** Format: int64 */ + runtime_from_ms: number; + /** Format: int64 */ + runtime_to_ms: number; + }; + ActivityTemplateVersionPage: { + items: components["schemas"]["ActivityTemplateVersion"][]; + page: number; + page_size: number; + /** Format: int64 */ + total: number; + }; + ActivityTemplateArchive: { + template: components["schemas"]["ActivityTemplate"]; + archived: boolean; + }; + ActivityTemplateValidationIssue: { + field: string; + code: string; + message: string; + }; + ActivityTemplateValidation: { + valid: boolean; + issues: components["schemas"]["ActivityTemplateValidationIssue"][]; + }; + ApiResponseActivityTemplatePage: components["schemas"]["Envelope"] & { + data?: components["schemas"]["ActivityTemplatePage"]; + }; + ApiResponseActivityTemplate: components["schemas"]["Envelope"] & { + data?: components["schemas"]["ActivityTemplate"]; + }; + ApiResponseActivityTemplateArchive: components["schemas"]["Envelope"] & { + data?: components["schemas"]["ActivityTemplateArchive"]; + }; + ApiResponseActivityTemplateValidation: components["schemas"]["Envelope"] & { + data?: components["schemas"]["ActivityTemplateValidation"]; + }; + ApiResponseActivityTemplateVersionPage: components["schemas"]["Envelope"] & { + data?: components["schemas"]["ActivityTemplateVersionPage"]; + }; + ActivityTemplateDataOverview: { + template_id: string; + template_code: string; + /** Format: int64 */ + published_version: number; + /** Format: int64 */ + participants: number; + /** Format: int64 */ + visit_count: number; + /** Format: int64 */ + gift_users: number; + /** Format: int64 */ + task_users: number; + /** Format: int64 */ + gift_event_count: number; + /** Format: int64 */ + gift_count: number; + /** Format: int64 */ + coin_amount: number; + /** Format: int64 */ + task_progress: number; + /** Format: int64 */ + task_completed: number; + /** Format: int64 */ + task_claimed: number; + }; + ActivityTemplateDataTrendPoint: { + stat_day: string; + /** Format: int64 */ + participants: number; + /** Format: int64 */ + visit_count: number; + /** Format: int64 */ + gift_users: number; + /** Format: int64 */ + task_users: number; + /** Format: int64 */ + gift_event_count: number; + /** Format: int64 */ + gift_count: number; + /** Format: int64 */ + coin_amount: number; + /** Format: int64 */ + task_progress: number; + /** Format: int64 */ + task_completed: number; + /** Format: int64 */ + task_claimed: number; + }; + ActivityTemplateGiftStat: { + gift_id: string; + /** Format: int64 */ + gift_users: number; + /** Format: int64 */ + gift_event_count: number; + /** Format: int64 */ + gift_count: number; + /** Format: int64 */ + coin_amount: number; + }; + ActivityTemplateTaskStat: { + task_key: string; + task_type: string; + /** Format: int64 */ + task_users: number; + /** Format: int64 */ + task_progress: number; + /** Format: int64 */ + task_completed: number; + /** Format: int64 */ + task_claimed: number; + }; + ActivityTemplateData: { + overview: components["schemas"]["ActivityTemplateDataOverview"]; + trend: components["schemas"]["ActivityTemplateDataTrendPoint"][]; + gifts: components["schemas"]["ActivityTemplateGiftStat"][]; + tasks: components["schemas"]["ActivityTemplateTaskStat"][]; + granularity: string; + timezone: string; + /** Format: int64 */ + start_ms: number; + /** Format: int64 */ + end_ms: number; + /** Format: int64 */ + region_id: number; + dimension_limit: number; + gift_sort: string; + task_sort: string; + /** Format: int64 */ + server_time_ms: number; + }; + ActivityTemplateRankPeriod: { + template_id: string; + template_code: string; + /** Format: int64 */ + version_no: number; + /** @enum {string} */ + board_type: "daily" | "total"; + period_key: string; + /** Format: int64 */ + period_start_ms: number; + /** Format: int64 */ + period_end_ms: number; + /** Format: int64 */ + settle_after_ms: number; + status: string; + /** Format: int64 */ + settled_at_ms: number; + }; + ActivityTemplateLeaderboardEntry: { + rank_no: number; + /** Format: int64 */ + user_id: number; + /** Format: int64 */ + score: number; + /** Format: int64 */ + gift_count: number; + /** Format: int64 */ + coin_amount: number; + /** Format: int64 */ + first_scored_at_ms: number; + snapshot: boolean; + user?: components["schemas"]["ActivityTemplateUser"]; + }; + ActivityTemplateLeaderboardPage: { + period: components["schemas"]["ActivityTemplateRankPeriod"]; + items: components["schemas"]["ActivityTemplateLeaderboardEntry"][]; + page: number; + page_size: number; + /** Format: int64 */ + total: number; + }; + ActivityTemplateRewardJob: { + reward_job_id: string; + template_id: string; + template_code: string; + /** Format: int64 */ + version_no: number; + /** @enum {string} */ + board_type: "daily" | "total"; + period_key: string; + rank_no: number; + /** Format: int64 */ + user_id: number; + /** Format: int64 */ + score: number; + /** Format: int64 */ + resource_group_id: number; + reward_snapshot_id: string; + reward_snapshot_hash: string; + reward_name: string; + reward_items: components["schemas"]["ActivityTemplateRewardItem"][]; + wallet_command_id: string; + wallet_grant_id: string; + status: string; + failure_reason: string; + attempt_count: number; + max_attempts: number; + /** Format: int64 */ + next_retry_at_ms: number; + /** Format: int64 */ + created_at_ms: number; + /** Format: int64 */ + updated_at_ms: number; + /** Format: int64 */ + granted_at_ms: number; + user?: components["schemas"]["ActivityTemplateUser"]; + }; + ActivityTemplateRewardJobPage: { + items: components["schemas"]["ActivityTemplateRewardJob"][]; + page: number; + page_size: number; + /** Format: int64 */ + total: number; + }; + ActivityTemplateTaskClaim: { + claim_id: string; + template_id: string; + template_code: string; + /** Format: int64 */ + version_no: number; + /** Format: date */ + task_day: string; + task_key: string; + /** Format: int64 */ + user_id: number; + /** Format: int64 */ + earning_region_id: number; + /** Format: int64 */ + attribution_at_ms: number; + /** Format: int64 */ + reward_resource_group_id: number; + reward_snapshot_id: string; + reward_snapshot_hash: string; + reward_name: string; + reward_items: components["schemas"]["ActivityTemplateRewardItem"][]; + wallet_grant_id: string; + status: string; + failure_reason: string; + attempt_count: number; + /** Format: int64 */ + created_at_ms: number; + /** Format: int64 */ + updated_at_ms: number; + /** Format: int64 */ + granted_at_ms: number; + user?: components["schemas"]["ActivityTemplateUser"]; + }; + ActivityTemplateTaskClaimPage: { + items: components["schemas"]["ActivityTemplateTaskClaim"][]; + page: number; + page_size: number; + /** Format: int64 */ + total: number; + }; + ApiResponseActivityTemplateData: components["schemas"]["Envelope"] & { + data?: components["schemas"]["ActivityTemplateData"]; + }; + ApiResponseActivityTemplateLeaderboardPage: components["schemas"]["Envelope"] & { + data?: components["schemas"]["ActivityTemplateLeaderboardPage"]; + }; + ApiResponseActivityTemplateRewardJobPage: components["schemas"]["Envelope"] & { + data?: components["schemas"]["ActivityTemplateRewardJobPage"]; + }; + ApiResponseActivityTemplateRewardJob: components["schemas"]["Envelope"] & { + data?: components["schemas"]["ActivityTemplateRewardJob"]; + }; + ApiResponseActivityTemplateTaskClaimPage: components["schemas"]["Envelope"] & { + data?: components["schemas"]["ActivityTemplateTaskClaimPage"]; + }; + ApiResponseActivityTemplateTaskClaim: components["schemas"]["Envelope"] & { + data?: components["schemas"]["ActivityTemplateTaskClaim"]; + }; + ActivityTemplateUser: { + display_user_id: string; + username: string; + avatar: string; + }; }; responses: { /** @description OK */ @@ -6571,6 +7262,105 @@ export interface components { "application/json": components["schemas"]["ApiResponseUser"]; }; }; + /** @description OK */ + ActivityTemplatePageResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponseActivityTemplatePage"]; + }; + }; + /** @description OK */ + ActivityTemplateResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponseActivityTemplate"]; + }; + }; + /** @description OK */ + ActivityTemplateArchiveResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponseActivityTemplateArchive"]; + }; + }; + /** @description OK */ + ActivityTemplateValidationResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponseActivityTemplateValidation"]; + }; + }; + /** @description OK */ + ActivityTemplateVersionPageResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponseActivityTemplateVersionPage"]; + }; + }; + /** @description OK */ + ActivityTemplateDataResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponseActivityTemplateData"]; + }; + }; + /** @description OK */ + ActivityTemplateLeaderboardPageResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponseActivityTemplateLeaderboardPage"]; + }; + }; + /** @description OK */ + ActivityTemplateRewardJobPageResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponseActivityTemplateRewardJobPage"]; + }; + }; + /** @description OK */ + ActivityTemplateRewardJobResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponseActivityTemplateRewardJob"]; + }; + }; + /** @description OK */ + ActivityTemplateTaskClaimPageResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponseActivityTemplateTaskClaimPage"]; + }; + }; + /** @description OK */ + ActivityTemplateTaskClaimResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponseActivityTemplateTaskClaim"]; + }; + }; }; parameters: { AppCodeHeader: string; @@ -12077,4 +12867,289 @@ export interface operations { 200: components["responses"]["EmptyResponse"]; }; }; + listActivityTemplates: { + parameters: { + query?: { + keyword?: components["parameters"]["Keyword"]; + status?: components["parameters"]["Status"]; + region_id?: components["parameters"]["RegionId"]; + start_ms?: number; + end_ms?: number; + all_regions?: boolean; + page?: components["parameters"]["Page"]; + page_size?: components["parameters"]["PageSize"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["ActivityTemplatePageResponse"]; + }; + }; + createActivityTemplate: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ActivityTemplateInput"]; + }; + }; + responses: { + 201: components["responses"]["ActivityTemplateResponse"]; + }; + }; + validateActivityTemplate: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ActivityTemplateValidationInput"]; + }; + }; + responses: { + 200: components["responses"]["ActivityTemplateValidationResponse"]; + }; + }; + getActivityTemplate: { + parameters: { + query?: never; + header?: never; + path: { + template_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["ActivityTemplateResponse"]; + }; + }; + updateActivityTemplate: { + parameters: { + query?: never; + header?: never; + path: { + template_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ActivityTemplateUpdateInput"]; + }; + }; + responses: { + 200: components["responses"]["ActivityTemplateResponse"]; + }; + }; + deleteActivityTemplate: { + parameters: { + query: { + revision: number; + }; + header?: never; + path: { + template_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["ActivityTemplateArchiveResponse"]; + }; + }; + setActivityTemplateStatus: { + parameters: { + query?: never; + header?: never; + path: { + template_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ActivityTemplateStatusInput"]; + }; + }; + responses: { + 200: components["responses"]["ActivityTemplateResponse"]; + }; + }; + cloneActivityTemplate: { + parameters: { + query?: never; + header?: never; + path: { + template_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ActivityTemplateCloneInput"]; + }; + }; + responses: { + 201: components["responses"]["ActivityTemplateResponse"]; + }; + }; + listActivityTemplateVersions: { + parameters: { + query?: { + page?: components["parameters"]["Page"]; + page_size?: components["parameters"]["PageSize"]; + }; + header?: never; + path: { + template_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["ActivityTemplateVersionPageResponse"]; + }; + }; + getActivityTemplateData: { + parameters: { + query: { + version_no: number; + start_ms: number; + end_ms: number; + region_id?: number; + }; + header?: never; + path: { + template_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["ActivityTemplateDataResponse"]; + }; + }; + exportActivityTemplateData: { + parameters: { + query: { + version_no: number; + start_ms: number; + end_ms: number; + region_id?: number; + }; + header?: never; + path: { + template_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["ActivityTemplateDataResponse"]; + }; + }; + listActivityTemplateLeaderboard: { + parameters: { + query: { + version_no: number; + board_type: "daily" | "total"; + period_key?: string; + page?: components["parameters"]["Page"]; + page_size?: components["parameters"]["PageSize"]; + }; + header?: never; + path: { + template_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["ActivityTemplateLeaderboardPageResponse"]; + }; + }; + listActivityTemplateRewardJobs: { + parameters: { + query: { + version_no: number; + board_type?: "daily" | "total"; + period_key?: string; + status?: "pending" | "running" | "granted" | "failed" | "dead"; + page?: components["parameters"]["Page"]; + page_size?: components["parameters"]["PageSize"]; + }; + header?: never; + path: { + template_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["ActivityTemplateRewardJobPageResponse"]; + }; + }; + retryActivityTemplateRewardJob: { + parameters: { + query?: never; + header?: never; + path: { + template_id: string; + reward_job_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["ActivityTemplateRewardJobResponse"]; + }; + }; + listActivityTemplateTaskClaims: { + parameters: { + query?: { + version_no?: number; + task_day?: string; + task_key?: string; + user_id?: number; + status?: "pending" | "running" | "granted" | "failed"; + page?: components["parameters"]["Page"]; + page_size?: components["parameters"]["PageSize"]; + }; + header?: never; + path: { + template_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["ActivityTemplateTaskClaimPageResponse"]; + }; + }; + retryActivityTemplateTaskClaim: { + parameters: { + query?: never; + header?: never; + path: { + template_id: string; + claim_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["ActivityTemplateTaskClaimResponse"]; + }; + }; } diff --git a/src/shared/charts/MultiSeriesLineChart.jsx b/src/shared/charts/MultiSeriesLineChart.jsx new file mode 100644 index 0000000..bd15e75 --- /dev/null +++ b/src/shared/charts/MultiSeriesLineChart.jsx @@ -0,0 +1,141 @@ +import { useEffect, useMemo, useState } from "react"; +import { EChart } from "@/shared/charts/EChart.jsx"; +import { readCssToken } from "@/shared/utils/cssTokens.js"; + +const numberFormatter = new Intl.NumberFormat("zh-CN"); +const defaultColorTokens = ["--chart-blue", "--chart-green", "--chart-orange", "--chart-red", "--info"]; + +export function MultiSeriesLineChart({ ariaLabel, categories = [], emptyText = "当前无数据", series = [] }) { + const reducedMotion = usePrefersReducedMotion(); + const normalized = useMemo(() => normalizeSeries(series, categories.length), [categories.length, series]); + const option = useMemo( + () => createMultiSeriesLineOption(categories, normalized, reducedMotion), + [categories, normalized, reducedMotion], + ); + + if (!categories.length || !normalized.length) { + return ( +
+ {emptyText} +
+ ); + } + + return ( +
+ +
+ ); +} + +export function normalizeSeries(series, categoryCount) { + return (Array.isArray(series) ? series : []) + .map((item, index) => ({ + color: readCssToken(item?.colorToken || defaultColorTokens[index % defaultColorTokens.length], "#64748b"), + data: Array.from({ length: categoryCount }, (_, dataIndex) => { + const value = Number(item?.data?.[dataIndex]); + return Number.isFinite(value) ? value : 0; + }), + key: String(item?.key || `series-${index}`), + name: String(item?.name || item?.key || `指标 ${index + 1}`), + yAxisIndex: Number(item?.yAxisIndex) === 1 ? 1 : 0, + })) + .filter((item) => item.data.length > 0); +} + +export function createMultiSeriesLineOption(categories, series, reducedMotion = false) { + const gridColor = readCssToken("--chart-grid", "#e5e7eb"); + const axisColor = readCssToken("--text-tertiary", "#64748b"); + const hasSecondaryAxis = series.some((item) => item.yAxisIndex === 1); + const yAxes = [createValueAxis(gridColor, axisColor, true)]; + if (hasSecondaryAxis) { + yAxes.push(createValueAxis(gridColor, axisColor, false)); + } + + return { + animation: !reducedMotion, + animationDuration: reducedMotion ? 0 : 450, + animationDurationUpdate: reducedMotion ? 0 : 300, + color: series.map((item) => item.color), + grid: { bottom: 18, containLabel: true, left: 8, right: hasSecondaryAxis ? 16 : 8, top: 44 }, + legend: { + icon: "roundRect", + itemHeight: 7, + itemWidth: 14, + textStyle: { color: readCssToken("--text-secondary", "#475569"), fontSize: 11 }, + top: 6, + }, + tooltip: { + trigger: "axis", + backgroundColor: readCssToken("--chart-tooltip-bg", "#ffffff"), + borderColor: readCssToken("--chart-tooltip-border", "#d8e0ec"), + textStyle: { color: readCssToken("--chart-tooltip-text", "#0f172a") }, + valueFormatter: (value) => numberFormatter.format(Number(value) || 0), + }, + xAxis: { + type: "category", + boundaryGap: false, + data: categories, + axisLabel: { color: axisColor, fontSize: 10, hideOverlap: true }, + axisLine: { lineStyle: { color: gridColor } }, + axisTick: { show: false }, + }, + yAxis: yAxes, + series: series.map((item) => ({ + type: "line", + name: item.name, + data: item.data, + yAxisIndex: item.yAxisIndex, + smooth: 0.25, + showSymbol: false, + symbolSize: 5, + lineStyle: { color: item.color, width: 2 }, + itemStyle: { color: item.color }, + emphasis: { focus: "series" }, + })), + }; +} + +function createValueAxis(gridColor, axisColor, splitLine) { + return { + type: "value", + min: 0, + axisLabel: { + color: axisColor, + fontSize: 10, + formatter: (value) => compactNumber(Number(value) || 0), + }, + axisLine: { show: false }, + axisTick: { show: false }, + splitLine: { show: splitLine, lineStyle: { color: gridColor, type: "dashed" } }, + }; +} + +function compactNumber(value) { + if (Math.abs(value) >= 100000000) return `${(value / 100000000).toFixed(1)}亿`; + if (Math.abs(value) >= 10000) return `${(value / 10000).toFixed(1)}万`; + return numberFormatter.format(value); +} + +function usePrefersReducedMotion() { + const [reducedMotion, setReducedMotion] = useState(() => readReducedMotionPreference()); + + useEffect(() => { + if (typeof window.matchMedia !== "function") return undefined; + const media = window.matchMedia("(prefers-reduced-motion: reduce)"); + const update = (event) => setReducedMotion(event.matches); + setReducedMotion(media.matches); + media.addEventListener?.("change", update); + return () => media.removeEventListener?.("change", update); + }, []); + + return reducedMotion; +} + +function readReducedMotionPreference() { + return ( + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ); +} diff --git a/src/shared/charts/MultiSeriesLineChart.test.jsx b/src/shared/charts/MultiSeriesLineChart.test.jsx new file mode 100644 index 0000000..04dcf28 --- /dev/null +++ b/src/shared/charts/MultiSeriesLineChart.test.jsx @@ -0,0 +1,48 @@ +import { render, screen } from "@testing-library/react"; +import { expect, test, vi } from "vitest"; + +vi.mock("@/shared/charts/EChart.jsx", () => ({ + EChart: ({ className }) =>
, +})); + +import { + createMultiSeriesLineOption, + MultiSeriesLineChart, + normalizeSeries, +} from "@/shared/charts/MultiSeriesLineChart.jsx"; + +test("renders the standard empty state without creating an ECharts instance", () => { + render(); + + expect(screen.getByRole("status", { name: "空趋势" })).toHaveTextContent("当前无数据"); + expect(screen.queryByTestId("multi-series-echart")).not.toBeInTheDocument(); +}); + +test("normalizes invalid points and configures dual axes", () => { + const series = normalizeSeries( + [ + { data: [1, "bad"], key: "users", name: "用户" }, + { data: [100, 200], key: "coins", name: "金币", yAxisIndex: 1 }, + ], + 2, + ); + const option = createMultiSeriesLineOption(["2026-01-01", "2026-01-02"], series, true); + + expect(series[0].data).toEqual([1, 0]); + expect(option.yAxis).toHaveLength(2); + expect(option.series[1].yAxisIndex).toBe(1); + expect(option).toMatchObject({ animation: false, animationDuration: 0, animationDurationUpdate: 0 }); +}); + +test("renders the shared ECharts wrapper when data is available", () => { + render( + , + ); + + expect(screen.getByRole("group", { name: "活动趋势" })).toBeInTheDocument(); + expect(screen.getByTestId("multi-series-echart")).toBeInTheDocument(); +}); diff --git a/src/styles/tokens.css b/src/styles/tokens.css index d74ef72..9d18c47 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -105,6 +105,7 @@ --chart-green: #16a34a; --chart-red: #dc2626; --chart-orange: #d97706; + --chart-violet: #7c3aed; --chart-grid: rgba(100, 116, 139, 0.16); --chart-tooltip-bg: #ffffff; --chart-tooltip-border: #d8e0ec;