活动模版
This commit is contained in:
parent
da6d2dfdb9
commit
2eb18502a2
File diff suppressed because it is too large
Load Diff
35
generated/admin-modules/activity-templates/permissions.md
Normal file
35
generated/admin-modules/activity-templates/permissions.md
Normal file
@ -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 |
|
||||||
@ -40,6 +40,7 @@ import { getRouteByMenuCode } from "@/app/router/routeConfig";
|
|||||||
|
|
||||||
const deprecatedMenuCodes = new Set(["services"]);
|
const deprecatedMenuCodes = new Set(["services"]);
|
||||||
const menuLabelOverrides = {
|
const menuLabelOverrides = {
|
||||||
|
activities: "运营活动",
|
||||||
system: "后台设置",
|
system: "后台设置",
|
||||||
"system-users": "后台用户",
|
"system-users": "后台用户",
|
||||||
"system-teams": "团队配置",
|
"system-teams": "团队配置",
|
||||||
@ -212,8 +213,9 @@ export const fallbackNavigation = [
|
|||||||
code: "activities",
|
code: "activities",
|
||||||
icon: CampaignOutlined,
|
icon: CampaignOutlined,
|
||||||
id: "activities",
|
id: "activities",
|
||||||
label: "活动管理",
|
label: "运营活动",
|
||||||
children: [
|
children: [
|
||||||
|
routeNavItem("activity-template", { icon: CampaignOutlined }),
|
||||||
routeNavItem("daily-task-list", { icon: TaskAltOutlined }),
|
routeNavItem("daily-task-list", { icon: TaskAltOutlined }),
|
||||||
routeNavItem("registration-reward", { icon: CardGiftcardOutlined }),
|
routeNavItem("registration-reward", { icon: CardGiftcardOutlined }),
|
||||||
routeNavItem("first-recharge-reward", { icon: CardGiftcardOutlined }),
|
routeNavItem("first-recharge-reward", { icon: CardGiftcardOutlined }),
|
||||||
|
|||||||
@ -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", () => {
|
test("normalizes requested parent menu order from backend menus", () => {
|
||||||
const mapped = mapBackendMenus([
|
const mapped = mapBackendMenus([
|
||||||
{ children: [], code: "system", icon: "settings", id: "system", label: "后台设置" },
|
{ children: [], code: "system", icon: "settings", id: "system", label: "后台设置" },
|
||||||
|
|||||||
@ -159,6 +159,14 @@ export const PERMISSIONS = {
|
|||||||
giftDelete: "gift:delete",
|
giftDelete: "gift:delete",
|
||||||
emojiPackView: "emoji-pack:view",
|
emojiPackView: "emoji-pack:view",
|
||||||
emojiPackCreate: "emoji-pack:create",
|
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",
|
dailyTaskView: "daily-task:view",
|
||||||
dailyTaskCreate: "daily-task:create",
|
dailyTaskCreate: "daily-task:create",
|
||||||
dailyTaskUpdate: "daily-task:update",
|
dailyTaskUpdate: "daily-task:update",
|
||||||
@ -260,6 +268,7 @@ export const MENU_CODES = {
|
|||||||
luckyGift: "lucky-gift",
|
luckyGift: "lucky-gift",
|
||||||
wheel: "wheel",
|
wheel: "wheel",
|
||||||
activities: "activities",
|
activities: "activities",
|
||||||
|
activityTemplate: "activity-template",
|
||||||
dailyTaskList: "daily-task-list",
|
dailyTaskList: "daily-task-list",
|
||||||
registrationReward: "registration-reward",
|
registrationReward: "registration-reward",
|
||||||
firstRechargeReward: "first-recharge-reward",
|
firstRechargeReward: "first-recharge-reward",
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { authRoutes } from "@/features/auth/routes.js";
|
import { authRoutes } from "@/features/auth/routes.js";
|
||||||
import { agencyOpeningRoutes } from "@/features/agency-opening/routes.js";
|
import { agencyOpeningRoutes } from "@/features/agency-opening/routes.js";
|
||||||
import { achievementRoutes } from "@/features/achievements/routes.js";
|
import { achievementRoutes } from "@/features/achievements/routes.js";
|
||||||
|
import { activityTemplatesRoutes } from "@/features/activity-templates/routes.js";
|
||||||
import { appConfigRoutes } from "@/features/app-config/routes.js";
|
import { appConfigRoutes } from "@/features/app-config/routes.js";
|
||||||
import { appUserRoutes } from "@/features/app-users/routes.js";
|
import { appUserRoutes } from "@/features/app-users/routes.js";
|
||||||
import { cumulativeRechargeRewardRoutes } from "@/features/cumulative-recharge-reward/routes.js";
|
import { cumulativeRechargeRewardRoutes } from "@/features/cumulative-recharge-reward/routes.js";
|
||||||
@ -48,6 +49,7 @@ export const adminRoutes: AdminRoute[] = [
|
|||||||
...regionBlockRoutes,
|
...regionBlockRoutes,
|
||||||
...roomRoutes,
|
...roomRoutes,
|
||||||
...appConfigRoutes,
|
...appConfigRoutes,
|
||||||
|
...activityTemplatesRoutes,
|
||||||
...dailyTaskRoutes,
|
...dailyTaskRoutes,
|
||||||
...achievementRoutes,
|
...achievementRoutes,
|
||||||
...registrationRewardRoutes,
|
...registrationRewardRoutes,
|
||||||
|
|||||||
1302
src/features/activity-templates/activity-templates.module.css
Normal file
1302
src/features/activity-templates/activity-templates.module.css
Normal file
File diff suppressed because it is too large
Load Diff
861
src/features/activity-templates/api.ts
Normal file
861
src/features/activity-templates/api.ts
Normal file
@ -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<ActivityTemplateLeaderboardEntryDto> {
|
||||||
|
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<string, unknown>;
|
||||||
|
|
||||||
|
export function listActivityTemplates(query: PageQuery = {}): Promise<ApiPage<ActivityTemplateSummaryDto>> {
|
||||||
|
const endpoint = API_ENDPOINTS.listActivityTemplates;
|
||||||
|
return apiRequest<RawRecord>(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<ActivityTemplateDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.createActivityTemplate;
|
||||||
|
return apiRequest<RawRecord, ActivityTemplatePayload>(apiEndpointPath(API_OPERATIONS.createActivityTemplate), {
|
||||||
|
body: payload,
|
||||||
|
method: endpoint.method,
|
||||||
|
}).then((result) => normalizeTemplate(recordValue(result.template ?? result)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActivityTemplate(templateId: EntityId): Promise<ActivityTemplateDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.getActivityTemplate;
|
||||||
|
return apiRequest<RawRecord>(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<ActivityTemplateDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.updateActivityTemplate;
|
||||||
|
return apiRequest<RawRecord, ActivityTemplatePayload & { expected_revision: number }>(
|
||||||
|
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<ActivityTemplateDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.setActivityTemplateStatus;
|
||||||
|
return apiRequest<RawRecord, { expected_revision: number; status: string }>(
|
||||||
|
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<ActivityTemplateDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.deleteActivityTemplate;
|
||||||
|
return apiRequest<RawRecord>(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<ActivityTemplateDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.cloneActivityTemplate;
|
||||||
|
return apiRequest<RawRecord, typeof payload>(
|
||||||
|
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<ApiPage<ActivityTemplateVersionDto>> {
|
||||||
|
const endpoint = API_ENDPOINTS.listActivityTemplateVersions;
|
||||||
|
return apiRequest<RawRecord>(
|
||||||
|
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<ActivityTemplateValidationResultDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.validateActivityTemplate;
|
||||||
|
return apiRequest<RawRecord, { for_publish: boolean; template: ActivityTemplatePayload }>(
|
||||||
|
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<ActivityTemplateDataDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.getActivityTemplateData;
|
||||||
|
return apiRequest<RawRecord>(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<ActivityTemplateDataDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.exportActivityTemplateData;
|
||||||
|
return apiRequest<RawRecord>(
|
||||||
|
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<ActivityTemplateLeaderboardPageDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.listActivityTemplateLeaderboard;
|
||||||
|
return apiRequest<RawRecord>(
|
||||||
|
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<ApiPage<ActivityTemplateRewardJobDto>> {
|
||||||
|
const endpoint = API_ENDPOINTS.listActivityTemplateRewardJobs;
|
||||||
|
return apiRequest<RawRecord>(
|
||||||
|
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<ActivityTemplateRewardJobDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.retryActivityTemplateRewardJob;
|
||||||
|
return apiRequest<RawRecord>(
|
||||||
|
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<ApiPage<ActivityTemplateTaskClaimDto>> {
|
||||||
|
const endpoint = API_ENDPOINTS.listActivityTemplateTaskClaims;
|
||||||
|
return apiRequest<RawRecord>(
|
||||||
|
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<ActivityTemplateTaskClaimDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.retryActivityTemplateTaskClaim;
|
||||||
|
return apiRequest<RawRecord>(
|
||||||
|
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;
|
||||||
|
}
|
||||||
@ -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 (
|
||||||
|
<aside className={styles.previewPanel}>
|
||||||
|
<div className={styles.previewToolbar}>
|
||||||
|
<span>手机预览</span>
|
||||||
|
<div className={styles.previewLocale}>
|
||||||
|
<button
|
||||||
|
className={locale === "en" ? styles.previewLocaleActive : ""}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLocale("en")}
|
||||||
|
>
|
||||||
|
EN
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={locale === "ar" ? styles.previewLocaleActive : ""}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLocale("ar")}
|
||||||
|
>
|
||||||
|
AR
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.phoneFrame}>
|
||||||
|
<div
|
||||||
|
className={styles.phoneScreen}
|
||||||
|
dir={rtl ? "rtl" : "ltr"}
|
||||||
|
style={pageBackground ? { backgroundImage: `url(${pageBackground})` } : undefined}
|
||||||
|
>
|
||||||
|
<div className={styles.phoneHero} style={hero ? { backgroundImage: `url(${hero})` } : undefined}>
|
||||||
|
<span className={styles.phoneBack}>‹</span>
|
||||||
|
<strong>{title || "Gift Challenge"}</strong>
|
||||||
|
</div>
|
||||||
|
<div className={styles.phoneCountdown}>
|
||||||
|
{["29D", "23H", "12M", "45S"].map((part) => (
|
||||||
|
<span key={part}>{part}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<section className={styles.phoneGiftPanel}>
|
||||||
|
<span className={styles.phoneSectionTitle}>{rtl ? "هدايا النشاط" : "Activity gifts"}</span>
|
||||||
|
<div className={styles.phoneGiftRail}>
|
||||||
|
{(form.gifts || []).slice(0, 5).map((gift) => (
|
||||||
|
<div className={styles.phoneGift} key={gift.giftId}>
|
||||||
|
{gift.iconUrl ? (
|
||||||
|
<img alt="" src={gift.iconUrl} />
|
||||||
|
) : (
|
||||||
|
<CardGiftcardOutlined fontSize="small" />
|
||||||
|
)}
|
||||||
|
<small>{Number(gift.coinPrice || 0).toLocaleString("en-US")}</small>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!form.gifts?.length ? (
|
||||||
|
<div className={styles.phoneGiftEmpty}>
|
||||||
|
<CardGiftcardOutlined fontSize="small" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div className={styles.phoneTabs}>
|
||||||
|
<button
|
||||||
|
aria-pressed={tab === "tasks"}
|
||||||
|
className={tab === "tasks" ? styles.phoneTabActive : ""}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab("tasks")}
|
||||||
|
>
|
||||||
|
<TaskAltOutlined fontSize="inherit" />
|
||||||
|
{rtl ? "المهام" : "Tasks"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-pressed={tab === "leaderboard"}
|
||||||
|
className={tab === "leaderboard" ? styles.phoneTabActive : ""}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab("leaderboard")}
|
||||||
|
>
|
||||||
|
<EmojiEventsOutlined fontSize="inherit" />
|
||||||
|
{rtl ? "التصنيف" : "Ranking"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{tab === "tasks" ? (
|
||||||
|
<div className={styles.phoneTaskList}>
|
||||||
|
{(form.tasks || []).slice(0, 4).map((task) => (
|
||||||
|
<div className={styles.phoneTask} key={task.taskKey}>
|
||||||
|
<span>
|
||||||
|
<strong>{taskLabel(task, rtl)}</strong>
|
||||||
|
<small>
|
||||||
|
{groupName(groupsById.get(String(task.rewardResourceGroupId)), rtl)}
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
<button type="button">{rtl ? "استلام" : "Claim"}</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!form.tasks?.length ? <div className={styles.phoneEmpty}>No tasks</div> : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.phoneRanking}>
|
||||||
|
<div className={styles.phonePodium}>
|
||||||
|
{[2, 1, 3].map((rank) => (
|
||||||
|
<span className={rank === 1 ? styles.phonePodiumFirst : ""} key={rank}>
|
||||||
|
<i>{rank}</i>
|
||||||
|
<small>TOP {rank}</small>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{[4, 5, 6].map((rank) => (
|
||||||
|
<div className={styles.phoneRankRow} key={rank}>
|
||||||
|
<span>{rank}</span>
|
||||||
|
<i />
|
||||||
|
<strong>User {rank}</strong>
|
||||||
|
<small>15,000</small>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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`;
|
||||||
|
}
|
||||||
@ -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(<ActivityPhonePreview form={form} resourceGroups={[]} />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: /Tasks/ })).toHaveAttribute("aria-pressed", "true");
|
||||||
|
expect(screen.getByRole("button", { name: /Ranking/ })).toHaveAttribute("aria-pressed", "false");
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<ActivityPhonePreview
|
||||||
|
form={{ ...form, displayConfigJson: JSON.stringify({ default_tab: "leaderboard" }) }}
|
||||||
|
resourceGroups={[]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("button", { name: /Ranking/ })).toHaveAttribute("aria-pressed", "true");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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 (
|
||||||
|
<Dialog fullWidth maxWidth="sm" open={open} onClose={saving ? undefined : onClose}>
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
|
<DialogTitle>复制活动模版</DialogTitle>
|
||||||
|
<DialogContent className={styles.cloneDialogContent}>
|
||||||
|
<div className={styles.cloneSource}>
|
||||||
|
<strong>{source?.name || "-"}</strong>
|
||||||
|
<span>
|
||||||
|
{source?.templateCode || source?.templateId || "-"} ·{" "}
|
||||||
|
{form.sourceVersion ? `版本 v${form.sourceVersion}` : "当前编辑态"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
error={Boolean(form.templateCode) && !validCode}
|
||||||
|
helperText={Boolean(form.templateCode) && !validCode ? "长度 3-64,以小写字母开头" : " "}
|
||||||
|
label="新模版编码"
|
||||||
|
required
|
||||||
|
value={form.templateCode}
|
||||||
|
onChange={(event) => onChange({ templateCode: normalizeCode(event.target.value) })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="新内部名称"
|
||||||
|
required
|
||||||
|
value={form.name}
|
||||||
|
onChange={(event) => onChange({ name: event.target.value })}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button disabled={saving} onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={saving || !valid}
|
||||||
|
startIcon={<ContentCopyOutlined fontSize="small" />}
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
>
|
||||||
|
复制为草稿
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCode(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9_-]/g, "");
|
||||||
|
}
|
||||||
@ -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 (
|
||||||
|
<SideDrawer
|
||||||
|
className={styles.dataDrawer}
|
||||||
|
contentClassName={styles.dataDrawerBody}
|
||||||
|
open={open}
|
||||||
|
title={`数据中心 · ${source?.name || "-"}`}
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
<div className={styles.dataHeader}>
|
||||||
|
<div className={styles.dataIdentity}>
|
||||||
|
<strong>{source?.templateCode || source?.templateId || "-"}</strong>
|
||||||
|
<span>发布 v{dataCenter.versionNo || 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.dataHeaderActions}>
|
||||||
|
<Button startIcon={<RefreshOutlined fontSize="small" />} onClick={activeReload}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
{abilities.canExport && dataCenter.activeTab === "overview" ? (
|
||||||
|
<Button
|
||||||
|
disabled={!dataCenter.dataParams || dataCenter.loadingAction === "export"}
|
||||||
|
startIcon={<DownloadOutlined fontSize="small" />}
|
||||||
|
variant="primary"
|
||||||
|
onClick={dataCenter.exportData}
|
||||||
|
>
|
||||||
|
{dataCenter.loadingAction === "export" ? "导出中" : "导出 CSV"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.dataFilterBar}>
|
||||||
|
<TextField
|
||||||
|
disabled={dataCenter.versionsLoading && !dataCenter.versionItems.length}
|
||||||
|
label="发布版本"
|
||||||
|
select
|
||||||
|
size="small"
|
||||||
|
value={String(dataCenter.versionNo || "")}
|
||||||
|
onChange={(event) => dataCenter.changeVersion(event.target.value)}
|
||||||
|
>
|
||||||
|
{dataCenter.versionItems.map((item) => (
|
||||||
|
<MenuItem key={item.versionNo} value={String(item.versionNo)}>
|
||||||
|
v{item.versionNo}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
{dataCenter.activeTab === "overview" ? (
|
||||||
|
<>
|
||||||
|
<RegionFilterControl
|
||||||
|
emptyLabel="全部区域"
|
||||||
|
label="统计区域"
|
||||||
|
loading={loadingRegions}
|
||||||
|
options={dataCenter.scopedRegionOptions}
|
||||||
|
value={dataCenter.regionId}
|
||||||
|
onChange={dataCenter.setRegionId}
|
||||||
|
/>
|
||||||
|
<TimeRangeFilter
|
||||||
|
label="统计日期"
|
||||||
|
value={dataCenter.timeRange}
|
||||||
|
withTime={false}
|
||||||
|
onChange={dataCenter.setTimeRange}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{dataCenter.versionsError ? (
|
||||||
|
<button className={styles.inlineErrorButton} type="button" onClick={dataCenter.reloadVersions}>
|
||||||
|
发布版本加载失败,重试
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<Tabs
|
||||||
|
className={styles.dataTabs}
|
||||||
|
value={dataCenter.activeTab}
|
||||||
|
onChange={(_, value) => dataCenter.setActiveTab(value)}
|
||||||
|
>
|
||||||
|
{tabs.map(([value, label]) => (
|
||||||
|
<Tab key={value} label={label} value={value} />
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
<div className={styles.dataTabContent}>
|
||||||
|
{dataCenter.activeTab === "overview" ? (
|
||||||
|
<ActivityTemplateDataOverview
|
||||||
|
data={dataCenter.data}
|
||||||
|
error={dataCenter.dataError}
|
||||||
|
loading={dataCenter.dataLoading}
|
||||||
|
template={dataCenter.selectedTemplate}
|
||||||
|
onRetry={dataCenter.reloadData}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{dataCenter.activeTab === "leaderboard" ? (
|
||||||
|
<ActivityTemplateLeaderboardPanel
|
||||||
|
boardType={dataCenter.leaderboardBoardType}
|
||||||
|
data={dataCenter.leaderboard}
|
||||||
|
error={dataCenter.leaderboardError}
|
||||||
|
loading={dataCenter.leaderboardLoading}
|
||||||
|
page={dataCenter.leaderboardPage}
|
||||||
|
periodKey={dataCenter.leaderboardPeriodKey}
|
||||||
|
periodOptions={dataCenter.periodOptions}
|
||||||
|
onBoardTypeChange={dataCenter.changeLeaderboardBoardType}
|
||||||
|
onPageChange={dataCenter.setLeaderboardPage}
|
||||||
|
onPeriodChange={dataCenter.setLeaderboardPeriodKey}
|
||||||
|
onRetry={dataCenter.reloadLeaderboard}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{dataCenter.activeTab === "rewards" ? (
|
||||||
|
<ActivityTemplateRewardsPanel
|
||||||
|
abilities={abilities}
|
||||||
|
boardType={dataCenter.rewardBoardType}
|
||||||
|
data={dataCenter.rewards}
|
||||||
|
error={dataCenter.rewardsError}
|
||||||
|
loading={dataCenter.rewardsLoading}
|
||||||
|
loadingAction={dataCenter.loadingAction}
|
||||||
|
page={dataCenter.rewardPage}
|
||||||
|
periodKey={dataCenter.rewardPeriodKey}
|
||||||
|
periodOptions={dataCenter.periodOptions}
|
||||||
|
status={dataCenter.rewardStatus}
|
||||||
|
onBoardTypeChange={dataCenter.changeRewardBoardType}
|
||||||
|
onPageChange={dataCenter.setRewardPage}
|
||||||
|
onPeriodChange={dataCenter.setRewardPeriodKey}
|
||||||
|
onRetry={dataCenter.reloadRewards}
|
||||||
|
onRetryJob={dataCenter.retryReward}
|
||||||
|
onStatusChange={dataCenter.setRewardStatus}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{dataCenter.activeTab === "taskClaims" ? (
|
||||||
|
<ActivityTemplateTaskClaimsPanel
|
||||||
|
abilities={abilities}
|
||||||
|
data={dataCenter.taskClaims}
|
||||||
|
error={dataCenter.taskClaimsError}
|
||||||
|
loading={dataCenter.taskClaimsLoading}
|
||||||
|
loadingAction={dataCenter.loadingAction}
|
||||||
|
page={dataCenter.taskClaimPage}
|
||||||
|
periodOptions={dataCenter.periodOptions}
|
||||||
|
status={dataCenter.taskClaimStatus}
|
||||||
|
taskDay={dataCenter.taskClaimDay}
|
||||||
|
taskKey={dataCenter.taskClaimKey}
|
||||||
|
taskOptions={dataCenter.selectedTemplate.tasks || []}
|
||||||
|
userId={dataCenter.taskClaimUserId}
|
||||||
|
onDayChange={dataCenter.setTaskClaimDay}
|
||||||
|
onPageChange={dataCenter.setTaskClaimPage}
|
||||||
|
onRetry={dataCenter.reloadTaskClaims}
|
||||||
|
onRetryClaim={dataCenter.retryTaskClaim}
|
||||||
|
onStatusChange={dataCenter.setTaskClaimStatus}
|
||||||
|
onTaskKeyChange={dataCenter.setTaskClaimKey}
|
||||||
|
onUserIdChange={dataCenter.setTaskClaimUserId}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</SideDrawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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 (
|
||||||
|
<div className={styles.cellStack}>
|
||||||
|
<strong>{gift?.name || item.giftId || "-"}</strong>
|
||||||
|
<small>{item.giftId || "-"}</small>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ 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 (
|
||||||
|
<div className={styles.cellStack}>
|
||||||
|
<strong>{item.taskKey || "-"}</strong>
|
||||||
|
<small>{taskTypeLabel(task?.taskType || item.taskType)}</small>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ 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 (
|
||||||
|
<DataState error={error} loading={loading} reportPageLoading={false} onRetry={onRetry}>
|
||||||
|
<div className={styles.dataOverview}>
|
||||||
|
<div className={styles.metricGrid}>
|
||||||
|
{metricDefinitions.map(([key, label, unit]) => (
|
||||||
|
<div className={styles.metricItem} key={key}>
|
||||||
|
<span>{label}</span>
|
||||||
|
<strong>{formatActivityMetric(data.overview[key])}</strong>
|
||||||
|
<small>{unit}</small>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<section className={styles.dataSection}>
|
||||||
|
<div className={styles.dataSectionHeader}>
|
||||||
|
<div>
|
||||||
|
<h3>每日趋势</h3>
|
||||||
|
<span>{effectiveWindowLabel(data)}</span>
|
||||||
|
</div>
|
||||||
|
<span className={styles.dataMetaBadge}>{data.timezone || "UTC"} · 日粒度</span>
|
||||||
|
</div>
|
||||||
|
<MultiSeriesLineChart
|
||||||
|
ariaLabel="活动模版每日趋势"
|
||||||
|
categories={data.trend.map((item) => item.statDay)}
|
||||||
|
series={trendSeries}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<div className={styles.dataRankingGrid}>
|
||||||
|
<section className={styles.dataSection}>
|
||||||
|
<div className={styles.dataSectionHeader}>
|
||||||
|
<div>
|
||||||
|
<h3>礼物排行</h3>
|
||||||
|
<span>按金币、数量降序</span>
|
||||||
|
</div>
|
||||||
|
<span>
|
||||||
|
{data.gifts.length} / {data.dimensionLimit || 100}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<DataTable
|
||||||
|
columns={giftColumns}
|
||||||
|
items={data.gifts}
|
||||||
|
minWidth="590px"
|
||||||
|
rowKey={(item) => item.giftId}
|
||||||
|
total={data.gifts.length}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<section className={styles.dataSection}>
|
||||||
|
<div className={styles.dataSectionHeader}>
|
||||||
|
<div>
|
||||||
|
<h3>任务排行</h3>
|
||||||
|
<span>按完成、领奖降序</span>
|
||||||
|
</div>
|
||||||
|
<span>
|
||||||
|
{data.tasks.length} / {data.dimensionLimit || 100}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<DataTable
|
||||||
|
columns={taskColumns}
|
||||||
|
items={data.tasks}
|
||||||
|
minWidth="620px"
|
||||||
|
rowKey={(item) => item.taskKey}
|
||||||
|
total={data.tasks.length}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DataState>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function metric(value) {
|
||||||
|
return <strong className={styles.numericCell}>{formatActivityMetric(value)}</strong>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 || "-";
|
||||||
|
}
|
||||||
@ -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 (
|
||||||
|
<Drawer
|
||||||
|
anchor="right"
|
||||||
|
open={open}
|
||||||
|
slotProps={{ backdrop: { sx: { backgroundColor: "var(--overlay-scrim)" } } }}
|
||||||
|
onClose={saving ? undefined : onClose}
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
className={["form-drawer", "form-drawer--workspace", styles.editorDrawer].join(" ")}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
>
|
||||||
|
<header className={["form-drawer__header", styles.editorHeader].join(" ")}>
|
||||||
|
<div className="form-drawer__heading">
|
||||||
|
<div className={styles.editorTitleLine}>
|
||||||
|
<h2>{drawerTitle(mode, form)}</h2>
|
||||||
|
{form.templateId ? (
|
||||||
|
<ActivityTemplateStatusBadge status={form.lifecycleStatus || form.status} />
|
||||||
|
) : null}
|
||||||
|
{dirty ? <span className={styles.dirtyBadge}>未保存</span> : null}
|
||||||
|
</div>
|
||||||
|
<span>
|
||||||
|
{form.templateId
|
||||||
|
? `${form.templateCode || form.templateId} · revision ${form.revision}`
|
||||||
|
: "新建礼物挑战赛活动模版"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<IconButton disabled={saving} label="关闭" onClick={onClose}>
|
||||||
|
<CloseOutlined fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav aria-label="活动模版配置分区" className={["form-drawer__tabs", styles.editorTabs].join(" ")}>
|
||||||
|
{EDITOR_SECTIONS.map((section) => (
|
||||||
|
<button
|
||||||
|
aria-current={section.key === activeSection ? "page" : undefined}
|
||||||
|
className={section.key === activeSection ? "is-active" : ""}
|
||||||
|
key={section.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveSection(section.key)}
|
||||||
|
>
|
||||||
|
<span>{section.label}</span>
|
||||||
|
{sectionCounts[section.key] !== undefined ? (
|
||||||
|
<small>{sectionCounts[section.key]}</small>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className={["form-drawer__workspace", styles.editorWorkspace].join(" ")}>
|
||||||
|
{loading ? (
|
||||||
|
<div className={styles.editorLoading}>
|
||||||
|
<PageSkeleton />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<main className={styles.editorMain}>
|
||||||
|
{isPublished ? (
|
||||||
|
<Alert
|
||||||
|
action={
|
||||||
|
mode !== "version" && onDisable && abilities.canPublish ? (
|
||||||
|
<Button disabled={saving} onClick={onDisable}>
|
||||||
|
停用
|
||||||
|
</Button>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
severity="info"
|
||||||
|
>
|
||||||
|
{mode === "version"
|
||||||
|
? "发布版本为不可变快照。"
|
||||||
|
: "已发布版本只读,停用后可编辑并重新发布。"}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
{validationIssues?.length ? (
|
||||||
|
<Alert
|
||||||
|
action={
|
||||||
|
hasRevisionConflict && form.templateId && onReload ? (
|
||||||
|
<Button disabled={saving} onClick={onReload}>
|
||||||
|
重新加载
|
||||||
|
</Button>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
severity="error"
|
||||||
|
>
|
||||||
|
<div className={styles.validationList}>
|
||||||
|
{validationIssues.slice(0, 8).map((issue, index) => (
|
||||||
|
<span key={`${issue.field || "form"}-${issue.code || index}-${index}`}>
|
||||||
|
{issue.field ? `${issue.field}:` : ""}
|
||||||
|
{issue.message}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
{activeSection === "basic" ? (
|
||||||
|
<BasicSettingsSection
|
||||||
|
disabled={readOnly}
|
||||||
|
form={form}
|
||||||
|
loadingRegions={loadingRegions}
|
||||||
|
regionOptions={regionOptions}
|
||||||
|
updateField={updateField}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{activeSection === "gifts" ? (
|
||||||
|
<GiftSettingsSection
|
||||||
|
disabled={readOnly}
|
||||||
|
form={form}
|
||||||
|
gifts={gifts}
|
||||||
|
giftsLoading={giftsLoading}
|
||||||
|
updateField={updateField}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{activeSection === "tasks" ? (
|
||||||
|
<TaskSettingsSection
|
||||||
|
disabled={readOnly}
|
||||||
|
form={form}
|
||||||
|
resourceGroups={resourceGroups}
|
||||||
|
updateField={updateField}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{activeSection === "ranking" ? (
|
||||||
|
<RankingSettingsSection
|
||||||
|
disabled={readOnly}
|
||||||
|
form={form}
|
||||||
|
resourceGroups={resourceGroups}
|
||||||
|
updateField={updateField}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{activeSection === "rules" ? (
|
||||||
|
<RulesSettingsSection disabled={readOnly} form={form} updateField={updateField} />
|
||||||
|
) : null}
|
||||||
|
{activeSection === "assets" ? (
|
||||||
|
<AssetsSettingsSection disabled={readOnly} form={form} updateField={updateField} />
|
||||||
|
) : null}
|
||||||
|
{activeSection === "display" ? (
|
||||||
|
<DisplayConfigSection disabled={readOnly} form={form} updateField={updateField} />
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
<ActivityPhonePreview form={form} resourceGroups={resourceGroups} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={["form-drawer__actions", styles.editorActions].join(" ")}>
|
||||||
|
<Button disabled={saving} onClick={onClose}>
|
||||||
|
{readOnly ? "关闭" : "取消"}
|
||||||
|
</Button>
|
||||||
|
{canSave ? (
|
||||||
|
<Button disabled={saving} startIcon={<SaveOutlined fontSize="small" />} type="submit">
|
||||||
|
保存草稿
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{canSave && abilities.canPublish ? (
|
||||||
|
<Button
|
||||||
|
disabled={saving}
|
||||||
|
startIcon={<PublishOutlined fontSize="small" />}
|
||||||
|
variant="primary"
|
||||||
|
onClick={onPublish}
|
||||||
|
>
|
||||||
|
保存并发布
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{isPublished && !immutableMode && abilities.canPublish ? (
|
||||||
|
<Button
|
||||||
|
disabled={saving}
|
||||||
|
startIcon={<StopCircleOutlined fontSize="small" />}
|
||||||
|
variant="danger"
|
||||||
|
onClick={onDisable}
|
||||||
|
>
|
||||||
|
停用模版
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawerTitle(mode, form) {
|
||||||
|
if (mode === "create") return "新增活动模版";
|
||||||
|
if (mode === "version") return `发布版本 v${form.publishedVersion || "-"}`;
|
||||||
|
if (mode === "view") return "查看活动模版";
|
||||||
|
return "编辑活动模版";
|
||||||
|
}
|
||||||
@ -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 ? <div>{children}</div> : null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/activity-templates/components/ActivityPhonePreview.jsx", () => ({
|
||||||
|
ActivityPhonePreview: () => <aside>手机预览</aside>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/shared/ui/TimeRangeFilter.jsx", () => ({
|
||||||
|
TimeRangeFilter: ({ disabled, label }) => <input aria-label={label} disabled={disabled} />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/shared/ui/MultiValueAutocomplete.jsx", () => ({
|
||||||
|
MultiValueAutocomplete: ({ disabled, label, required = false }) => (
|
||||||
|
<input aria-label={label} disabled={disabled} required={required} />
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
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(<ActivityTemplateEditorDrawer {...props} />);
|
||||||
|
expect(screen.getByRole("button", { name: "重新加载" })).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderEditor(patch = {}) {
|
||||||
|
return render(<ActivityTemplateEditorDrawer {...editorProps(patch)} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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) => <strong>#{item.rankNo}</strong> },
|
||||||
|
{
|
||||||
|
key: "user",
|
||||||
|
label: "用户",
|
||||||
|
width: "minmax(210px, .9fr)",
|
||||||
|
render: (item) => (
|
||||||
|
<div className={styles.dataUserIdentity}>
|
||||||
|
<Avatar alt="" src={item.user?.avatar || undefined}>
|
||||||
|
{(item.user?.username || String(item.userId || "-")).slice(0, 1)}
|
||||||
|
</Avatar>
|
||||||
|
<div className={styles.cellStack}>
|
||||||
|
<strong>{item.user?.username || `用户 #${item.userId || "-"}`}</strong>
|
||||||
|
<small>
|
||||||
|
{item.user?.displayUserId
|
||||||
|
? `${item.user.displayUserId} · `
|
||||||
|
: `UID ${item.userId || "-"} · `}
|
||||||
|
{item.snapshot ? "结算快照" : "实时排名"}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ 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 (
|
||||||
|
<div className={styles.dataPanel}>
|
||||||
|
<div className={styles.inlineFilters}>
|
||||||
|
<TextField
|
||||||
|
label="榜单类型"
|
||||||
|
select
|
||||||
|
size="small"
|
||||||
|
value={boardType}
|
||||||
|
onChange={(event) => onBoardTypeChange(event.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem value="daily">日榜</MenuItem>
|
||||||
|
<MenuItem value="total">总榜</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
{boardType === "daily" ? (
|
||||||
|
<TextField
|
||||||
|
label="UTC 榜单日期"
|
||||||
|
select
|
||||||
|
size="small"
|
||||||
|
value={periodKey}
|
||||||
|
onChange={(event) => onPeriodChange(event.target.value)}
|
||||||
|
>
|
||||||
|
{periodOptions.map((item) => (
|
||||||
|
<MenuItem key={item} value={item}>
|
||||||
|
{item}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
) : null}
|
||||||
|
<PeriodStatus period={data.period} />
|
||||||
|
</div>
|
||||||
|
<DataState error={error} loading={loading} reportPageLoading={false} onRetry={onRetry}>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
items={data.items || []}
|
||||||
|
minWidth="820px"
|
||||||
|
rowKey={(item) => `${item.rankNo}-${item.userId}`}
|
||||||
|
total={data.total || 0}
|
||||||
|
/>
|
||||||
|
{pageCount > 1 ? (
|
||||||
|
<div className={styles.drawerPagination}>
|
||||||
|
<Pagination
|
||||||
|
count={pageCount}
|
||||||
|
page={page}
|
||||||
|
size="small"
|
||||||
|
onChange={(_, value) => onPageChange(value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</DataState>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PeriodStatus({ period }) {
|
||||||
|
if (!period?.periodKey) return null;
|
||||||
|
return (
|
||||||
|
<div className={styles.periodSummary}>
|
||||||
|
<span className={`${styles.dataStatus} ${styles[`dataStatus_${period.status}`] || ""}`}>
|
||||||
|
{periodStatusLabel(period.status)}
|
||||||
|
</span>
|
||||||
|
<span>{period.periodKey}</span>
|
||||||
|
{period.settledAtMs ? (
|
||||||
|
<small>结算 {formatMillis(period.settledAtMs)}</small>
|
||||||
|
) : period.settleAfterMs ? (
|
||||||
|
<small>结算水位 {formatMillis(period.settleAfterMs)}</small>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function numeric(value) {
|
||||||
|
return <strong className={styles.numericCell}>{formatActivityMetric(value)}</strong>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 || "未知";
|
||||||
|
}
|
||||||
@ -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 (
|
||||||
|
<SideDrawer className={className} open title={title} width={width} onClose={onClose}>
|
||||||
|
<div className={styles.overlayDrawerSkeleton}>
|
||||||
|
<PageSkeleton />
|
||||||
|
</div>
|
||||||
|
</SideDrawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActivityTemplateCloneDialogSkeleton({ onClose }) {
|
||||||
|
return (
|
||||||
|
<Dialog fullWidth maxWidth="sm" open onClose={onClose}>
|
||||||
|
<DialogTitle>复制活动模版</DialogTitle>
|
||||||
|
<DialogContent className={styles.overlayDialogSkeleton} aria-busy="true" aria-label="复制活动模版加载中">
|
||||||
|
<Skeleton animation="wave" height={64} variant="rounded" />
|
||||||
|
<Skeleton animation="wave" height={56} variant="rounded" />
|
||||||
|
<Skeleton animation="wave" height={56} variant="rounded" />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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) => (
|
||||||
|
<div className={styles.cellStack} title={item.rewardJobId}>
|
||||||
|
<strong>{item.rewardJobId || "-"}</strong>
|
||||||
|
<small>
|
||||||
|
v{item.versionNo} · {item.boardType === "daily" ? "日榜" : "总榜"} {item.periodKey}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "user",
|
||||||
|
label: "用户 / 名次",
|
||||||
|
width: "150px",
|
||||||
|
render: (item) => (
|
||||||
|
<div className={styles.dataUserIdentity}>
|
||||||
|
<Avatar alt="" src={item.user?.avatar || undefined}>
|
||||||
|
{(item.user?.username || String(item.userId || "-")).slice(0, 1)}
|
||||||
|
</Avatar>
|
||||||
|
<div className={styles.cellStack}>
|
||||||
|
<strong>{item.user?.username || `#${item.userId || "-"}`}</strong>
|
||||||
|
<small>
|
||||||
|
{item.user?.displayUserId || `UID ${item.userId || "-"}`} · 排名 #{item.rankNo} ·{" "}
|
||||||
|
{formatActivityMetric(item.score)} 分
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "resource",
|
||||||
|
label: "发布态奖励",
|
||||||
|
width: "minmax(180px, .75fr)",
|
||||||
|
render: (item) => (
|
||||||
|
<div className={styles.cellStack} title={item.rewardSnapshotHash || ""}>
|
||||||
|
<strong>{item.rewardName || `资源组 #${item.resourceGroupId || "-"}`}</strong>
|
||||||
|
<small>
|
||||||
|
{item.rewardItems.length} 项 · {item.rewardSnapshotId || `group ${item.resourceGroupId || "-"}`}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
label: "状态 / 尝试",
|
||||||
|
width: "130px",
|
||||||
|
render: (item) => (
|
||||||
|
<div className={styles.cellStack}>
|
||||||
|
<span className={`${styles.dataStatus} ${styles[`dataStatus_${item.status}`] || ""}`}>
|
||||||
|
{rewardStatusLabel(item.status)}
|
||||||
|
</span>
|
||||||
|
<small>
|
||||||
|
{item.attemptCount || 0}/{item.maxAttempts || "-"} 次
|
||||||
|
{item.nextRetryAtMs > 0 ? ` · 下次 ${formatMillis(item.nextRetryAtMs)}` : ""}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "failure",
|
||||||
|
label: "失败原因",
|
||||||
|
width: "minmax(200px, .9fr)",
|
||||||
|
render: (item) => (
|
||||||
|
<span className={styles.ellipsisCell} title={item.failureReason || ""}>
|
||||||
|
{item.failureReason || "-"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "time",
|
||||||
|
label: "更新时间",
|
||||||
|
width: "180px",
|
||||||
|
render: (item) => (
|
||||||
|
<div className={styles.cellStack}>
|
||||||
|
<span>{formatMillis(item.updatedAtMs)}</span>
|
||||||
|
<small>
|
||||||
|
{item.grantedAtMs
|
||||||
|
? `发放 ${formatMillis(item.grantedAtMs)}`
|
||||||
|
: `创建 ${formatMillis(item.createdAtMs)}`}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
label: "操作",
|
||||||
|
width: "72px",
|
||||||
|
render: (item) => (
|
||||||
|
<AdminRowActions>
|
||||||
|
{abilities.canRetry && ["failed", "dead"].includes(item.status) ? (
|
||||||
|
<AdminActionIconButton
|
||||||
|
disabled={loadingAction === `retry:${item.rewardJobId}`}
|
||||||
|
label="重试发放"
|
||||||
|
onClick={() => onRetryJob(item)}
|
||||||
|
>
|
||||||
|
<ReplayOutlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
|
) : null}
|
||||||
|
</AdminRowActions>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const pageCount = Math.max(1, Math.ceil(Number(data.total || 0) / Number(data.pageSize || 20)));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.dataPanel}>
|
||||||
|
<div className={styles.inlineFilters}>
|
||||||
|
<TextField
|
||||||
|
label="榜单类型"
|
||||||
|
select
|
||||||
|
size="small"
|
||||||
|
value={boardType}
|
||||||
|
onChange={(event) => onBoardTypeChange(event.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem value="">全部榜单</MenuItem>
|
||||||
|
<MenuItem value="daily">日榜</MenuItem>
|
||||||
|
<MenuItem value="total">总榜</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
{boardType === "daily" ? (
|
||||||
|
<TextField
|
||||||
|
label="UTC 奖励周期"
|
||||||
|
select
|
||||||
|
size="small"
|
||||||
|
value={periodKey}
|
||||||
|
onChange={(event) => onPeriodChange(event.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem value="">全部周期</MenuItem>
|
||||||
|
{periodOptions.map((item) => (
|
||||||
|
<MenuItem key={item} value={item}>
|
||||||
|
{item}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
) : null}
|
||||||
|
<StatusSelect label="发放状态" options={rewardStatusOptions} value={status} onChange={onStatusChange} />
|
||||||
|
</div>
|
||||||
|
<DataState error={error} loading={loading} reportPageLoading={false} onRetry={onRetry}>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
items={data.items || []}
|
||||||
|
minWidth="1260px"
|
||||||
|
rowKey={(item) => item.rewardJobId}
|
||||||
|
total={data.total || 0}
|
||||||
|
/>
|
||||||
|
{pageCount > 1 ? (
|
||||||
|
<div className={styles.drawerPagination}>
|
||||||
|
<Pagination
|
||||||
|
count={pageCount}
|
||||||
|
page={page}
|
||||||
|
size="small"
|
||||||
|
onChange={(_, value) => onPageChange(value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</DataState>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewardStatusLabel(value) {
|
||||||
|
return rewardStatusOptions.find(([status]) => status === value)?.[1] || value || "未知";
|
||||||
|
}
|
||||||
@ -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(
|
||||||
|
<ActivityTemplateRewardsPanel
|
||||||
|
abilities={{ canRetry: true }}
|
||||||
|
boardType="total"
|
||||||
|
data={{
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
attemptCount: 8,
|
||||||
|
boardType: "total",
|
||||||
|
createdAtMs: Date.parse("2026-07-14T03:00:00Z"),
|
||||||
|
failureReason: "wallet unavailable",
|
||||||
|
grantedAtMs: 0,
|
||||||
|
maxAttempts: 8,
|
||||||
|
nextRetryAtMs: 0,
|
||||||
|
periodKey: "total",
|
||||||
|
rankNo: 1,
|
||||||
|
resourceGroupId: 81,
|
||||||
|
rewardItems: [{ name: "Avatar frame" }],
|
||||||
|
rewardJobId: "reward-101",
|
||||||
|
rewardName: "Champion reward",
|
||||||
|
rewardSnapshotHash: "snapshot-hash",
|
||||||
|
rewardSnapshotId: "rgs-81-v3",
|
||||||
|
score: 19999,
|
||||||
|
status: "dead",
|
||||||
|
updatedAtMs: Date.parse("2026-07-14T03:02:00Z"),
|
||||||
|
user: { displayUserId: "HY503", username: "User 503" },
|
||||||
|
userId: 503,
|
||||||
|
versionNo: 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pageSize: 20,
|
||||||
|
total: 1,
|
||||||
|
}}
|
||||||
|
error=""
|
||||||
|
loading={false}
|
||||||
|
loadingAction=""
|
||||||
|
page={1}
|
||||||
|
periodKey=""
|
||||||
|
periodOptions={[]}
|
||||||
|
status="dead"
|
||||||
|
onBoardTypeChange={vi.fn()}
|
||||||
|
onPageChange={vi.fn()}
|
||||||
|
onPeriodChange={vi.fn()}
|
||||||
|
onRetry={vi.fn()}
|
||||||
|
onRetryJob={onRetryJob}
|
||||||
|
onStatusChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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" }));
|
||||||
|
});
|
||||||
@ -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 (
|
||||||
|
<span className={[styles.statusBadge, styles[`status_${normalized}`] || ""].filter(Boolean).join(" ")}>
|
||||||
|
<span className={styles.statusDot} />
|
||||||
|
{activityTemplateStatusLabel(normalized)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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) => (
|
||||||
|
<div className={styles.cellStack} title={item.claimId}>
|
||||||
|
<strong>{item.claimId || "-"}</strong>
|
||||||
|
<small>
|
||||||
|
v{item.versionNo} · {item.taskDay} · {item.taskKey}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "user",
|
||||||
|
label: "用户 / 归属区域",
|
||||||
|
width: "190px",
|
||||||
|
render: (item) => (
|
||||||
|
<div className={styles.dataUserIdentity}>
|
||||||
|
<Avatar alt="" src={item.user?.avatar || undefined}>
|
||||||
|
{(item.user?.username || String(item.userId || "-")).slice(0, 1)}
|
||||||
|
</Avatar>
|
||||||
|
<div className={styles.cellStack}>
|
||||||
|
<strong>{item.user?.username || `#${item.userId || "-"}`}</strong>
|
||||||
|
<small>
|
||||||
|
{item.user?.displayUserId || `UID ${item.userId || "-"}`} · 区域 #{item.earningRegionId || "-"}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "reward",
|
||||||
|
label: "发布态奖励",
|
||||||
|
width: "minmax(190px, .85fr)",
|
||||||
|
render: (item) => (
|
||||||
|
<div className={styles.cellStack} title={item.rewardSnapshotHash || ""}>
|
||||||
|
<strong>{item.rewardName || `资源组 #${item.rewardResourceGroupId || "-"}`}</strong>
|
||||||
|
<small>
|
||||||
|
{item.rewardItems.length} 项 · {item.rewardSnapshotId || `group ${item.rewardResourceGroupId || "-"}`}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
label: "状态 / 尝试",
|
||||||
|
width: "124px",
|
||||||
|
render: (item) => (
|
||||||
|
<div className={styles.cellStack}>
|
||||||
|
<span className={`${styles.dataStatus} ${styles[`dataStatus_${item.status}`] || ""}`}>
|
||||||
|
{claimStatusLabel(item.status)}
|
||||||
|
</span>
|
||||||
|
<small>{item.attemptCount || 0} 次</small>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "grant",
|
||||||
|
label: "钱包发放",
|
||||||
|
width: "minmax(160px, .7fr)",
|
||||||
|
render: (item) => (
|
||||||
|
<div className={styles.cellStack}>
|
||||||
|
<span>{item.walletGrantId || "-"}</span>
|
||||||
|
<small>{item.grantedAtMs ? formatMillis(item.grantedAtMs) : "尚未发放"}</small>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "attribution",
|
||||||
|
label: "业务归因 / 更新",
|
||||||
|
width: "190px",
|
||||||
|
render: (item) => (
|
||||||
|
<div className={styles.cellStack}>
|
||||||
|
<span>{formatMillis(item.attributionAtMs)}</span>
|
||||||
|
<small>更新 {formatMillis(item.updatedAtMs || item.createdAtMs)}</small>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "failure",
|
||||||
|
label: "失败原因",
|
||||||
|
width: "minmax(190px, .8fr)",
|
||||||
|
render: (item) => (
|
||||||
|
<span className={styles.ellipsisCell} title={item.failureReason || ""}>
|
||||||
|
{item.failureReason || "-"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
label: "操作",
|
||||||
|
width: "72px",
|
||||||
|
render: (item) => (
|
||||||
|
<AdminRowActions>
|
||||||
|
{abilities.canRetry && item.status === "failed" ? (
|
||||||
|
<AdminActionIconButton
|
||||||
|
disabled={loadingAction === `retry-claim:${item.claimId}`}
|
||||||
|
label="重试发放"
|
||||||
|
onClick={() => onRetryClaim(item)}
|
||||||
|
>
|
||||||
|
<ReplayOutlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
|
) : null}
|
||||||
|
</AdminRowActions>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const pageCount = Math.max(1, Math.ceil(Number(data.total || 0) / Number(data.pageSize || 20)));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.dataPanel}>
|
||||||
|
<div className={styles.inlineFilters}>
|
||||||
|
<TextField
|
||||||
|
label="UTC 任务日"
|
||||||
|
select
|
||||||
|
size="small"
|
||||||
|
value={taskDay}
|
||||||
|
onChange={(event) => onDayChange(event.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem value="">全部日期</MenuItem>
|
||||||
|
{periodOptions.map((item) => (
|
||||||
|
<MenuItem key={item} value={item}>
|
||||||
|
{item}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
label="任务"
|
||||||
|
select
|
||||||
|
size="small"
|
||||||
|
value={taskKey}
|
||||||
|
onChange={(event) => onTaskKeyChange(event.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem value="">全部任务</MenuItem>
|
||||||
|
{taskOptions.map((item) => (
|
||||||
|
<MenuItem key={item.taskKey} value={item.taskKey}>
|
||||||
|
{item.taskKey}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
label="用户 ID"
|
||||||
|
placeholder="精确查询"
|
||||||
|
size="small"
|
||||||
|
value={userId}
|
||||||
|
onChange={(event) => onUserIdChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
<StatusSelect label="发放状态" options={claimStatusOptions} value={status} onChange={onStatusChange} />
|
||||||
|
</div>
|
||||||
|
<DataState error={error} loading={loading} reportPageLoading={false} onRetry={onRetry}>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
items={data.items || []}
|
||||||
|
minWidth="1510px"
|
||||||
|
rowKey={(item) => item.claimId}
|
||||||
|
total={data.total || 0}
|
||||||
|
/>
|
||||||
|
{pageCount > 1 ? (
|
||||||
|
<div className={styles.drawerPagination}>
|
||||||
|
<Pagination count={pageCount} page={page} size="small" onChange={(_, value) => onPageChange(value)} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</DataState>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function claimStatusLabel(value) {
|
||||||
|
return claimStatusOptions.find(([status]) => status === value)?.[1] || value || "未知";
|
||||||
|
}
|
||||||
@ -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(
|
||||||
|
<ActivityTemplateTaskClaimsPanel
|
||||||
|
abilities={{ canRetry: true }}
|
||||||
|
data={{
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
attributionAtMs: Date.parse("2026-07-14T03:00:00Z"),
|
||||||
|
attemptCount: 2,
|
||||||
|
claimId: "claim-101",
|
||||||
|
createdAtMs: Date.parse("2026-07-14T03:01:00Z"),
|
||||||
|
earningRegionId: 7,
|
||||||
|
failureReason: "wallet unavailable",
|
||||||
|
grantedAtMs: 0,
|
||||||
|
rewardItems: [{ name: "Avatar frame" }],
|
||||||
|
rewardName: "Daily task reward",
|
||||||
|
rewardResourceGroupId: 81,
|
||||||
|
rewardSnapshotHash: "snapshot-hash",
|
||||||
|
rewardSnapshotId: "rgs-81-v3",
|
||||||
|
status: "failed",
|
||||||
|
taskDay: "2026-07-14",
|
||||||
|
taskKey: "send_gift",
|
||||||
|
updatedAtMs: Date.parse("2026-07-14T03:02:00Z"),
|
||||||
|
user: { displayUserId: "HY503", username: "User 503" },
|
||||||
|
userId: 503,
|
||||||
|
versionNo: 3,
|
||||||
|
walletGrantId: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pageSize: 20,
|
||||||
|
total: 1,
|
||||||
|
}}
|
||||||
|
error=""
|
||||||
|
loading={false}
|
||||||
|
loadingAction=""
|
||||||
|
page={1}
|
||||||
|
periodOptions={["2026-07-14"]}
|
||||||
|
status=""
|
||||||
|
taskDay=""
|
||||||
|
taskKey=""
|
||||||
|
taskOptions={[{ taskKey: "send_gift" }]}
|
||||||
|
userId=""
|
||||||
|
onDayChange={vi.fn()}
|
||||||
|
onPageChange={vi.fn()}
|
||||||
|
onRetry={vi.fn()}
|
||||||
|
onRetryClaim={onRetryClaim}
|
||||||
|
onStatusChange={vi.fn()}
|
||||||
|
onTaskKeyChange={vi.fn()}
|
||||||
|
onUserIdChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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" }));
|
||||||
|
});
|
||||||
@ -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) => <strong>v{version.versionNo}</strong>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "published",
|
||||||
|
label: "发布时间 / 管理员",
|
||||||
|
width: "minmax(190px, .85fr)",
|
||||||
|
render: (version) => (
|
||||||
|
<div className={styles.cellStack}>
|
||||||
|
<span>{formatMillis(version.publishedAtMs)}</span>
|
||||||
|
<small>管理员 #{version.publishedByAdminId || "-"}</small>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "runtime",
|
||||||
|
label: "实际运行窗口",
|
||||||
|
width: "minmax(210px, .95fr)",
|
||||||
|
render: (version) => (
|
||||||
|
<div className={styles.cellStack}>
|
||||||
|
<span>{formatMillis(version.runtimeFromMs || version.snapshot.startMs)}</span>
|
||||||
|
<small>至 {formatRuntimeEnd(version)}</small>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "config",
|
||||||
|
label: "配置规模",
|
||||||
|
width: "minmax(150px, .7fr)",
|
||||||
|
render: (version) => (
|
||||||
|
<div className={styles.cellStack}>
|
||||||
|
<span>
|
||||||
|
{version.snapshot.gifts.length} 礼物 · {version.snapshot.tasks.length} 任务
|
||||||
|
</span>
|
||||||
|
<small>{version.snapshot.rankRewards.length} 个奖励区间</small>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
label: "操作",
|
||||||
|
width: "104px",
|
||||||
|
render: (version) => (
|
||||||
|
<AdminRowActions>
|
||||||
|
<AdminActionIconButton label="查看版本" onClick={() => onPreview(version)}>
|
||||||
|
<VisibilityOutlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
|
{abilities.canCreate ? (
|
||||||
|
<AdminActionIconButton label="基于版本复制" onClick={() => onClone(version)}>
|
||||||
|
<ContentCopyOutlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
|
) : null}
|
||||||
|
</AdminRowActions>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const pageCount = Math.max(1, Math.ceil(Number(data.total || 0) / Number(data.pageSize || 20)));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SideDrawer
|
||||||
|
className={styles.versionDrawer}
|
||||||
|
contentClassName={styles.versionDrawerBody}
|
||||||
|
open={open}
|
||||||
|
title={`发布版本 · ${source?.name || "-"}`}
|
||||||
|
width="detail"
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
<DataState error={error} loading={loading} reportPageLoading={false} onRetry={onRetry}>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
items={data.items || []}
|
||||||
|
minWidth="760px"
|
||||||
|
rowKey={(version) => `${version.templateId}-${version.versionNo}`}
|
||||||
|
total={data.total || 0}
|
||||||
|
/>
|
||||||
|
{pageCount > 1 ? (
|
||||||
|
<div className={styles.drawerPagination}>
|
||||||
|
<Pagination
|
||||||
|
count={pageCount}
|
||||||
|
page={page}
|
||||||
|
size="small"
|
||||||
|
onChange={(_, nextPage) => onPageChange(nextPage)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</DataState>
|
||||||
|
</SideDrawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRuntimeEnd(version) {
|
||||||
|
const runtimeToMs = Number(version.runtimeToMs || 0);
|
||||||
|
if (runtimeToMs > 0) return formatMillis(runtimeToMs);
|
||||||
|
return `未关闭 · 排期至 ${formatMillis(version.snapshot.endMs)}`;
|
||||||
|
}
|
||||||
@ -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 (
|
||||||
|
<EditorSection title="基础与投放">
|
||||||
|
<div className={styles.formGridThree}>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled || Boolean(form.templateId)}
|
||||||
|
label="模版编码"
|
||||||
|
required
|
||||||
|
value={form.templateCode}
|
||||||
|
onChange={(event) => updateField("templateCode", normalizeCode(event.target.value))}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
label="内部名称"
|
||||||
|
required
|
||||||
|
value={form.name}
|
||||||
|
onChange={(event) => updateField("name", event.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField disabled label="活动类型" value="礼物挑战赛" />
|
||||||
|
</div>
|
||||||
|
<div className={styles.localizedGrid}>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
label="活动标题(英语)"
|
||||||
|
value={form.titleEn}
|
||||||
|
onChange={(event) => updateField("titleEn", event.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
dir="rtl"
|
||||||
|
label="活动标题(阿语)"
|
||||||
|
value={form.titleAr}
|
||||||
|
onChange={(event) => updateField("titleAr", event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.settingRow}>
|
||||||
|
<div className={styles.settingCopy}>
|
||||||
|
<strong>活动时间</strong>
|
||||||
|
<span>UTC 毫秒存储,结束时间不包含在活动周期内</span>
|
||||||
|
</div>
|
||||||
|
<TimeRangeFilter
|
||||||
|
className={styles.timeRangeField}
|
||||||
|
disabled={disabled}
|
||||||
|
label="活动时间"
|
||||||
|
value={form.timeRange}
|
||||||
|
onChange={(value) => updateField("timeRange", value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.settingRow}>
|
||||||
|
<div className={styles.settingCopy}>
|
||||||
|
<strong>投放全部区域</strong>
|
||||||
|
<span>开启后不再提交指定区域</span>
|
||||||
|
</div>
|
||||||
|
<AdminSwitch
|
||||||
|
checked={form.allRegions}
|
||||||
|
checkedLabel="全部"
|
||||||
|
disabled={disabled}
|
||||||
|
label="投放全部区域"
|
||||||
|
uncheckedLabel="指定"
|
||||||
|
onChange={(event) => {
|
||||||
|
updateField("allRegions", event.target.checked);
|
||||||
|
if (event.target.checked) updateField("regionIds", []);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!form.allRegions ? (
|
||||||
|
<MultiValueAutocomplete
|
||||||
|
disabled={disabled}
|
||||||
|
getOptionLabel={(value) => regionLabels[String(value)] || `区域 #${value}`}
|
||||||
|
label="指定投放区域"
|
||||||
|
labels={regionLabels}
|
||||||
|
loading={loadingRegions}
|
||||||
|
options={regionOptions.map((option) => option.value)}
|
||||||
|
value={form.regionIds}
|
||||||
|
onChange={(value) => updateField("regionIds", value)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</EditorSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RulesSettingsSection({ disabled, form, updateField }) {
|
||||||
|
return (
|
||||||
|
<EditorSection title="规则文案">
|
||||||
|
<div className={styles.rulesGrid}>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
label="活动规则(英语)"
|
||||||
|
minRows={14}
|
||||||
|
multiline
|
||||||
|
value={form.rulesEn}
|
||||||
|
onChange={(event) => updateField("rulesEn", event.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
dir="rtl"
|
||||||
|
label="活动规则(阿语)"
|
||||||
|
minRows={14}
|
||||||
|
multiline
|
||||||
|
value={form.rulesAr}
|
||||||
|
onChange={(event) => updateField("rulesAr", event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</EditorSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<EditorSection title="展示配置">
|
||||||
|
<div className={styles.formGridThree}>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
label="默认页面"
|
||||||
|
select
|
||||||
|
value={config.default_tab === "leaderboard" ? "leaderboard" : "tasks"}
|
||||||
|
onChange={(event) => updateConfig(["default_tab"], event.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem value="tasks">福利任务</MenuItem>
|
||||||
|
<MenuItem value="leaderboard">荣誉榜单</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
label="榜单默认维度"
|
||||||
|
select
|
||||||
|
value={config.leaderboard?.default_board === "total" ? "total" : "daily"}
|
||||||
|
onChange={(event) => updateConfig(["leaderboard", "default_board"], event.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem value="daily">日榜</MenuItem>
|
||||||
|
<MenuItem value="total">总榜</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
label="榜单每页条数"
|
||||||
|
slotProps={{ htmlInput: { min: 10, max: 100 } }}
|
||||||
|
type="number"
|
||||||
|
value={config.leaderboard?.page_size ?? 20}
|
||||||
|
onChange={(event) => updateConfig(["leaderboard", "page_size"], Number(event.target.value))}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
label="积分展示上限"
|
||||||
|
slotProps={{ htmlInput: { min: 1 } }}
|
||||||
|
type="number"
|
||||||
|
value={config.leaderboard?.score_display_cap ?? 999999999}
|
||||||
|
onChange={(event) => updateConfig(["leaderboard", "score_display_cap"], Number(event.target.value))}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
label="未上榜文案"
|
||||||
|
value={config.leaderboard?.unranked_label ?? "100+"}
|
||||||
|
onChange={(event) => updateConfig(["leaderboard", "unranked_label"], event.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
label="礼物轮播间隔(毫秒)"
|
||||||
|
slotProps={{ htmlInput: { min: 1000 } }}
|
||||||
|
type="number"
|
||||||
|
value={config.gifts?.interval_ms ?? 3000}
|
||||||
|
onChange={(event) => updateConfig(["gifts", "interval_ms"], Number(event.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.settingRow}>
|
||||||
|
<div className={styles.settingCopy}>
|
||||||
|
<strong>礼物自动轮播</strong>
|
||||||
|
<span>礼物超出客户端容器宽度时自动滚动</span>
|
||||||
|
</div>
|
||||||
|
<AdminSwitch
|
||||||
|
checked={config.gifts?.auto_scroll !== false}
|
||||||
|
checkedLabel="开启"
|
||||||
|
disabled={disabled}
|
||||||
|
label="礼物自动轮播"
|
||||||
|
uncheckedLabel="关闭"
|
||||||
|
onChange={(event) => updateConfig(["gifts", "auto_scroll"], event.target.checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<JsonEditorField
|
||||||
|
disabled={disabled}
|
||||||
|
label="display_config_json"
|
||||||
|
minRows={12}
|
||||||
|
required
|
||||||
|
value={form.displayConfigJson}
|
||||||
|
onChange={(value) => updateField("displayConfigJson", value)}
|
||||||
|
/>
|
||||||
|
</EditorSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditorSection({ actions, children, title }) {
|
||||||
|
return (
|
||||||
|
<section className={styles.editorSection}>
|
||||||
|
<header className={styles.editorSectionHeader}>
|
||||||
|
<h3>{title}</h3>
|
||||||
|
{actions ? <div>{actions}</div> : null}
|
||||||
|
</header>
|
||||||
|
<div className={styles.editorSectionBody}>{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCode(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9_-]/g, "");
|
||||||
|
}
|
||||||
235
src/features/activity-templates/components/GiftTaskSections.jsx
Normal file
235
src/features/activity-templates/components/GiftTaskSections.jsx
Normal file
@ -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 (
|
||||||
|
<EditorSection
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
disabled={disabled || form.gifts.length >= 20}
|
||||||
|
startIcon={<AddOutlined fontSize="small" />}
|
||||||
|
onClick={addGift}
|
||||||
|
>
|
||||||
|
添加礼物
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
title="活动礼物"
|
||||||
|
>
|
||||||
|
<div className={styles.editorCountLine}>已配置 {form.gifts.length} / 20</div>
|
||||||
|
<div className={styles.sortableList}>
|
||||||
|
{form.gifts.map((gift, index) => (
|
||||||
|
<div className={styles.giftEditorRow} key={`${gift.giftId || "new"}-${index}`}>
|
||||||
|
<span className={styles.rowOrder}>{index + 1}</span>
|
||||||
|
<GiftPreviewSelectField
|
||||||
|
className={styles.giftPicker}
|
||||||
|
disabled={disabled}
|
||||||
|
drawerTitle={`选择第 ${index + 1} 个活动礼物`}
|
||||||
|
fallbackGift={giftDtoFromForm(gift)}
|
||||||
|
gifts={gifts}
|
||||||
|
label="活动礼物"
|
||||||
|
loading={giftsLoading}
|
||||||
|
value={gift.giftId}
|
||||||
|
onChange={(_, selectedGift) => updateGift(index, giftFormValue(selectedGift))}
|
||||||
|
/>
|
||||||
|
<div className={styles.rowMoveActions}>
|
||||||
|
<RowIconButton
|
||||||
|
disabled={disabled || index === 0}
|
||||||
|
label="上移"
|
||||||
|
onClick={() => moveGift(index, -1)}
|
||||||
|
>
|
||||||
|
<ArrowUpwardOutlined fontSize="small" />
|
||||||
|
</RowIconButton>
|
||||||
|
<RowIconButton
|
||||||
|
disabled={disabled || index === form.gifts.length - 1}
|
||||||
|
label="下移"
|
||||||
|
onClick={() => moveGift(index, 1)}
|
||||||
|
>
|
||||||
|
<ArrowDownwardOutlined fontSize="small" />
|
||||||
|
</RowIconButton>
|
||||||
|
<RowIconButton disabled={disabled} label="删除" onClick={() => removeGift(index)}>
|
||||||
|
<DeleteOutlineOutlined fontSize="small" />
|
||||||
|
</RowIconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!form.gifts.length ? <div className={styles.editorEmpty}>当前无数据</div> : null}
|
||||||
|
</div>
|
||||||
|
</EditorSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<EditorSection
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
disabled={disabled || form.tasks.length >= 30}
|
||||||
|
startIcon={<AddOutlined fontSize="small" />}
|
||||||
|
onClick={addTask}
|
||||||
|
>
|
||||||
|
添加任务
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
title="每日任务"
|
||||||
|
>
|
||||||
|
<div className={styles.editorCountLine}>每日 00:00 UTC 重置 · 已配置 {form.tasks.length} / 30</div>
|
||||||
|
<div className={styles.taskTable}>
|
||||||
|
<div className={styles.taskTableHead}>
|
||||||
|
<span>排序</span>
|
||||||
|
<span>任务键</span>
|
||||||
|
<span>任务类型</span>
|
||||||
|
<span>目标值</span>
|
||||||
|
<span>奖励资源组</span>
|
||||||
|
<span>操作</span>
|
||||||
|
</div>
|
||||||
|
{form.tasks.map((task, index) => (
|
||||||
|
<div className={styles.taskTableRow} key={`${task.taskKey}-${index}`}>
|
||||||
|
<span className={styles.rowOrder}>{index + 1}</span>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder="gift_count_1"
|
||||||
|
size="small"
|
||||||
|
value={task.taskKey}
|
||||||
|
onChange={(event) => updateTask(index, { taskKey: normalizeKey(event.target.value) })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
select
|
||||||
|
size="small"
|
||||||
|
value={task.taskType}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateTask(index, {
|
||||||
|
targetValue: event.target.value === "daily_visit" ? "1" : task.targetValue,
|
||||||
|
taskType: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{TASK_TYPE_OPTIONS.map(([value, label]) => (
|
||||||
|
<MenuItem
|
||||||
|
disabled={
|
||||||
|
value === "daily_visit" &&
|
||||||
|
task.taskType !== "daily_visit" &&
|
||||||
|
form.tasks.some((item) => item.taskType === "daily_visit")
|
||||||
|
}
|
||||||
|
key={value}
|
||||||
|
value={value}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled || task.taskType === "daily_visit"}
|
||||||
|
size="small"
|
||||||
|
slotProps={{ htmlInput: { min: 1 } }}
|
||||||
|
type="number"
|
||||||
|
value={task.targetValue}
|
||||||
|
onChange={(event) => updateTask(index, { targetValue: event.target.value })}
|
||||||
|
/>
|
||||||
|
<ResourceGroupSelectField
|
||||||
|
disabled={disabled}
|
||||||
|
drawerTitle={`选择任务 ${task.taskKey || index + 1} 奖励`}
|
||||||
|
groups={resourceGroups}
|
||||||
|
label="奖励资源组"
|
||||||
|
value={task.rewardResourceGroupId}
|
||||||
|
onChange={(value) => updateTask(index, { rewardResourceGroupId: value })}
|
||||||
|
/>
|
||||||
|
<div className={styles.rowMoveActions}>
|
||||||
|
<RowIconButton
|
||||||
|
disabled={disabled || index === 0}
|
||||||
|
label="上移"
|
||||||
|
onClick={() => moveTask(index, -1)}
|
||||||
|
>
|
||||||
|
<ArrowUpwardOutlined fontSize="small" />
|
||||||
|
</RowIconButton>
|
||||||
|
<RowIconButton
|
||||||
|
disabled={disabled || index === form.tasks.length - 1}
|
||||||
|
label="下移"
|
||||||
|
onClick={() => moveTask(index, 1)}
|
||||||
|
>
|
||||||
|
<ArrowDownwardOutlined fontSize="small" />
|
||||||
|
</RowIconButton>
|
||||||
|
<RowIconButton disabled={disabled} label="删除" onClick={() => removeTask(index)}>
|
||||||
|
<DeleteOutlineOutlined fontSize="small" />
|
||||||
|
</RowIconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!form.tasks.length ? <div className={styles.editorEmpty}>当前无数据</div> : null}
|
||||||
|
</div>
|
||||||
|
</EditorSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RowIconButton({ children, disabled, label, onClick }) {
|
||||||
|
return (
|
||||||
|
<Tooltip arrow title={label}>
|
||||||
|
<span>
|
||||||
|
<IconButton aria-label={label} disabled={disabled} size="small" onClick={onClick}>
|
||||||
|
{children}
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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, "");
|
||||||
|
}
|
||||||
@ -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 (
|
||||||
|
<EditorSection title="榜单奖励">
|
||||||
|
<RankBoardEditor
|
||||||
|
boardLabel="日榜"
|
||||||
|
disabled={disabled}
|
||||||
|
leaderboardSize={form.dailyLeaderboardSize}
|
||||||
|
resourceGroups={resourceGroups}
|
||||||
|
rewards={form.dailyRankRewards}
|
||||||
|
onLeaderboardSizeChange={(value) => updateField("dailyLeaderboardSize", value)}
|
||||||
|
onRewardsChange={(value) => updateField("dailyRankRewards", value)}
|
||||||
|
/>
|
||||||
|
<RankBoardEditor
|
||||||
|
boardLabel="总榜"
|
||||||
|
disabled={disabled}
|
||||||
|
leaderboardSize={form.totalLeaderboardSize}
|
||||||
|
resourceGroups={resourceGroups}
|
||||||
|
rewards={form.totalRankRewards}
|
||||||
|
onLeaderboardSizeChange={(value) => updateField("totalLeaderboardSize", value)}
|
||||||
|
onRewardsChange={(value) => updateField("totalRankRewards", value)}
|
||||||
|
/>
|
||||||
|
</EditorSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<EditorSection title="页面素材">
|
||||||
|
<div className={styles.assetGrid}>
|
||||||
|
{form.assets.map((asset, index) => (
|
||||||
|
<div className={styles.assetSlot} key={`${asset.assetKey}-${asset.locale}-${index}`}>
|
||||||
|
<UploadField
|
||||||
|
density="compact"
|
||||||
|
disabled={disabled}
|
||||||
|
hideType={false}
|
||||||
|
kind="image"
|
||||||
|
label={asset.label}
|
||||||
|
showSourceActions
|
||||||
|
value={asset.url}
|
||||||
|
onChange={(url) => updateAsset(index, url)}
|
||||||
|
/>
|
||||||
|
<div className={styles.assetMeta}>
|
||||||
|
<code>{asset.assetKey}</code>
|
||||||
|
<span>{asset.locale === "*" ? "共享" : asset.locale.toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</EditorSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<section className={styles.rankBoard}>
|
||||||
|
<header className={styles.rankBoardHeader}>
|
||||||
|
<div>
|
||||||
|
<strong>{boardLabel}</strong>
|
||||||
|
<span>{boardLabel === "日榜" ? "每日 00:00 UTC 结算" : "活动结束结算"}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.rankBoardActions}>
|
||||||
|
<TextField
|
||||||
|
className={styles.leaderboardSizeField}
|
||||||
|
disabled={disabled}
|
||||||
|
label="上榜人数"
|
||||||
|
size="small"
|
||||||
|
slotProps={{ htmlInput: { min: 3, max: 1000 } }}
|
||||||
|
type="number"
|
||||||
|
value={leaderboardSize}
|
||||||
|
onChange={(event) => onLeaderboardSizeChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Button disabled={disabled} startIcon={<AddOutlined fontSize="small" />} onClick={addReward}>
|
||||||
|
添加区间
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className={styles.rankRewardTable}>
|
||||||
|
<div className={styles.rankRewardHead}>
|
||||||
|
<span>起始名次</span>
|
||||||
|
<span>结束名次</span>
|
||||||
|
<span>奖励资源组</span>
|
||||||
|
<span>操作</span>
|
||||||
|
</div>
|
||||||
|
{rewards.map((reward, index) => (
|
||||||
|
<div className={styles.rankRewardRow} key={`${reward.rankFrom}-${reward.rankTo}-${index}`}>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
size="small"
|
||||||
|
slotProps={{ htmlInput: { min: 1 } }}
|
||||||
|
type="number"
|
||||||
|
value={reward.rankFrom}
|
||||||
|
onChange={(event) => updateReward(index, { rankFrom: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
size="small"
|
||||||
|
slotProps={{ htmlInput: { min: 1 } }}
|
||||||
|
type="number"
|
||||||
|
value={reward.rankTo}
|
||||||
|
onChange={(event) => updateReward(index, { rankTo: event.target.value })}
|
||||||
|
/>
|
||||||
|
<ResourceGroupSelectField
|
||||||
|
disabled={disabled}
|
||||||
|
drawerTitle={`选择${boardLabel}第 ${reward.rankFrom}-${reward.rankTo} 名奖励`}
|
||||||
|
groups={resourceGroups}
|
||||||
|
label="奖励资源组"
|
||||||
|
value={reward.resourceGroupId}
|
||||||
|
onChange={(value) => updateReward(index, { resourceGroupId: value })}
|
||||||
|
/>
|
||||||
|
<Tooltip arrow title="删除区间">
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
aria-label="删除区间"
|
||||||
|
disabled={disabled}
|
||||||
|
size="small"
|
||||||
|
onClick={() => removeReward(index)}
|
||||||
|
>
|
||||||
|
<DeleteOutlineOutlined fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!rewards.length ? <div className={styles.editorEmpty}>当前无数据</div> : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
src/features/activity-templates/constants.js
Normal file
58
src/features/activity-templates/constants.js
Normal file
@ -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 || "-";
|
||||||
|
}
|
||||||
193
src/features/activity-templates/dataModel.js
Normal file
193
src/features/activity-templates/dataModel.js
Normal file
@ -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();
|
||||||
|
}
|
||||||
145
src/features/activity-templates/dataModel.test.js
Normal file
145
src/features/activity-templates/dataModel.test.js
Normal file
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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 };
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
262
src/features/activity-templates/model.js
Normal file
262
src/features/activity-templates/model.js
Normal file
@ -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);
|
||||||
|
}
|
||||||
157
src/features/activity-templates/model.test.js
Normal file
157
src/features/activity-templates/model.test.js
Normal file
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
395
src/features/activity-templates/pages/ActivityTemplatesPage.jsx
Normal file
395
src/features/activity-templates/pages/ActivityTemplatesPage.jsx
Normal file
@ -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) => (
|
||||||
|
<div className={styles.templateIdentity}>
|
||||||
|
<span className={styles.templateTypeMark}>礼</span>
|
||||||
|
<span className={styles.cellStack}>
|
||||||
|
<strong>{item.name || "-"}</strong>
|
||||||
|
<small>{item.templateCode || item.templateId}</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
label: "状态",
|
||||||
|
width: "120px",
|
||||||
|
render: (item) => <ActivityTemplateStatusBadge status={item.lifecycleStatus || item.status} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "period",
|
||||||
|
label: "活动周期",
|
||||||
|
width: "minmax(210px, .95fr)",
|
||||||
|
render: (item) => (
|
||||||
|
<div className={styles.cellStack}>
|
||||||
|
<span>{formatMillis(item.startMs)}</span>
|
||||||
|
<small>至 {formatMillis(item.endMs)}</small>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "scope",
|
||||||
|
label: "投放区域",
|
||||||
|
width: "minmax(170px, .7fr)",
|
||||||
|
render: (item) => (
|
||||||
|
<div className={styles.cellStack}>
|
||||||
|
<span>{item.allRegions ? "全部区域" : regionScopeLabel(item.regionIds, regionLabels)}</span>
|
||||||
|
<small>{item.allRegions ? "全量投放" : `${item.regionIds.length} 个区域`}</small>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "version",
|
||||||
|
label: "版本",
|
||||||
|
width: "130px",
|
||||||
|
render: (item) => (
|
||||||
|
<div className={styles.cellStack}>
|
||||||
|
<span>发布 v{item.publishedVersion || 0}</span>
|
||||||
|
<small>revision {item.revision || 0}</small>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "updated",
|
||||||
|
label: "最近更新",
|
||||||
|
width: "minmax(180px, .75fr)",
|
||||||
|
render: (item) => (
|
||||||
|
<div className={styles.cellStack}>
|
||||||
|
<span>{formatMillis(item.updatedAtMs)}</span>
|
||||||
|
<small>管理员 #{item.updatedByAdminId || "-"}</small>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 (
|
||||||
|
<AdminRowActions>
|
||||||
|
{editable && page.abilities.canUpdate ? (
|
||||||
|
<AdminActionIconButton
|
||||||
|
disabled={actionLoading}
|
||||||
|
label="编辑"
|
||||||
|
onClick={() => page.openEdit(item)}
|
||||||
|
>
|
||||||
|
<EditOutlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
|
) : (
|
||||||
|
<AdminActionIconButton
|
||||||
|
disabled={actionLoading}
|
||||||
|
label="查看"
|
||||||
|
onClick={() => page.openView(item)}
|
||||||
|
>
|
||||||
|
<VisibilityOutlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
|
)}
|
||||||
|
{page.abilities.canCreate ? (
|
||||||
|
<AdminActionIconButton
|
||||||
|
disabled={actionLoading}
|
||||||
|
label="复制"
|
||||||
|
onClick={() => page.openClone(item)}
|
||||||
|
>
|
||||||
|
<ContentCopyOutlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
|
) : null}
|
||||||
|
{item.publishedVersion > 0 ? (
|
||||||
|
<AdminActionIconButton
|
||||||
|
disabled={actionLoading}
|
||||||
|
label="发布版本"
|
||||||
|
onClick={() => page.openVersions(item)}
|
||||||
|
>
|
||||||
|
<HistoryOutlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
|
) : null}
|
||||||
|
{canOpenActivityTemplateData(item, page.abilities) ? (
|
||||||
|
<AdminActionIconButton
|
||||||
|
disabled={actionLoading}
|
||||||
|
label="数据中心"
|
||||||
|
onClick={() => page.openData(item)}
|
||||||
|
>
|
||||||
|
<QueryStatsOutlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
|
) : null}
|
||||||
|
{page.abilities.canPublish && editable ? (
|
||||||
|
<AdminActionIconButton
|
||||||
|
disabled={actionLoading}
|
||||||
|
label="发布"
|
||||||
|
onClick={() => page.changeStatus(item, "published")}
|
||||||
|
>
|
||||||
|
<PublishOutlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
|
) : null}
|
||||||
|
{page.abilities.canPublish &&
|
||||||
|
item.status === "published" &&
|
||||||
|
item.lifecycleStatus !== "ended" ? (
|
||||||
|
<AdminActionIconButton
|
||||||
|
disabled={actionLoading}
|
||||||
|
label="停用"
|
||||||
|
tone="danger"
|
||||||
|
onClick={() => page.changeStatus(item, "disabled")}
|
||||||
|
>
|
||||||
|
<StopCircleOutlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
|
) : null}
|
||||||
|
{page.abilities.canDelete && canArchive ? (
|
||||||
|
<AdminActionIconButton
|
||||||
|
disabled={actionLoading}
|
||||||
|
label="归档"
|
||||||
|
tone="danger"
|
||||||
|
onClick={() => page.archiveTemplate(item)}
|
||||||
|
>
|
||||||
|
<ArchiveOutlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
|
) : null}
|
||||||
|
</AdminRowActions>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminListPage>
|
||||||
|
<AdminListToolbar
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button startIcon={<RefreshOutlined fontSize="small" />} onClick={page.reload}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
{page.abilities.canCreate ? (
|
||||||
|
<Button
|
||||||
|
startIcon={<AddOutlined fontSize="small" />}
|
||||||
|
variant="primary"
|
||||||
|
onClick={page.openCreate}
|
||||||
|
>
|
||||||
|
新增模版
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
filters={
|
||||||
|
<>
|
||||||
|
<AdminSearchBox
|
||||||
|
label="搜索"
|
||||||
|
placeholder="内部名称 / 模版编码 / 模版 ID"
|
||||||
|
value={page.keyword}
|
||||||
|
onChange={page.setKeyword}
|
||||||
|
/>
|
||||||
|
<AdminFilterSelect
|
||||||
|
label="状态"
|
||||||
|
options={ACTIVITY_TEMPLATE_STATUS_OPTIONS}
|
||||||
|
value={page.status}
|
||||||
|
onChange={page.setStatus}
|
||||||
|
/>
|
||||||
|
<RegionFilterControl
|
||||||
|
emptyLabel="全部区域"
|
||||||
|
label="区域"
|
||||||
|
loading={page.loadingRegions}
|
||||||
|
options={page.regionOptions}
|
||||||
|
value={page.regionId}
|
||||||
|
onChange={page.setRegionId}
|
||||||
|
/>
|
||||||
|
<TimeRangeFilter label="活动时间" value={page.timeRange} onChange={page.setTimeRange} />
|
||||||
|
<AdminFilterResetButton onClick={page.resetFilters} />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||||
|
<AdminListBody>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
items={page.data.items || []}
|
||||||
|
minWidth="1500px"
|
||||||
|
rowKey={(item) => item.templateId}
|
||||||
|
total={page.data.total || 0}
|
||||||
|
/>
|
||||||
|
<TablePagination
|
||||||
|
component="div"
|
||||||
|
count={Number(page.data.total || 0)}
|
||||||
|
labelDisplayedRows={({ from, to, count }) => `${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))}
|
||||||
|
/>
|
||||||
|
</AdminListBody>
|
||||||
|
</DataState>
|
||||||
|
{page.editorMode ? (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<ActivityTemplateDrawerSkeleton
|
||||||
|
className={styles.editorDrawer}
|
||||||
|
title="活动模版加载中"
|
||||||
|
onClose={page.closeEditor}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ActivityTemplateEditorDrawer
|
||||||
|
abilities={page.abilities}
|
||||||
|
dirty={page.editorDirty}
|
||||||
|
form={page.form}
|
||||||
|
gifts={page.gifts}
|
||||||
|
giftsLoading={page.giftsLoading}
|
||||||
|
loading={page.editorLoading}
|
||||||
|
loadingRegions={page.loadingRegions}
|
||||||
|
mode={page.editorMode}
|
||||||
|
open
|
||||||
|
regionOptions={page.regionOptions}
|
||||||
|
resourceGroups={page.resourceGroups}
|
||||||
|
saving={
|
||||||
|
page.loadingAction === "save" ||
|
||||||
|
page.loadingAction === "save-publish" ||
|
||||||
|
page.loadingAction === `status:${page.form.templateId}`
|
||||||
|
}
|
||||||
|
setForm={page.setForm}
|
||||||
|
validationIssues={page.validationIssues}
|
||||||
|
onClose={page.closeEditor}
|
||||||
|
onDisable={page.disableFromEditor}
|
||||||
|
onPublish={page.saveAndPublish}
|
||||||
|
onReload={page.reloadEditor}
|
||||||
|
onSubmit={page.submitForm}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
) : null}
|
||||||
|
{page.cloneSource ? (
|
||||||
|
<Suspense fallback={<ActivityTemplateCloneDialogSkeleton onClose={page.closeClone} />}>
|
||||||
|
<ActivityTemplateCloneDialog
|
||||||
|
form={page.cloneForm}
|
||||||
|
open
|
||||||
|
saving={page.loadingAction === "clone"}
|
||||||
|
source={page.cloneSource}
|
||||||
|
onChange={page.updateCloneForm}
|
||||||
|
onClose={page.closeClone}
|
||||||
|
onSubmit={page.submitClone}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
) : null}
|
||||||
|
{page.versionSource ? (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<ActivityTemplateDrawerSkeleton
|
||||||
|
className={styles.versionDrawer}
|
||||||
|
title="发布版本加载中"
|
||||||
|
width="detail"
|
||||||
|
onClose={page.closeVersions}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ActivityTemplateVersionsDrawer
|
||||||
|
abilities={page.abilities}
|
||||||
|
data={page.versions}
|
||||||
|
error={page.versionsError}
|
||||||
|
loading={page.versionsLoading}
|
||||||
|
open
|
||||||
|
page={page.versionPage}
|
||||||
|
source={page.versionSource}
|
||||||
|
onClone={page.cloneVersion}
|
||||||
|
onClose={page.closeVersions}
|
||||||
|
onPageChange={page.setVersionPage}
|
||||||
|
onPreview={page.previewVersion}
|
||||||
|
onRetry={page.reloadVersions}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
) : null}
|
||||||
|
{page.dataSource ? (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<ActivityTemplateDrawerSkeleton
|
||||||
|
className={styles.dataDrawer}
|
||||||
|
title="数据中心加载中"
|
||||||
|
onClose={page.closeData}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ActivityTemplateDataDrawer
|
||||||
|
abilities={page.abilities}
|
||||||
|
key={`${page.dataSource.templateId}:${page.dataSource.publishedVersion || 0}`}
|
||||||
|
loadingRegions={page.loadingRegions}
|
||||||
|
open
|
||||||
|
regionOptions={page.regionOptions}
|
||||||
|
source={page.dataSource}
|
||||||
|
onClose={page.closeData}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
) : null}
|
||||||
|
</AdminListPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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} 个`;
|
||||||
|
}
|
||||||
@ -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: () => <div data-testid="pagination" /> }));
|
||||||
|
|
||||||
|
vi.mock("@/features/activity-templates/hooks/useActivityTemplatesPage.js", () => ({
|
||||||
|
useActivityTemplatesPage: () => mocks.page,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/activity-templates/components/ActivityTemplateStatusBadge.jsx", () => ({
|
||||||
|
ActivityTemplateStatusBadge: () => <span />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/activity-templates/components/ActivityTemplateOverlaySkeletons.jsx", () => ({
|
||||||
|
ActivityTemplateCloneDialogSkeleton: () => <div data-testid="clone-fallback" />,
|
||||||
|
ActivityTemplateDrawerSkeleton: () => <div data-testid="drawer-fallback" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/activity-templates/components/ActivityTemplateCloneDialog.jsx", () => ({
|
||||||
|
ActivityTemplateCloneDialog: () => <div data-testid="clone-overlay" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/activity-templates/components/ActivityTemplateEditorDrawer.jsx", () => ({
|
||||||
|
ActivityTemplateEditorDrawer: () => <div data-testid="editor-overlay" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/activity-templates/components/ActivityTemplateVersionsDrawer.jsx", () => ({
|
||||||
|
ActivityTemplateVersionsDrawer: () => <div data-testid="versions-overlay" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/activity-templates/components/ActivityTemplateDataDrawer.jsx", () => ({
|
||||||
|
ActivityTemplateDataDrawer: ({ source }) => (
|
||||||
|
<div
|
||||||
|
data-testid="data-overlay"
|
||||||
|
ref={(node) => {
|
||||||
|
if (node && !node.dataset.mountCounted) {
|
||||||
|
node.dataset.mountCounted = "true";
|
||||||
|
mocks.dataMounts += 1;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{source.templateId}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/shared/ui/AdminListLayout.jsx", () => ({
|
||||||
|
AdminActionIconButton: ({ children, label, onClick }) => (
|
||||||
|
<button aria-label={label} type="button" onClick={onClick}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
AdminFilterResetButton: ({ onClick }) => (
|
||||||
|
<button type="button" onClick={onClick}>
|
||||||
|
重置
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
AdminFilterSelect: () => <div />,
|
||||||
|
AdminListBody: ({ children }) => <div>{children}</div>,
|
||||||
|
AdminListPage: ({ children }) => <main>{children}</main>,
|
||||||
|
AdminListToolbar: ({ actions, filters }) => (
|
||||||
|
<div>
|
||||||
|
{filters}
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
AdminRowActions: ({ children }) => <div>{children}</div>,
|
||||||
|
AdminSearchBox: () => <input aria-label="搜索" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/shared/ui/Button.jsx", () => ({
|
||||||
|
Button: ({ children, startIcon, ...props }) => (
|
||||||
|
<button type="button" {...props}>
|
||||||
|
{startIcon}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/shared/ui/DataState.jsx", () => ({ DataState: ({ children }) => children }));
|
||||||
|
vi.mock("@/shared/ui/DataTable.jsx", () => ({ DataTable: () => <div data-testid="data-table" /> }));
|
||||||
|
vi.mock("@/shared/ui/RegionFilterControl.jsx", () => ({ RegionFilterControl: () => <div /> }));
|
||||||
|
vi.mock("@/shared/ui/TimeRangeFilter.jsx", () => ({ TimeRangeFilter: () => <div /> }));
|
||||||
|
|
||||||
|
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(<ActivityTemplatesPage />);
|
||||||
|
|
||||||
|
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(<ActivityTemplatesPage />);
|
||||||
|
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(<ActivityTemplatesPage />);
|
||||||
|
|
||||||
|
expect(await screen.findByText("template-a")).toBeInTheDocument();
|
||||||
|
expect(mocks.dataMounts).toBe(1);
|
||||||
|
|
||||||
|
mocks.page.dataSource = { publishedVersion: 1, templateId: "template-b" };
|
||||||
|
view.rerender(<ActivityTemplatesPage />);
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
17
src/features/activity-templates/permissions.js
Normal file
17
src/features/activity-templates/permissions.js
Normal file
@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
12
src/features/activity-templates/routes.js
Normal file
12
src/features/activity-templates/routes.js
Normal file
@ -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,
|
||||||
|
},
|
||||||
|
];
|
||||||
310
src/features/activity-templates/schema.ts
Normal file
310
src/features/activity-templates/schema.ts
Normal file
@ -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<typeof activityTemplateFormSchema>;
|
||||||
|
|
||||||
|
export function validateActivityTemplateForPublish(form: ActivityTemplateForm) {
|
||||||
|
const issues: Array<{ message: string; path: Array<number | string> }> = [];
|
||||||
|
const push = (path: Array<number | string>, 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<number | string>,
|
||||||
|
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<number | string>,
|
||||||
|
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;
|
||||||
|
}
|
||||||
@ -39,6 +39,7 @@ const permissionGroupDefinitions = [
|
|||||||
{
|
{
|
||||||
key: "activities",
|
key: "activities",
|
||||||
prefixes: [
|
prefixes: [
|
||||||
|
"activity-template",
|
||||||
"achievement",
|
"achievement",
|
||||||
"agency-opening",
|
"agency-opening",
|
||||||
"cp-config",
|
"cp-config",
|
||||||
@ -56,7 +57,7 @@ const permissionGroupDefinitions = [
|
|||||||
"vip-config",
|
"vip-config",
|
||||||
"weekly-star",
|
"weekly-star",
|
||||||
],
|
],
|
||||||
title: "活动管理",
|
title: "运营活动",
|
||||||
},
|
},
|
||||||
{ key: "games", prefixes: ["game"], title: "游戏管理" },
|
{ key: "games", prefixes: ["game"], title: "游戏管理" },
|
||||||
{ key: "countries", prefixes: ["country"], title: "国家管理" },
|
{ key: "countries", prefixes: ["country"], title: "国家管理" },
|
||||||
|
|||||||
@ -37,8 +37,10 @@ export const API_OPERATIONS = {
|
|||||||
cancelJob: "cancelJob",
|
cancelJob: "cancelJob",
|
||||||
cancelRoomPin: "cancelRoomPin",
|
cancelRoomPin: "cancelRoomPin",
|
||||||
changePassword: "changePassword",
|
changePassword: "changePassword",
|
||||||
|
cloneActivityTemplate: "cloneActivityTemplate",
|
||||||
closeAgency: "closeAgency",
|
closeAgency: "closeAgency",
|
||||||
createAchievementDefinition: "createAchievementDefinition",
|
createAchievementDefinition: "createAchievementDefinition",
|
||||||
|
createActivityTemplate: "createActivityTemplate",
|
||||||
createAgency: "createAgency",
|
createAgency: "createAgency",
|
||||||
createAppVersion: "createAppVersion",
|
createAppVersion: "createAppVersion",
|
||||||
createBanner: "createBanner",
|
createBanner: "createBanner",
|
||||||
@ -82,6 +84,7 @@ export const API_OPERATIONS = {
|
|||||||
dashboardUserProfileOverview: "dashboardUserProfileOverview",
|
dashboardUserProfileOverview: "dashboardUserProfileOverview",
|
||||||
debitCoinSellerStock: "debitCoinSellerStock",
|
debitCoinSellerStock: "debitCoinSellerStock",
|
||||||
deleteAchievementDefinition: "deleteAchievementDefinition",
|
deleteAchievementDefinition: "deleteAchievementDefinition",
|
||||||
|
deleteActivityTemplate: "deleteActivityTemplate",
|
||||||
deleteAgency: "deleteAgency",
|
deleteAgency: "deleteAgency",
|
||||||
deleteAppVersion: "deleteAppVersion",
|
deleteAppVersion: "deleteAppVersion",
|
||||||
deleteBanner: "deleteBanner",
|
deleteBanner: "deleteBanner",
|
||||||
@ -117,6 +120,7 @@ export const API_OPERATIONS = {
|
|||||||
enableResourceGroup: "enableResourceGroup",
|
enableResourceGroup: "enableResourceGroup",
|
||||||
enableResourceShopItem: "enableResourceShopItem",
|
enableResourceShopItem: "enableResourceShopItem",
|
||||||
expireRoomRpsChallenge: "expireRoomRpsChallenge",
|
expireRoomRpsChallenge: "expireRoomRpsChallenge",
|
||||||
|
exportActivityTemplateData: "exportActivityTemplateData",
|
||||||
exportCoinSellerLedger: "exportCoinSellerLedger",
|
exportCoinSellerLedger: "exportCoinSellerLedger",
|
||||||
exportLoginLogs: "exportLoginLogs",
|
exportLoginLogs: "exportLoginLogs",
|
||||||
exportOperationLogs: "exportOperationLogs",
|
exportOperationLogs: "exportOperationLogs",
|
||||||
@ -124,6 +128,8 @@ export const API_OPERATIONS = {
|
|||||||
exportUsers: "exportUsers",
|
exportUsers: "exportUsers",
|
||||||
generateDiceRobots: "generateDiceRobots",
|
generateDiceRobots: "generateDiceRobots",
|
||||||
generatePrettyIds: "generatePrettyIds",
|
generatePrettyIds: "generatePrettyIds",
|
||||||
|
getActivityTemplate: "getActivityTemplate",
|
||||||
|
getActivityTemplateData: "getActivityTemplateData",
|
||||||
getCoinSellerSalaryRates: "getCoinSellerSalaryRates",
|
getCoinSellerSalaryRates: "getCoinSellerSalaryRates",
|
||||||
getCountry: "getCountry",
|
getCountry: "getCountry",
|
||||||
getCPConfig: "getCPConfig",
|
getCPConfig: "getCPConfig",
|
||||||
@ -175,6 +181,11 @@ export const API_OPERATIONS = {
|
|||||||
grantVip: "grantVip",
|
grantVip: "grantVip",
|
||||||
grantVipTrialCard: "grantVipTrialCard",
|
grantVipTrialCard: "grantVipTrialCard",
|
||||||
listAchievementDefinitions: "listAchievementDefinitions",
|
listAchievementDefinitions: "listAchievementDefinitions",
|
||||||
|
listActivityTemplateLeaderboard: "listActivityTemplateLeaderboard",
|
||||||
|
listActivityTemplateRewardJobs: "listActivityTemplateRewardJobs",
|
||||||
|
listActivityTemplates: "listActivityTemplates",
|
||||||
|
listActivityTemplateTaskClaims: "listActivityTemplateTaskClaims",
|
||||||
|
listActivityTemplateVersions: "listActivityTemplateVersions",
|
||||||
listAgencies: "listAgencies",
|
listAgencies: "listAgencies",
|
||||||
listApps: "listApps",
|
listApps: "listApps",
|
||||||
listAppVersions: "listAppVersions",
|
listAppVersions: "listAppVersions",
|
||||||
@ -290,6 +301,8 @@ export const API_OPERATIONS = {
|
|||||||
replaceUserAppScopes: "replaceUserAppScopes",
|
replaceUserAppScopes: "replaceUserAppScopes",
|
||||||
replaceUserFinanceScopes: "replaceUserFinanceScopes",
|
replaceUserFinanceScopes: "replaceUserFinanceScopes",
|
||||||
resetUserPassword: "resetUserPassword",
|
resetUserPassword: "resetUserPassword",
|
||||||
|
retryActivityTemplateRewardJob: "retryActivityTemplateRewardJob",
|
||||||
|
retryActivityTemplateTaskClaim: "retryActivityTemplateTaskClaim",
|
||||||
retryRedPacketRefund: "retryRedPacketRefund",
|
retryRedPacketRefund: "retryRedPacketRefund",
|
||||||
retryRoomRpsSettlement: "retryRoomRpsSettlement",
|
retryRoomRpsSettlement: "retryRoomRpsSettlement",
|
||||||
retryRoomTurnoverRewardSettlement: "retryRoomTurnoverRewardSettlement",
|
retryRoomTurnoverRewardSettlement: "retryRoomTurnoverRewardSettlement",
|
||||||
@ -297,6 +310,7 @@ export const API_OPERATIONS = {
|
|||||||
savePolicyTemplate: "savePolicyTemplate",
|
savePolicyTemplate: "savePolicyTemplate",
|
||||||
search: "search",
|
search: "search",
|
||||||
selfGameStatisticsOverview: "selfGameStatisticsOverview",
|
selfGameStatisticsOverview: "selfGameStatisticsOverview",
|
||||||
|
setActivityTemplateStatus: "setActivityTemplateStatus",
|
||||||
setAgencyJoinEnabled: "setAgencyJoinEnabled",
|
setAgencyJoinEnabled: "setAgencyJoinEnabled",
|
||||||
setAgencyStatus: "setAgencyStatus",
|
setAgencyStatus: "setAgencyStatus",
|
||||||
setBDLeaderStatus: "setBDLeaderStatus",
|
setBDLeaderStatus: "setBDLeaderStatus",
|
||||||
@ -317,6 +331,7 @@ export const API_OPERATIONS = {
|
|||||||
syncThirdPartyPaymentMethods: "syncThirdPartyPaymentMethods",
|
syncThirdPartyPaymentMethods: "syncThirdPartyPaymentMethods",
|
||||||
syncThirdPartyPaymentRates: "syncThirdPartyPaymentRates",
|
syncThirdPartyPaymentRates: "syncThirdPartyPaymentRates",
|
||||||
updateAchievementDefinition: "updateAchievementDefinition",
|
updateAchievementDefinition: "updateAchievementDefinition",
|
||||||
|
updateActivityTemplate: "updateActivityTemplate",
|
||||||
updateAppVersion: "updateAppVersion",
|
updateAppVersion: "updateAppVersion",
|
||||||
updateBanner: "updateBanner",
|
updateBanner: "updateBanner",
|
||||||
updateCatalog: "updateCatalog",
|
updateCatalog: "updateCatalog",
|
||||||
@ -378,6 +393,7 @@ export const API_OPERATIONS = {
|
|||||||
upsertResourceShopItems: "upsertResourceShopItems",
|
upsertResourceShopItems: "upsertResourceShopItems",
|
||||||
upsertRule: "upsertRule",
|
upsertRule: "upsertRule",
|
||||||
upsertWheelConfig: "upsertWheelConfig",
|
upsertWheelConfig: "upsertWheelConfig",
|
||||||
|
validateActivityTemplate: "validateActivityTemplate",
|
||||||
verifyFinanceCoinSellerRechargeOrder: "verifyFinanceCoinSellerRechargeOrder",
|
verifyFinanceCoinSellerRechargeOrder: "verifyFinanceCoinSellerRechargeOrder",
|
||||||
verifyFinanceCoinSellerRechargeReceipt: "verifyFinanceCoinSellerRechargeReceipt"
|
verifyFinanceCoinSellerRechargeReceipt: "verifyFinanceCoinSellerRechargeReceipt"
|
||||||
} as const;
|
} as const;
|
||||||
@ -551,6 +567,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
operationId: API_OPERATIONS.changePassword,
|
operationId: API_OPERATIONS.changePassword,
|
||||||
path: "/v1/auth/change-password"
|
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: {
|
closeAgency: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
operationId: API_OPERATIONS.closeAgency,
|
operationId: API_OPERATIONS.closeAgency,
|
||||||
@ -565,6 +588,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "achievement:create",
|
permission: "achievement:create",
|
||||||
permissions: ["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: {
|
createAgency: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
operationId: API_OPERATIONS.createAgency,
|
operationId: API_OPERATIONS.createAgency,
|
||||||
@ -866,6 +896,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "achievement:update",
|
permission: "achievement:update",
|
||||||
permissions: ["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: {
|
deleteAgency: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
operationId: API_OPERATIONS.deleteAgency,
|
operationId: API_OPERATIONS.deleteAgency,
|
||||||
@ -1111,6 +1148,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "game:update",
|
permission: "game:update",
|
||||||
permissions: ["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: {
|
exportCoinSellerLedger: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.exportCoinSellerLedger,
|
operationId: API_OPERATIONS.exportCoinSellerLedger,
|
||||||
@ -1160,6 +1204,20 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "pretty-id:generate",
|
permission: "pretty-id:generate",
|
||||||
permissions: ["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: {
|
getCoinSellerSalaryRates: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.getCoinSellerSalaryRates,
|
operationId: API_OPERATIONS.getCoinSellerSalaryRates,
|
||||||
@ -1517,6 +1575,41 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "achievement:view",
|
permission: "achievement:view",
|
||||||
permissions: ["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: {
|
listAgencies: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.listAgencies,
|
operationId: API_OPERATIONS.listAgencies,
|
||||||
@ -2308,6 +2401,20 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "user:reset-password",
|
permission: "user:reset-password",
|
||||||
permissions: ["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: {
|
retryRedPacketRefund: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
operationId: API_OPERATIONS.retryRedPacketRefund,
|
operationId: API_OPERATIONS.retryRedPacketRefund,
|
||||||
@ -2355,6 +2462,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "overview:view",
|
permission: "overview:view",
|
||||||
permissions: ["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: {
|
setAgencyJoinEnabled: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
operationId: API_OPERATIONS.setAgencyJoinEnabled,
|
operationId: API_OPERATIONS.setAgencyJoinEnabled,
|
||||||
@ -2495,6 +2609,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "achievement:update",
|
permission: "achievement:update",
|
||||||
permissions: ["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: {
|
updateAppVersion: {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
operationId: API_OPERATIONS.updateAppVersion,
|
operationId: API_OPERATIONS.updateAppVersion,
|
||||||
@ -2922,6 +3043,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "wheel:update",
|
permission: "wheel:update",
|
||||||
permissions: ["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: {
|
verifyFinanceCoinSellerRechargeOrder: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
operationId: API_OPERATIONS.verifyFinanceCoinSellerRechargeOrder,
|
operationId: API_OPERATIONS.verifyFinanceCoinSellerRechargeOrder,
|
||||||
|
|||||||
1075
src/shared/api/generated/schema.d.ts
vendored
1075
src/shared/api/generated/schema.d.ts
vendored
File diff suppressed because it is too large
Load Diff
141
src/shared/charts/MultiSeriesLineChart.jsx
Normal file
141
src/shared/charts/MultiSeriesLineChart.jsx
Normal file
@ -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 (
|
||||||
|
<div aria-label={ariaLabel} className="multi-series-line-chart__empty" role="status">
|
||||||
|
{emptyText}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div aria-label={ariaLabel} className="multi-series-line-chart" role="group">
|
||||||
|
<EChart className="multi-series-line-chart__canvas" option={option} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
48
src/shared/charts/MultiSeriesLineChart.test.jsx
Normal file
48
src/shared/charts/MultiSeriesLineChart.test.jsx
Normal file
@ -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 }) => <div className={className} data-testid="multi-series-echart" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
createMultiSeriesLineOption,
|
||||||
|
MultiSeriesLineChart,
|
||||||
|
normalizeSeries,
|
||||||
|
} from "@/shared/charts/MultiSeriesLineChart.jsx";
|
||||||
|
|
||||||
|
test("renders the standard empty state without creating an ECharts instance", () => {
|
||||||
|
render(<MultiSeriesLineChart ariaLabel="空趋势" categories={[]} series={[]} />);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<MultiSeriesLineChart
|
||||||
|
ariaLabel="活动趋势"
|
||||||
|
categories={["2026-01-01"]}
|
||||||
|
series={[{ data: [3], key: "users", name: "参与用户" }]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("group", { name: "活动趋势" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("multi-series-echart")).toBeInTheDocument();
|
||||||
|
});
|
||||||
@ -105,6 +105,7 @@
|
|||||||
--chart-green: #16a34a;
|
--chart-green: #16a34a;
|
||||||
--chart-red: #dc2626;
|
--chart-red: #dc2626;
|
||||||
--chart-orange: #d97706;
|
--chart-orange: #d97706;
|
||||||
|
--chart-violet: #7c3aed;
|
||||||
--chart-grid: rgba(100, 116, 139, 0.16);
|
--chart-grid: rgba(100, 116, 139, 0.16);
|
||||||
--chart-tooltip-bg: #ffffff;
|
--chart-tooltip-bg: #ffffff;
|
||||||
--chart-tooltip-border: #d8e0ec;
|
--chart-tooltip-border: #d8e0ec;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user