活动模版

This commit is contained in:
ZuoZuo 2026-07-15 09:44:04 +08:00
parent da6d2dfdb9
commit 2eb18502a2
45 changed files with 11628 additions and 2 deletions

File diff suppressed because it is too large Load Diff

View 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 |

View File

@ -40,6 +40,7 @@ import { getRouteByMenuCode } from "@/app/router/routeConfig";
const deprecatedMenuCodes = new Set(["services"]);
const menuLabelOverrides = {
activities: "运营活动",
system: "后台设置",
"system-users": "后台用户",
"system-teams": "团队配置",
@ -212,8 +213,9 @@ export const fallbackNavigation = [
code: "activities",
icon: CampaignOutlined,
id: "activities",
label: "活动管理",
label: "运营活动",
children: [
routeNavItem("activity-template", { icon: CampaignOutlined }),
routeNavItem("daily-task-list", { icon: TaskAltOutlined }),
routeNavItem("registration-reward", { icon: CardGiftcardOutlined }),
routeNavItem("first-recharge-reward", { icon: CardGiftcardOutlined }),

View File

@ -19,6 +19,18 @@ describe("navigation menu helpers", () => {
]);
});
test("exposes activity templates below the operations activity menu", () => {
const activities = fallbackNavigation.find((item) => item.code === "activities");
const template = activities.children.find((item) => item.code === "activity-template");
expect(activities.label).toBe("运营活动");
expect(template).toMatchObject({
label: "活动模版",
path: "/activities/templates",
permissionCode: "activity-template:view",
});
});
test("normalizes requested parent menu order from backend menus", () => {
const mapped = mapBackendMenus([
{ children: [], code: "system", icon: "settings", id: "system", label: "后台设置" },

View File

@ -159,6 +159,14 @@ export const PERMISSIONS = {
giftDelete: "gift:delete",
emojiPackView: "emoji-pack:view",
emojiPackCreate: "emoji-pack:create",
activityTemplateView: "activity-template:view",
activityTemplateCreate: "activity-template:create",
activityTemplateUpdate: "activity-template:update",
activityTemplatePublish: "activity-template:publish",
activityTemplateDelete: "activity-template:delete",
activityTemplateData: "activity-template:data",
activityTemplateExport: "activity-template:export",
activityTemplateRetry: "activity-template:retry",
dailyTaskView: "daily-task:view",
dailyTaskCreate: "daily-task:create",
dailyTaskUpdate: "daily-task:update",
@ -260,6 +268,7 @@ export const MENU_CODES = {
luckyGift: "lucky-gift",
wheel: "wheel",
activities: "activities",
activityTemplate: "activity-template",
dailyTaskList: "daily-task-list",
registrationReward: "registration-reward",
firstRechargeReward: "first-recharge-reward",

View File

@ -1,6 +1,7 @@
import { authRoutes } from "@/features/auth/routes.js";
import { agencyOpeningRoutes } from "@/features/agency-opening/routes.js";
import { achievementRoutes } from "@/features/achievements/routes.js";
import { activityTemplatesRoutes } from "@/features/activity-templates/routes.js";
import { appConfigRoutes } from "@/features/app-config/routes.js";
import { appUserRoutes } from "@/features/app-users/routes.js";
import { cumulativeRechargeRewardRoutes } from "@/features/cumulative-recharge-reward/routes.js";
@ -48,6 +49,7 @@ export const adminRoutes: AdminRoute[] = [
...regionBlockRoutes,
...roomRoutes,
...appConfigRoutes,
...activityTemplatesRoutes,
...dailyTaskRoutes,
...achievementRoutes,
...registrationRewardRoutes,

File diff suppressed because it is too large Load Diff

View 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;
}

View File

@ -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`;
}

View File

@ -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");
});
});

View File

@ -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, "");
}

View File

@ -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>
);
}

View File

@ -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 || "-";
}

View File

@ -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 "编辑活动模版";
}

View File

@ -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,
};
}

View File

@ -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 || "未知";
}

View File

@ -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>
);
}

View File

@ -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 || "未知";
}

View File

@ -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" }));
});

View File

@ -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>
);
}

View File

@ -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 || "未知";
}

View File

@ -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" }));
});

View File

@ -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)}`;
}

View File

@ -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, "");
}

View 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, "");
}

View File

@ -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>
);
}

View 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 || "-";
}

View 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();
}

View 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");
});
});

View File

@ -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 };
}

View File

@ -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,
};
}

View File

@ -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,
};
}

View 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);
}

View 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");
});
});

View 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}`;
}

View File

@ -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,
};
}

View 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),
};
}

View 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,
},
];

View 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;
}

View File

@ -39,6 +39,7 @@ const permissionGroupDefinitions = [
{
key: "activities",
prefixes: [
"activity-template",
"achievement",
"agency-opening",
"cp-config",
@ -56,7 +57,7 @@ const permissionGroupDefinitions = [
"vip-config",
"weekly-star",
],
title: "活动管理",
title: "运营活动",
},
{ key: "games", prefixes: ["game"], title: "游戏管理" },
{ key: "countries", prefixes: ["country"], title: "国家管理" },

View File

@ -37,8 +37,10 @@ export const API_OPERATIONS = {
cancelJob: "cancelJob",
cancelRoomPin: "cancelRoomPin",
changePassword: "changePassword",
cloneActivityTemplate: "cloneActivityTemplate",
closeAgency: "closeAgency",
createAchievementDefinition: "createAchievementDefinition",
createActivityTemplate: "createActivityTemplate",
createAgency: "createAgency",
createAppVersion: "createAppVersion",
createBanner: "createBanner",
@ -82,6 +84,7 @@ export const API_OPERATIONS = {
dashboardUserProfileOverview: "dashboardUserProfileOverview",
debitCoinSellerStock: "debitCoinSellerStock",
deleteAchievementDefinition: "deleteAchievementDefinition",
deleteActivityTemplate: "deleteActivityTemplate",
deleteAgency: "deleteAgency",
deleteAppVersion: "deleteAppVersion",
deleteBanner: "deleteBanner",
@ -117,6 +120,7 @@ export const API_OPERATIONS = {
enableResourceGroup: "enableResourceGroup",
enableResourceShopItem: "enableResourceShopItem",
expireRoomRpsChallenge: "expireRoomRpsChallenge",
exportActivityTemplateData: "exportActivityTemplateData",
exportCoinSellerLedger: "exportCoinSellerLedger",
exportLoginLogs: "exportLoginLogs",
exportOperationLogs: "exportOperationLogs",
@ -124,6 +128,8 @@ export const API_OPERATIONS = {
exportUsers: "exportUsers",
generateDiceRobots: "generateDiceRobots",
generatePrettyIds: "generatePrettyIds",
getActivityTemplate: "getActivityTemplate",
getActivityTemplateData: "getActivityTemplateData",
getCoinSellerSalaryRates: "getCoinSellerSalaryRates",
getCountry: "getCountry",
getCPConfig: "getCPConfig",
@ -175,6 +181,11 @@ export const API_OPERATIONS = {
grantVip: "grantVip",
grantVipTrialCard: "grantVipTrialCard",
listAchievementDefinitions: "listAchievementDefinitions",
listActivityTemplateLeaderboard: "listActivityTemplateLeaderboard",
listActivityTemplateRewardJobs: "listActivityTemplateRewardJobs",
listActivityTemplates: "listActivityTemplates",
listActivityTemplateTaskClaims: "listActivityTemplateTaskClaims",
listActivityTemplateVersions: "listActivityTemplateVersions",
listAgencies: "listAgencies",
listApps: "listApps",
listAppVersions: "listAppVersions",
@ -290,6 +301,8 @@ export const API_OPERATIONS = {
replaceUserAppScopes: "replaceUserAppScopes",
replaceUserFinanceScopes: "replaceUserFinanceScopes",
resetUserPassword: "resetUserPassword",
retryActivityTemplateRewardJob: "retryActivityTemplateRewardJob",
retryActivityTemplateTaskClaim: "retryActivityTemplateTaskClaim",
retryRedPacketRefund: "retryRedPacketRefund",
retryRoomRpsSettlement: "retryRoomRpsSettlement",
retryRoomTurnoverRewardSettlement: "retryRoomTurnoverRewardSettlement",
@ -297,6 +310,7 @@ export const API_OPERATIONS = {
savePolicyTemplate: "savePolicyTemplate",
search: "search",
selfGameStatisticsOverview: "selfGameStatisticsOverview",
setActivityTemplateStatus: "setActivityTemplateStatus",
setAgencyJoinEnabled: "setAgencyJoinEnabled",
setAgencyStatus: "setAgencyStatus",
setBDLeaderStatus: "setBDLeaderStatus",
@ -317,6 +331,7 @@ export const API_OPERATIONS = {
syncThirdPartyPaymentMethods: "syncThirdPartyPaymentMethods",
syncThirdPartyPaymentRates: "syncThirdPartyPaymentRates",
updateAchievementDefinition: "updateAchievementDefinition",
updateActivityTemplate: "updateActivityTemplate",
updateAppVersion: "updateAppVersion",
updateBanner: "updateBanner",
updateCatalog: "updateCatalog",
@ -378,6 +393,7 @@ export const API_OPERATIONS = {
upsertResourceShopItems: "upsertResourceShopItems",
upsertRule: "upsertRule",
upsertWheelConfig: "upsertWheelConfig",
validateActivityTemplate: "validateActivityTemplate",
verifyFinanceCoinSellerRechargeOrder: "verifyFinanceCoinSellerRechargeOrder",
verifyFinanceCoinSellerRechargeReceipt: "verifyFinanceCoinSellerRechargeReceipt"
} as const;
@ -551,6 +567,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
operationId: API_OPERATIONS.changePassword,
path: "/v1/auth/change-password"
},
cloneActivityTemplate: {
method: "POST",
operationId: API_OPERATIONS.cloneActivityTemplate,
path: "/v1/admin/activities/templates/{template_id}/clone",
permission: "activity-template:create",
permissions: ["activity-template:create"]
},
closeAgency: {
method: "POST",
operationId: API_OPERATIONS.closeAgency,
@ -565,6 +588,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "achievement:create",
permissions: ["achievement:create"]
},
createActivityTemplate: {
method: "POST",
operationId: API_OPERATIONS.createActivityTemplate,
path: "/v1/admin/activities/templates",
permission: "activity-template:create",
permissions: ["activity-template:create"]
},
createAgency: {
method: "POST",
operationId: API_OPERATIONS.createAgency,
@ -866,6 +896,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "achievement:update",
permissions: ["achievement:update"]
},
deleteActivityTemplate: {
method: "DELETE",
operationId: API_OPERATIONS.deleteActivityTemplate,
path: "/v1/admin/activities/templates/{template_id}",
permission: "activity-template:delete",
permissions: ["activity-template:delete"]
},
deleteAgency: {
method: "POST",
operationId: API_OPERATIONS.deleteAgency,
@ -1111,6 +1148,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "game:update",
permissions: ["game:update"]
},
exportActivityTemplateData: {
method: "GET",
operationId: API_OPERATIONS.exportActivityTemplateData,
path: "/v1/admin/activities/templates/{template_id}/data/export",
permission: "activity-template:export",
permissions: ["activity-template:export"]
},
exportCoinSellerLedger: {
method: "GET",
operationId: API_OPERATIONS.exportCoinSellerLedger,
@ -1160,6 +1204,20 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "pretty-id:generate",
permissions: ["pretty-id:generate"]
},
getActivityTemplate: {
method: "GET",
operationId: API_OPERATIONS.getActivityTemplate,
path: "/v1/admin/activities/templates/{template_id}",
permission: "activity-template:view",
permissions: ["activity-template:view"]
},
getActivityTemplateData: {
method: "GET",
operationId: API_OPERATIONS.getActivityTemplateData,
path: "/v1/admin/activities/templates/{template_id}/data",
permission: "activity-template:data",
permissions: ["activity-template:data"]
},
getCoinSellerSalaryRates: {
method: "GET",
operationId: API_OPERATIONS.getCoinSellerSalaryRates,
@ -1517,6 +1575,41 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "achievement:view",
permissions: ["achievement:view"]
},
listActivityTemplateLeaderboard: {
method: "GET",
operationId: API_OPERATIONS.listActivityTemplateLeaderboard,
path: "/v1/admin/activities/templates/{template_id}/leaderboard",
permission: "activity-template:data",
permissions: ["activity-template:data"]
},
listActivityTemplateRewardJobs: {
method: "GET",
operationId: API_OPERATIONS.listActivityTemplateRewardJobs,
path: "/v1/admin/activities/templates/{template_id}/rewards",
permission: "activity-template:data",
permissions: ["activity-template:data"]
},
listActivityTemplates: {
method: "GET",
operationId: API_OPERATIONS.listActivityTemplates,
path: "/v1/admin/activities/templates",
permission: "activity-template:view",
permissions: ["activity-template:view"]
},
listActivityTemplateTaskClaims: {
method: "GET",
operationId: API_OPERATIONS.listActivityTemplateTaskClaims,
path: "/v1/admin/activities/templates/{template_id}/task-claims",
permission: "activity-template:data",
permissions: ["activity-template:data"]
},
listActivityTemplateVersions: {
method: "GET",
operationId: API_OPERATIONS.listActivityTemplateVersions,
path: "/v1/admin/activities/templates/{template_id}/versions",
permission: "activity-template:view",
permissions: ["activity-template:view"]
},
listAgencies: {
method: "GET",
operationId: API_OPERATIONS.listAgencies,
@ -2308,6 +2401,20 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "user:reset-password",
permissions: ["user:reset-password"]
},
retryActivityTemplateRewardJob: {
method: "POST",
operationId: API_OPERATIONS.retryActivityTemplateRewardJob,
path: "/v1/admin/activities/templates/{template_id}/rewards/{reward_job_id}/retry",
permission: "activity-template:retry",
permissions: ["activity-template:retry"]
},
retryActivityTemplateTaskClaim: {
method: "POST",
operationId: API_OPERATIONS.retryActivityTemplateTaskClaim,
path: "/v1/admin/activities/templates/{template_id}/task-claims/{claim_id}/retry",
permission: "activity-template:retry",
permissions: ["activity-template:retry"]
},
retryRedPacketRefund: {
method: "POST",
operationId: API_OPERATIONS.retryRedPacketRefund,
@ -2355,6 +2462,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "overview:view",
permissions: ["overview:view"]
},
setActivityTemplateStatus: {
method: "PATCH",
operationId: API_OPERATIONS.setActivityTemplateStatus,
path: "/v1/admin/activities/templates/{template_id}/status",
permission: "activity-template:publish",
permissions: ["activity-template:publish"]
},
setAgencyJoinEnabled: {
method: "POST",
operationId: API_OPERATIONS.setAgencyJoinEnabled,
@ -2495,6 +2609,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "achievement:update",
permissions: ["achievement:update"]
},
updateActivityTemplate: {
method: "PUT",
operationId: API_OPERATIONS.updateActivityTemplate,
path: "/v1/admin/activities/templates/{template_id}",
permission: "activity-template:update",
permissions: ["activity-template:update"]
},
updateAppVersion: {
method: "PUT",
operationId: API_OPERATIONS.updateAppVersion,
@ -2922,6 +3043,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "wheel:update",
permissions: ["wheel:update"]
},
validateActivityTemplate: {
method: "POST",
operationId: API_OPERATIONS.validateActivityTemplate,
path: "/v1/admin/activities/templates/validate",
permission: "activity-template:update",
permissions: ["activity-template:create","activity-template:update","activity-template:publish"]
},
verifyFinanceCoinSellerRechargeOrder: {
method: "POST",
operationId: API_OPERATIONS.verifyFinanceCoinSellerRechargeOrder,

File diff suppressed because it is too large Load Diff

View 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
);
}

View 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();
});

View File

@ -105,6 +105,7 @@
--chart-green: #16a34a;
--chart-red: #dc2626;
--chart-orange: #d97706;
--chart-violet: #7c3aed;
--chart-grid: rgba(100, 116, 139, 0.16);
--chart-tooltip-bg: #ffffff;
--chart-tooltip-border: #d8e0ec;