优化UI完善功能
This commit is contained in:
parent
f5a6c6685c
commit
bdeb3cd15a
@ -10,6 +10,56 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/admin/activity/achievements": {
|
||||
"get": {
|
||||
"operationId": "listAchievementDefinitions",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "achievement:view",
|
||||
"x-permissions": [
|
||||
"achievement:view"
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"operationId": "createAchievementDefinition",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "achievement:create",
|
||||
"x-permissions": [
|
||||
"achievement:create"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/admin/activity/achievements/{achievement_id}": {
|
||||
"put": {
|
||||
"operationId": "updateAchievementDefinition",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "achievement_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"x-permission": "achievement:update",
|
||||
"x-permissions": [
|
||||
"achievement:update"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/admin/activity/daily-tasks": {
|
||||
"get": {
|
||||
"operationId": "listTaskDefinitions",
|
||||
@ -775,6 +825,46 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/admin/emoji-packs": {
|
||||
"get": {
|
||||
"operationId": "listEmojiPacks",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "emoji-pack:view",
|
||||
"x-permissions": [
|
||||
"emoji-pack:view"
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"operationId": "createEmojiPack",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "emoji-pack:create",
|
||||
"x-permissions": [
|
||||
"emoji-pack:create"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/admin/emoji-packs/categories": {
|
||||
"get": {
|
||||
"operationId": "listEmojiPackCategories",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "emoji-pack:view",
|
||||
"x-permissions": [
|
||||
"emoji-pack:view"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/admin/files/image/upload": {
|
||||
"post": {
|
||||
"operationId": "uploadImage",
|
||||
@ -1059,6 +1149,46 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/admin/operations/coin-adjustments": {
|
||||
"get": {
|
||||
"operationId": "listCoinAdjustments",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "coin-adjustment:view",
|
||||
"x-permissions": [
|
||||
"coin-adjustment:view"
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"operationId": "createCoinAdjustment",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "coin-adjustment:create",
|
||||
"x-permissions": [
|
||||
"coin-adjustment:create"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/admin/operations/coin-adjustments/target": {
|
||||
"get": {
|
||||
"operationId": "lookupCoinAdjustmentTarget",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "coin-adjustment:create",
|
||||
"x-permissions": [
|
||||
"coin-adjustment:create"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/admin/operations/coin-ledger": {
|
||||
"get": {
|
||||
"operationId": "listCoinLedger",
|
||||
@ -1743,6 +1873,140 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/admin/users/level-config": {
|
||||
"get": {
|
||||
"operationId": "listLevelConfig",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "level-config:view",
|
||||
"x-permissions": [
|
||||
"level-config:view"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/admin/users/level-config/rules/{track}/{level}": {
|
||||
"put": {
|
||||
"operationId": "upsertRule",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "track",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "path",
|
||||
"name": "level",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"x-permission": "level-config:update",
|
||||
"x-permissions": [
|
||||
"level-config:update"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/admin/users/level-config/tiers": {
|
||||
"post": {
|
||||
"operationId": "createTier",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "level-config:update",
|
||||
"x-permissions": [
|
||||
"level-config:update"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/admin/users/level-config/tiers/{tier_id}": {
|
||||
"put": {
|
||||
"operationId": "updateTier",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "tier_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"x-permission": "level-config:update",
|
||||
"x-permissions": [
|
||||
"level-config:update"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/admin/users/level-config/tracks/{track}": {
|
||||
"put": {
|
||||
"operationId": "updateTrack",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "track",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"x-permission": "level-config:update",
|
||||
"x-permissions": [
|
||||
"level-config:update"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/admin/users/region-blocks": {
|
||||
"get": {
|
||||
"operationId": "listRegionBlocks",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "region-block:view",
|
||||
"x-permissions": [
|
||||
"region-block:view"
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"operationId": "replaceRegionBlocks",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "region-block:update",
|
||||
"x-permissions": [
|
||||
"region-block:update"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/app/users": {
|
||||
"get": {
|
||||
"operationId": "appListUsers",
|
||||
|
||||
@ -84,6 +84,7 @@ export const fallbackNavigation = [
|
||||
routeNavItem("app-user-list", { icon: ManageAccountsOutlined }),
|
||||
routeNavItem("app-user-bans", { icon: BlockOutlined }),
|
||||
routeNavItem("app-user-login-logs", { icon: LoginOutlined }),
|
||||
routeNavItem("app-user-region-blocks", { icon: PublicOutlined }),
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -119,6 +120,7 @@ export const fallbackNavigation = [
|
||||
routeNavItem("resource-group-list", { icon: CategoryOutlined }),
|
||||
routeNavItem("gift-list", { icon: CardGiftcardOutlined }),
|
||||
routeNavItem("resource-grant-list", { icon: SendOutlined }),
|
||||
routeNavItem("emoji-pack-list", { icon: ImageOutlined }),
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -126,7 +128,10 @@ export const fallbackNavigation = [
|
||||
icon: ReceiptLongOutlined,
|
||||
id: "operations",
|
||||
label: "运营管理",
|
||||
children: [routeNavItem("operation-coin-ledger", { icon: ReceiptLongOutlined })],
|
||||
children: [
|
||||
routeNavItem("operation-coin-ledger", { icon: ReceiptLongOutlined }),
|
||||
routeNavItem("operation-coin-adjustment", { icon: WalletOutlined }),
|
||||
],
|
||||
},
|
||||
{
|
||||
code: "activities",
|
||||
|
||||
@ -26,6 +26,8 @@ export const PERMISSIONS = {
|
||||
coinSellerUpdate: "coin-seller:update",
|
||||
coinSellerStockCredit: "coin-seller:stock-credit",
|
||||
coinLedgerView: "coin-ledger:view",
|
||||
coinAdjustmentView: "coin-adjustment:view",
|
||||
coinAdjustmentCreate: "coin-adjustment:create",
|
||||
paymentBillView: "payment-bill:view",
|
||||
paymentProductView: "payment-product:view",
|
||||
paymentProductCreate: "payment-product:create",
|
||||
@ -59,6 +61,10 @@ export const PERMISSIONS = {
|
||||
appUserUpdate: "app-user:update",
|
||||
appUserStatus: "app-user:status",
|
||||
appUserPassword: "app-user:password",
|
||||
levelConfigView: "level-config:view",
|
||||
levelConfigUpdate: "level-config:update",
|
||||
regionBlockView: "region-block:view",
|
||||
regionBlockUpdate: "region-block:update",
|
||||
roomView: "room:view",
|
||||
roomUpdate: "room:update",
|
||||
roomDelete: "room:delete",
|
||||
@ -85,10 +91,15 @@ export const PERMISSIONS = {
|
||||
giftCreate: "gift:create",
|
||||
giftUpdate: "gift:update",
|
||||
giftStatus: "gift:status",
|
||||
emojiPackView: "emoji-pack:view",
|
||||
emojiPackCreate: "emoji-pack:create",
|
||||
dailyTaskView: "daily-task:view",
|
||||
dailyTaskCreate: "daily-task:create",
|
||||
dailyTaskUpdate: "daily-task:update",
|
||||
dailyTaskStatus: "daily-task:status",
|
||||
achievementView: "achievement:view",
|
||||
achievementCreate: "achievement:create",
|
||||
achievementUpdate: "achievement:update",
|
||||
registrationRewardView: "registration-reward:view",
|
||||
registrationRewardUpdate: "registration-reward:update",
|
||||
uploadCreate: "upload:create",
|
||||
@ -114,6 +125,8 @@ export const MENU_CODES = {
|
||||
appUserList: "app-user-list",
|
||||
appUserBans: "app-user-bans",
|
||||
appUserLoginLogs: "app-user-login-logs",
|
||||
appUserLevelConfig: "app-user-level-config",
|
||||
appUserRegionBlocks: "app-user-region-blocks",
|
||||
rooms: "rooms",
|
||||
roomList: "room-list",
|
||||
roomPins: "room-pins",
|
||||
@ -127,11 +140,14 @@ export const MENU_CODES = {
|
||||
resourceGroupList: "resource-group-list",
|
||||
resourceGrantList: "resource-grant-list",
|
||||
giftList: "gift-list",
|
||||
emojiPackList: "emoji-pack-list",
|
||||
operations: "operations",
|
||||
operationCoinLedger: "operation-coin-ledger",
|
||||
operationCoinAdjustment: "operation-coin-adjustment",
|
||||
activities: "activities",
|
||||
dailyTaskList: "daily-task-list",
|
||||
registrationReward: "registration-reward",
|
||||
achievementConfig: "achievement-config",
|
||||
geo: "geo",
|
||||
hostOrg: "host-org",
|
||||
hostOrgCountries: "host-org-countries",
|
||||
|
||||
@ -1,15 +1,18 @@
|
||||
import { authRoutes } from "@/features/auth/routes.js";
|
||||
import { achievementRoutes } from "@/features/achievements/routes.js";
|
||||
import { appConfigRoutes } from "@/features/app-config/routes.js";
|
||||
import { appUserRoutes } from "@/features/app-users/routes.js";
|
||||
import { dashboardRoutes } from "@/features/dashboard/routes.js";
|
||||
import { dailyTaskRoutes } from "@/features/daily-tasks/routes.js";
|
||||
import { gameRoutes } from "@/features/games/routes.js";
|
||||
import { hostOrgRoutes } from "@/features/host-org/routes.js";
|
||||
import { levelConfigRoutes } from "@/features/level-config/routes.js";
|
||||
import { logsRoutes } from "@/features/logs/routes.js";
|
||||
import { menusRoutes } from "@/features/menus/routes.js";
|
||||
import { operationsRoutes } from "@/features/operations/routes.js";
|
||||
import { paymentRoutes } from "@/features/payment/routes.js";
|
||||
import { registrationRewardRoutes } from "@/features/registration-reward/routes.js";
|
||||
import { regionBlockRoutes } from "@/features/region-blocks/routes.js";
|
||||
import { resourceRoutes } from "@/features/resources/routes.js";
|
||||
import { rolesRoutes } from "@/features/roles/routes.js";
|
||||
import { roomRoutes } from "@/features/rooms/routes.js";
|
||||
@ -22,9 +25,12 @@ export const publicRoutes: PublicRoute[] = authRoutes;
|
||||
export const adminRoutes: AdminRoute[] = [
|
||||
...dashboardRoutes,
|
||||
...appUserRoutes,
|
||||
...levelConfigRoutes,
|
||||
...regionBlockRoutes,
|
||||
...roomRoutes,
|
||||
...appConfigRoutes,
|
||||
...dailyTaskRoutes,
|
||||
...achievementRoutes,
|
||||
...registrationRewardRoutes,
|
||||
...resourceRoutes,
|
||||
...operationsRoutes,
|
||||
|
||||
51
src/features/achievements/achievements.module.css
Normal file
51
src/features/achievements/achievements.module.css
Normal file
@ -0,0 +1,51 @@
|
||||
.identity {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.identityText,
|
||||
.stack {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.name,
|
||||
.resourceText {
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
font-weight: 650;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meta,
|
||||
.emptyValue {
|
||||
overflow: hidden;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.typeBadge {
|
||||
flex: 0 0 auto;
|
||||
min-width: 62px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 999px;
|
||||
padding: 3px 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.statusCell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
178
src/features/achievements/api.ts
Normal file
178
src/features/achievements/api.ts
Normal file
@ -0,0 +1,178 @@
|
||||
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||
import { apiRequest } from "@/shared/api/request";
|
||||
import type { ApiPage, EntityId, PageQuery } from "@/shared/api/types";
|
||||
|
||||
export interface AchievementConditionDto {
|
||||
achievementId?: string;
|
||||
conditionId?: string;
|
||||
dimensionFilterJson?: string;
|
||||
metricType: string;
|
||||
targetUnit?: string;
|
||||
targetValue: number;
|
||||
version?: number;
|
||||
}
|
||||
|
||||
export interface AchievementDefinitionDto {
|
||||
achievementId: string;
|
||||
achievementType: string;
|
||||
autoPinPolicy?: string;
|
||||
collectionId?: string;
|
||||
collectionType: string;
|
||||
conditions: AchievementConditionDto[];
|
||||
createdAtMs?: number;
|
||||
createdByAdminId?: number;
|
||||
description?: string;
|
||||
displayConfigJson?: string;
|
||||
effectiveFromMs?: number;
|
||||
effectiveToMs?: number;
|
||||
honorVisibility?: boolean;
|
||||
primaryBadgeResourceId?: number;
|
||||
rewardResourceGroupId?: number;
|
||||
sortOrder?: number;
|
||||
status: string;
|
||||
title: string;
|
||||
updatedAtMs?: number;
|
||||
updatedByAdminId?: number;
|
||||
version?: number;
|
||||
}
|
||||
|
||||
export interface AchievementConditionPayload {
|
||||
dimension_filter_json: string;
|
||||
metric_type: string;
|
||||
target_unit: string;
|
||||
target_value: number;
|
||||
}
|
||||
|
||||
export interface AchievementDefinitionPayload {
|
||||
achievement_type: string;
|
||||
auto_pin_policy: string;
|
||||
collection_id: string;
|
||||
collection_type: string;
|
||||
conditions: AchievementConditionPayload[];
|
||||
description: string;
|
||||
display_config_json: string;
|
||||
effective_from_ms: number;
|
||||
effective_to_ms: number;
|
||||
honor_visibility: boolean;
|
||||
primary_badge_resource_id: number;
|
||||
reward_resource_group_id: number;
|
||||
sort_order: number;
|
||||
status: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
type AnyRecord = Record<string, unknown>;
|
||||
|
||||
export function listAchievementDefinitions(query: PageQuery = {}): Promise<ApiPage<AchievementDefinitionDto>> {
|
||||
const endpoint = API_ENDPOINTS.listAchievementDefinitions;
|
||||
return apiRequest<ApiPage<unknown>>(apiEndpointPath(API_OPERATIONS.listAchievementDefinitions), {
|
||||
method: endpoint.method,
|
||||
query,
|
||||
}).then(normalizeAchievementPage);
|
||||
}
|
||||
|
||||
export function createAchievementDefinition(payload: AchievementDefinitionPayload): Promise<AchievementDefinitionDto> {
|
||||
const endpoint = API_ENDPOINTS.createAchievementDefinition;
|
||||
return apiRequest<unknown, AchievementDefinitionPayload>(
|
||||
apiEndpointPath(API_OPERATIONS.createAchievementDefinition),
|
||||
{
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
},
|
||||
).then(normalizeAchievement);
|
||||
}
|
||||
|
||||
export function updateAchievementDefinition(
|
||||
achievementId: EntityId,
|
||||
payload: AchievementDefinitionPayload,
|
||||
): Promise<AchievementDefinitionDto> {
|
||||
const endpoint = API_ENDPOINTS.updateAchievementDefinition;
|
||||
return apiRequest<unknown, AchievementDefinitionPayload>(
|
||||
apiEndpointPath(API_OPERATIONS.updateAchievementDefinition, { achievement_id: achievementId }),
|
||||
{
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
},
|
||||
).then(normalizeAchievement);
|
||||
}
|
||||
|
||||
function normalizeAchievementPage(page: ApiPage<unknown>): ApiPage<AchievementDefinitionDto> {
|
||||
return {
|
||||
...page,
|
||||
items: (page.items || []).map(normalizeAchievement),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAchievement(raw: unknown): AchievementDefinitionDto {
|
||||
const source = asRecord(raw);
|
||||
return {
|
||||
achievementId: stringValue(source.achievementId ?? source.achievement_id),
|
||||
achievementType: stringValue(source.achievementType ?? source.achievement_type),
|
||||
autoPinPolicy: stringValue(source.autoPinPolicy ?? source.auto_pin_policy),
|
||||
collectionId: stringValue(source.collectionId ?? source.collection_id),
|
||||
collectionType: stringValue(source.collectionType ?? source.collection_type),
|
||||
conditions: arrayValue(source.conditions).map(normalizeCondition),
|
||||
createdAtMs: numberValue(source.createdAtMs ?? source.created_at_ms),
|
||||
createdByAdminId: numberValue(source.createdByAdminId ?? source.created_by_admin_id),
|
||||
description: stringValue(source.description),
|
||||
displayConfigJson: jsonString(source.displayConfigJson ?? source.display_config_json),
|
||||
effectiveFromMs: numberValue(source.effectiveFromMs ?? source.effective_from_ms),
|
||||
effectiveToMs: numberValue(source.effectiveToMs ?? source.effective_to_ms),
|
||||
honorVisibility: booleanValue(source.honorVisibility ?? source.honor_visibility),
|
||||
primaryBadgeResourceId: numberValue(source.primaryBadgeResourceId ?? source.primary_badge_resource_id),
|
||||
rewardResourceGroupId: numberValue(source.rewardResourceGroupId ?? source.reward_resource_group_id),
|
||||
sortOrder: numberValue(source.sortOrder ?? source.sort_order),
|
||||
status: stringValue(source.status),
|
||||
title: stringValue(source.title),
|
||||
updatedAtMs: numberValue(source.updatedAtMs ?? source.updated_at_ms),
|
||||
updatedByAdminId: numberValue(source.updatedByAdminId ?? source.updated_by_admin_id),
|
||||
version: numberValue(source.version),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCondition(raw: unknown): AchievementConditionDto {
|
||||
const source = asRecord(raw);
|
||||
return {
|
||||
achievementId: stringValue(source.achievementId ?? source.achievement_id),
|
||||
conditionId: stringValue(source.conditionId ?? source.condition_id),
|
||||
dimensionFilterJson: jsonString(source.dimensionFilterJson ?? source.dimension_filter_json),
|
||||
metricType: stringValue(source.metricType ?? source.metric_type),
|
||||
targetUnit: stringValue(source.targetUnit ?? source.target_unit),
|
||||
targetValue: numberValue(source.targetValue ?? source.target_value),
|
||||
version: numberValue(source.version),
|
||||
};
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): AnyRecord {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as AnyRecord) : {};
|
||||
}
|
||||
|
||||
function arrayValue(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function booleanValue(value: unknown): boolean {
|
||||
return value === true || value === "true" || value === 1 || value === "1";
|
||||
}
|
||||
|
||||
function numberValue(value: unknown): number {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return 0;
|
||||
}
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : 0;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string {
|
||||
return value === undefined || value === null ? "" : String(value);
|
||||
}
|
||||
|
||||
function jsonString(value: unknown): string {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
41
src/features/achievements/constants.js
Normal file
41
src/features/achievements/constants.js
Normal file
@ -0,0 +1,41 @@
|
||||
export const achievementStatusOptions = [
|
||||
["draft", "草稿"],
|
||||
["active", "启用"],
|
||||
["paused", "暂停"],
|
||||
["archived", "归档"],
|
||||
];
|
||||
|
||||
export const achievementStatusLabels = Object.fromEntries(achievementStatusOptions);
|
||||
|
||||
export const collectionTypeOptions = [
|
||||
["ordinary", "普通成就"],
|
||||
["activity_limited", "活动成就"],
|
||||
];
|
||||
|
||||
export const collectionTypeFilterOptions = [["", "全部合集"], ...collectionTypeOptions];
|
||||
|
||||
export const collectionTypeLabels = Object.fromEntries(collectionTypeOptions);
|
||||
|
||||
export const achievementTypeOptions = [
|
||||
["ordinary", "普通成就"],
|
||||
["activity_limited", "限时活动"],
|
||||
];
|
||||
|
||||
export const achievementTypeLabels = Object.fromEntries(achievementTypeOptions);
|
||||
|
||||
export const autoPinPolicyOptions = [
|
||||
["none", "不自动展示"],
|
||||
["if_empty", "空位展示"],
|
||||
["always", "优先展示"],
|
||||
];
|
||||
|
||||
export const autoPinPolicyLabels = Object.fromEntries(autoPinPolicyOptions);
|
||||
|
||||
export const targetUnitOptions = [
|
||||
["count", "次数"],
|
||||
["coin", "金币"],
|
||||
["day", "天"],
|
||||
["ms", "毫秒"],
|
||||
];
|
||||
|
||||
export const targetUnitLabels = Object.fromEntries(targetUnitOptions);
|
||||
294
src/features/achievements/hooks/useAchievementsPage.js
Normal file
294
src/features/achievements/hooks/useAchievementsPage.js
Normal file
@ -0,0 +1,294 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { parseForm } from "@/shared/forms/validation";
|
||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
import { listResourceGroups, listResources } from "@/features/resources/api";
|
||||
import {
|
||||
createAchievementDefinition,
|
||||
listAchievementDefinitions,
|
||||
updateAchievementDefinition,
|
||||
} from "@/features/achievements/api";
|
||||
import { useAchievementAbilities } from "@/features/achievements/permissions.js";
|
||||
import { achievementFormSchema } from "@/features/achievements/schema";
|
||||
|
||||
const pageSize = 10;
|
||||
const emptyData = { items: [], page: 1, pageSize, total: 0 };
|
||||
const resourcePageQuery = { page: 1, page_size: 500, resource_type: "badge", status: "active" };
|
||||
const groupPageQuery = { page: 1, page_size: 500, status: "active" };
|
||||
|
||||
const emptyCondition = (condition = {}) => ({
|
||||
dimensionFilterJson: condition.dimensionFilterJson || "{}",
|
||||
metricType: condition.metricType || "",
|
||||
targetUnit: condition.targetUnit || "count",
|
||||
targetValue: condition.targetValue === 0 || condition.targetValue ? String(condition.targetValue) : "",
|
||||
});
|
||||
|
||||
const emptyForm = (achievement = {}) => ({
|
||||
achievementType: achievement.achievementType || "ordinary",
|
||||
autoPinPolicy: achievement.autoPinPolicy || "if_empty",
|
||||
collectionId: achievement.collectionId || "",
|
||||
collectionType: achievement.collectionType || "ordinary",
|
||||
conditions: achievement.conditions?.length ? achievement.conditions.map(emptyCondition) : [emptyCondition()],
|
||||
description: achievement.description || "",
|
||||
displayConfigJson: achievement.displayConfigJson || "{}",
|
||||
effectiveFrom: msToDatetimeLocal(achievement.effectiveFromMs),
|
||||
effectiveTo: msToDatetimeLocal(achievement.effectiveToMs),
|
||||
honorVisibility: achievement.honorVisibility ?? true,
|
||||
primaryBadgeResourceId: optionalId(achievement.primaryBadgeResourceId),
|
||||
rewardResourceGroupId: optionalId(achievement.rewardResourceGroupId),
|
||||
sortOrder: valueOrDefault(achievement.sortOrder, 0),
|
||||
status: achievement.status || "draft",
|
||||
title: achievement.title || "",
|
||||
});
|
||||
|
||||
export function useAchievementsPage() {
|
||||
const abilities = useAchievementAbilities();
|
||||
const { showToast } = useToast();
|
||||
const [collectionType, setCollectionType] = useState("");
|
||||
const [collectionId, setCollectionId] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [activeAction, setActiveAction] = useState("");
|
||||
const [selectedAchievement, setSelectedAchievement] = useState(null);
|
||||
const [form, setForm] = useState(() => emptyForm());
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
const [badgeResources, setBadgeResources] = useState([]);
|
||||
const [resourceGroups, setResourceGroups] = useState([]);
|
||||
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
collection_id: collectionId,
|
||||
collection_type: collectionType,
|
||||
}),
|
||||
[collectionId, collectionType],
|
||||
);
|
||||
const {
|
||||
data = emptyData,
|
||||
error,
|
||||
loading,
|
||||
reload,
|
||||
} = usePaginatedQuery({
|
||||
errorMessage: "加载成就配置失败",
|
||||
fetcher: listAchievementDefinitions,
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
queryKey: ["achievements", filters, page],
|
||||
});
|
||||
|
||||
const loadOptions = useCallback(async () => {
|
||||
try {
|
||||
const [resourcePage, groupPage] = await Promise.all([
|
||||
listResources(resourcePageQuery),
|
||||
listResourceGroups(groupPageQuery),
|
||||
]);
|
||||
setBadgeResources(resourcePage.items || []);
|
||||
setResourceGroups(groupPage.items || []);
|
||||
} catch (err) {
|
||||
showToast(err.message || "加载资源选项失败", "error");
|
||||
}
|
||||
}, [showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadOptions();
|
||||
}, [loadOptions]);
|
||||
|
||||
const changeCollectionType = resetSetter(setCollectionType, setPage);
|
||||
const changeCollectionId = resetSetter(setCollectionId, setPage);
|
||||
|
||||
const resetFilters = () => {
|
||||
setCollectionType("");
|
||||
setCollectionId("");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setSelectedAchievement(null);
|
||||
setForm(emptyForm());
|
||||
setActiveAction("create");
|
||||
};
|
||||
|
||||
const openEdit = (achievement) => {
|
||||
if (!achievement?.achievementId) {
|
||||
return;
|
||||
}
|
||||
setSelectedAchievement(achievement);
|
||||
setForm(emptyForm(achievement));
|
||||
setActiveAction("edit");
|
||||
};
|
||||
|
||||
const closeAction = () => {
|
||||
setActiveAction("");
|
||||
setSelectedAchievement(null);
|
||||
};
|
||||
|
||||
const reloadAll = async () => {
|
||||
await Promise.all([reload(), loadOptions()]);
|
||||
};
|
||||
|
||||
const submitAchievement = async (event) => {
|
||||
event.preventDefault();
|
||||
const editing = activeAction === "edit";
|
||||
if (editing && !selectedAchievement?.achievementId) {
|
||||
return;
|
||||
}
|
||||
await runAction(editing ? "edit" : "create", editing ? "成就已更新" : "成就已创建", async () => {
|
||||
const payload = buildAchievementPayload(parseForm(achievementFormSchema, form));
|
||||
if (editing) {
|
||||
await updateAchievementDefinition(selectedAchievement.achievementId, payload);
|
||||
} else {
|
||||
await createAchievementDefinition(payload);
|
||||
}
|
||||
closeAction();
|
||||
setForm(emptyForm());
|
||||
await reload();
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAchievement = async (achievement, nextEnabled = achievement.status !== "active") => {
|
||||
if (!abilities.canUpdate || !achievement?.achievementId || achievement.status === "archived") {
|
||||
return;
|
||||
}
|
||||
const nextStatus = nextEnabled ? "active" : "paused";
|
||||
if (achievement.status === nextStatus) {
|
||||
return;
|
||||
}
|
||||
await runAction(`status-${achievement.achievementId}`, nextEnabled ? "成就已启用" : "成就已暂停", async () => {
|
||||
await updateAchievementDefinition(
|
||||
achievement.achievementId,
|
||||
buildAchievementPayload(emptyForm({ ...achievement, status: nextStatus })),
|
||||
);
|
||||
await reload();
|
||||
});
|
||||
};
|
||||
|
||||
const addCondition = () => {
|
||||
setForm((current) => ({ ...current, conditions: [...current.conditions, emptyCondition()] }));
|
||||
};
|
||||
|
||||
const removeCondition = (index) => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
conditions:
|
||||
current.conditions.length > 1
|
||||
? current.conditions.filter((_, itemIndex) => itemIndex !== index)
|
||||
: current.conditions,
|
||||
}));
|
||||
};
|
||||
|
||||
const updateCondition = (index, key, value) => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
conditions: current.conditions.map((condition, itemIndex) =>
|
||||
itemIndex === index ? { ...condition, [key]: value } : condition,
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const runAction = async (action, successMessage, submitter) => {
|
||||
setLoadingAction(action);
|
||||
try {
|
||||
await submitter();
|
||||
showToast(successMessage, "success");
|
||||
} catch (err) {
|
||||
showToast(err.message || "操作失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
activeAction,
|
||||
addCondition,
|
||||
badgeResources,
|
||||
changeCollectionId,
|
||||
changeCollectionType,
|
||||
closeAction,
|
||||
collectionId,
|
||||
collectionType,
|
||||
data,
|
||||
error,
|
||||
form,
|
||||
loading,
|
||||
loadingAction,
|
||||
openCreate,
|
||||
openEdit,
|
||||
page,
|
||||
reload: reloadAll,
|
||||
removeCondition,
|
||||
resetFilters,
|
||||
resourceGroups,
|
||||
selectedAchievement,
|
||||
setForm,
|
||||
setPage,
|
||||
submitAchievement,
|
||||
toggleAchievement,
|
||||
updateCondition,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAchievementPayload(form) {
|
||||
return {
|
||||
achievement_type: form.achievementType,
|
||||
auto_pin_policy: form.autoPinPolicy,
|
||||
collection_id: String(form.collectionId || "").trim(),
|
||||
collection_type: form.collectionType,
|
||||
conditions: form.conditions.map((condition) => ({
|
||||
dimension_filter_json: normalizedJson(condition.dimensionFilterJson),
|
||||
metric_type: String(condition.metricType || "").trim(),
|
||||
target_unit: String(condition.targetUnit || "").trim(),
|
||||
target_value: Number(condition.targetValue || 0),
|
||||
})),
|
||||
description: String(form.description || "").trim(),
|
||||
display_config_json: normalizedJson(form.displayConfigJson),
|
||||
effective_from_ms: datetimeLocalToMs(form.effectiveFrom),
|
||||
effective_to_ms: datetimeLocalToMs(form.effectiveTo),
|
||||
honor_visibility: Boolean(form.honorVisibility),
|
||||
primary_badge_resource_id: Number(form.primaryBadgeResourceId || 0),
|
||||
reward_resource_group_id: Number(form.rewardResourceGroupId || 0),
|
||||
sort_order: Number(form.sortOrder || 0),
|
||||
status: form.status,
|
||||
title: String(form.title || "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function resetSetter(setter, setPage) {
|
||||
return (value) => {
|
||||
setter(value);
|
||||
setPage(1);
|
||||
};
|
||||
}
|
||||
|
||||
function msToDatetimeLocal(value) {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
const date = new Date(Number(value));
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return "";
|
||||
}
|
||||
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function datetimeLocalToMs(value) {
|
||||
const trimmed = String(value || "").trim();
|
||||
if (!trimmed) {
|
||||
return 0;
|
||||
}
|
||||
const parsed = new Date(trimmed).getTime();
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function normalizedJson(value) {
|
||||
const raw = String(value || "").trim();
|
||||
return raw || "{}";
|
||||
}
|
||||
|
||||
function optionalId(value) {
|
||||
return value ? String(value) : "";
|
||||
}
|
||||
|
||||
function valueOrDefault(value, fallback) {
|
||||
return value === 0 || value ? String(value) : String(fallback);
|
||||
}
|
||||
606
src/features/achievements/pages/AchievementConfigPage.jsx
Normal file
606
src/features/achievements/pages/AchievementConfigPage.jsx
Normal file
@ -0,0 +1,606 @@
|
||||
import Add from "@mui/icons-material/Add";
|
||||
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import {
|
||||
AdminFormDialog,
|
||||
AdminFormFieldGrid,
|
||||
AdminFormList,
|
||||
AdminFormListRow,
|
||||
AdminFormSection,
|
||||
AdminFormSwitchField,
|
||||
} from "@/shared/ui/AdminFormDialog.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import {
|
||||
AdminActionIconButton,
|
||||
AdminFilterResetButton,
|
||||
AdminFilterSelect,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminListToolbar,
|
||||
AdminRowActions,
|
||||
AdminSearchBox,
|
||||
} from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import {
|
||||
achievementStatusLabels,
|
||||
achievementStatusOptions,
|
||||
achievementTypeLabels,
|
||||
achievementTypeOptions,
|
||||
autoPinPolicyLabels,
|
||||
autoPinPolicyOptions,
|
||||
collectionTypeFilterOptions,
|
||||
collectionTypeLabels,
|
||||
collectionTypeOptions,
|
||||
targetUnitLabels,
|
||||
targetUnitOptions,
|
||||
} from "@/features/achievements/constants.js";
|
||||
import { useAchievementsPage } from "@/features/achievements/hooks/useAchievementsPage.js";
|
||||
import styles from "@/features/achievements/achievements.module.css";
|
||||
|
||||
const baseColumns = [
|
||||
{
|
||||
key: "achievement",
|
||||
label: "成就",
|
||||
width: "minmax(260px, 1.4fr)",
|
||||
render: (achievement) => <AchievementIdentity achievement={achievement} />,
|
||||
},
|
||||
{
|
||||
key: "condition",
|
||||
label: "达成条件",
|
||||
width: "minmax(240px, 1.2fr)",
|
||||
render: (achievement) => <ConditionSummary conditions={achievement.conditions} />,
|
||||
},
|
||||
{
|
||||
key: "badge",
|
||||
label: "展示徽章",
|
||||
width: "minmax(190px, 0.9fr)",
|
||||
},
|
||||
{
|
||||
key: "reward",
|
||||
label: "奖励资源组",
|
||||
width: "minmax(190px, 0.9fr)",
|
||||
},
|
||||
{
|
||||
key: "display",
|
||||
label: "展示策略",
|
||||
width: "minmax(150px, 0.7fr)",
|
||||
render: (achievement) => (
|
||||
<div className={styles.stack}>
|
||||
<span>{autoPinPolicyLabel(achievement.autoPinPolicy)}</span>
|
||||
<span className={styles.meta}>{achievement.honorVisibility ? "荣誉墙可见" : "荣誉墙隐藏"}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "effective",
|
||||
label: "有效期",
|
||||
width: "minmax(220px, 1.05fr)",
|
||||
render: (achievement) => (
|
||||
<div className={styles.stack}>
|
||||
<span>{achievement.effectiveFromMs ? formatMillis(achievement.effectiveFromMs) : "立即生效"}</span>
|
||||
<span className={styles.meta}>
|
||||
{achievement.effectiveToMs ? formatMillis(achievement.effectiveToMs) : "长期有效"}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "version",
|
||||
label: "版本 / 排序",
|
||||
width: "minmax(120px, 0.6fr)",
|
||||
render: (achievement) => (
|
||||
<div className={styles.stack}>
|
||||
<span>v{achievement.version || 1}</span>
|
||||
<span className={styles.meta}>{achievement.sortOrder || 0}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
width: "minmax(116px, 0.55fr)",
|
||||
},
|
||||
{
|
||||
key: "updatedAt",
|
||||
label: "更新时间",
|
||||
width: "minmax(170px, 0.8fr)",
|
||||
render: (achievement) => formatMillis(achievement.updatedAtMs || achievement.createdAtMs),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
width: "minmax(76px, 0.35fr)",
|
||||
},
|
||||
];
|
||||
|
||||
export function AchievementConfigPage() {
|
||||
const page = useAchievementsPage();
|
||||
const items = page.data.items || [];
|
||||
const total = page.data.total || 0;
|
||||
const isEditing = page.activeAction === "edit";
|
||||
const columns = baseColumns.map((column) => {
|
||||
if (column.key === "badge") {
|
||||
return {
|
||||
...column,
|
||||
render: (achievement) => (
|
||||
<ResourceLabel id={achievement.primaryBadgeResourceId} resources={page.badgeResources} />
|
||||
),
|
||||
};
|
||||
}
|
||||
if (column.key === "reward") {
|
||||
return {
|
||||
...column,
|
||||
render: (achievement) => (
|
||||
<ResourceGroupLabel groups={page.resourceGroups} id={achievement.rewardResourceGroupId} />
|
||||
),
|
||||
};
|
||||
}
|
||||
if (column.key === "status") {
|
||||
return {
|
||||
...column,
|
||||
render: (achievement) => <AchievementStatusSwitch achievement={achievement} page={page} />,
|
||||
};
|
||||
}
|
||||
if (column.key === "actions") {
|
||||
return {
|
||||
...column,
|
||||
render: (achievement) => <AchievementRowActions achievement={achievement} page={page} />,
|
||||
};
|
||||
}
|
||||
return column;
|
||||
});
|
||||
|
||||
return (
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
filters={
|
||||
<>
|
||||
<AdminFilterSelect
|
||||
label="合集类型"
|
||||
options={collectionTypeFilterOptions}
|
||||
value={page.collectionType}
|
||||
onChange={page.changeCollectionType}
|
||||
/>
|
||||
<AdminSearchBox
|
||||
placeholder="搜索合集 ID"
|
||||
value={page.collectionId}
|
||||
onChange={page.changeCollectionId}
|
||||
/>
|
||||
<AdminFilterResetButton
|
||||
disabled={!page.collectionType && !page.collectionId}
|
||||
onClick={page.resetFilters}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
page.abilities.canCreate ? (
|
||||
<AdminActionIconButton label="添加成就" primary onClick={page.openCreate}>
|
||||
<Add fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<AdminListBody>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
items={items}
|
||||
minWidth="1710px"
|
||||
pagination={
|
||||
total > 0
|
||||
? {
|
||||
page: page.page,
|
||||
pageSize: page.data.pageSize || 10,
|
||||
total,
|
||||
onPageChange: page.setPage,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
rowKey={(achievement) => achievement.achievementId}
|
||||
/>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
<AchievementFormDialog
|
||||
disabled={isEditing ? !page.abilities.canUpdate : !page.abilities.canCreate}
|
||||
form={page.form}
|
||||
loading={isEditing ? page.loadingAction === "edit" : page.loadingAction === "create"}
|
||||
mode={isEditing ? "edit" : "create"}
|
||||
open={page.activeAction === "create" || page.activeAction === "edit"}
|
||||
page={page}
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitAchievement}
|
||||
/>
|
||||
</AdminListPage>
|
||||
);
|
||||
}
|
||||
|
||||
function AchievementIdentity({ achievement }) {
|
||||
return (
|
||||
<div className={styles.identity}>
|
||||
<span className={styles.typeBadge}>{achievementTypeLabel(achievement.achievementType)}</span>
|
||||
<div className={styles.identityText}>
|
||||
<span className={styles.name}>{achievement.title || achievement.achievementId}</span>
|
||||
<span className={styles.meta}>{achievement.achievementId}</span>
|
||||
<span className={styles.meta}>
|
||||
{collectionTypeLabel(achievement.collectionType)} · {achievement.collectionId || "ordinary"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConditionSummary({ conditions = [] }) {
|
||||
const first = conditions[0] || {};
|
||||
if (!first.metricType) {
|
||||
return <span className={styles.emptyValue}>未配置</span>;
|
||||
}
|
||||
return (
|
||||
<div className={styles.stack}>
|
||||
<span>{first.metricType}</span>
|
||||
<span className={styles.meta}>
|
||||
{formatNumber(first.targetValue)} {targetUnitLabel(first.targetUnit)}
|
||||
{conditions.length > 1 ? ` · 共 ${conditions.length} 条` : ""}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AchievementStatusSwitch({ achievement, page }) {
|
||||
const checked = achievement.status === "active";
|
||||
const disabled =
|
||||
!page.abilities.canUpdate ||
|
||||
achievement.status === "archived" ||
|
||||
page.loadingAction === `status-${achievement.achievementId}`;
|
||||
return (
|
||||
<div className={styles.statusCell}>
|
||||
<AdminSwitch
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
inputProps={{ "aria-label": checked ? "暂停成就" : "启用成就" }}
|
||||
onChange={(event) => page.toggleAchievement(achievement, event.target.checked)}
|
||||
/>
|
||||
<span className={styles.meta}>{achievementStatusLabel(achievement.status)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AchievementRowActions({ achievement, page }) {
|
||||
return (
|
||||
<AdminRowActions>
|
||||
<AdminActionIconButton
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="编辑成就"
|
||||
onClick={() => page.openEdit(achievement)}
|
||||
>
|
||||
<EditOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
</AdminRowActions>
|
||||
);
|
||||
}
|
||||
|
||||
function AchievementFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open, page }) {
|
||||
return (
|
||||
<AdminFormDialog
|
||||
loading={loading}
|
||||
open={open}
|
||||
size="wide"
|
||||
submitDisabled={disabled || loading}
|
||||
submitLabel={mode === "edit" ? "保存成就" : "创建成就"}
|
||||
title={mode === "edit" ? "编辑成就" : "创建成就"}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<AdminFormSection title="基础信息">
|
||||
<AdminFormFieldGrid>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="合集类型"
|
||||
required
|
||||
select
|
||||
value={form.collectionType}
|
||||
onChange={changeField(page.setForm, "collectionType")}
|
||||
>
|
||||
{collectionTypeOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="成就类型"
|
||||
required
|
||||
select
|
||||
value={form.achievementType}
|
||||
onChange={changeField(page.setForm, "achievementType")}
|
||||
>
|
||||
{achievementTypeOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="合集 ID"
|
||||
value={form.collectionId}
|
||||
onChange={changeField(page.setForm, "collectionId")}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="状态"
|
||||
required
|
||||
select
|
||||
value={form.status}
|
||||
onChange={changeField(page.setForm, "status")}
|
||||
>
|
||||
{achievementStatusOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="成就名称"
|
||||
required
|
||||
value={form.title}
|
||||
onChange={changeField(page.setForm, "title")}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="描述"
|
||||
value={form.description}
|
||||
onChange={changeField(page.setForm, "description")}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection
|
||||
actions={
|
||||
<AdminActionIconButton
|
||||
disabled={disabled}
|
||||
label="添加条件"
|
||||
type="button"
|
||||
onClick={page.addCondition}
|
||||
>
|
||||
<Add fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
}
|
||||
title="达成条件"
|
||||
>
|
||||
<AdminFormList>
|
||||
{form.conditions.map((condition, index) => (
|
||||
<AdminFormListRow
|
||||
key={index}
|
||||
columns="minmax(180px, 1fr) minmax(110px, 0.55fr) minmax(120px, 0.6fr) minmax(220px, 1.1fr) 44px"
|
||||
>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="指标类型"
|
||||
required
|
||||
value={condition.metricType}
|
||||
onChange={(event) => page.updateCondition(index, "metricType", event.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
inputProps={{ min: 1 }}
|
||||
label="目标值"
|
||||
required
|
||||
type="number"
|
||||
value={condition.targetValue}
|
||||
onChange={(event) => page.updateCondition(index, "targetValue", event.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="目标单位"
|
||||
required
|
||||
select
|
||||
value={condition.targetUnit}
|
||||
onChange={(event) => page.updateCondition(index, "targetUnit", event.target.value)}
|
||||
>
|
||||
{targetUnitOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="维度过滤 JSON"
|
||||
value={condition.dimensionFilterJson}
|
||||
onChange={(event) =>
|
||||
page.updateCondition(index, "dimensionFilterJson", event.target.value)
|
||||
}
|
||||
/>
|
||||
<AdminActionIconButton
|
||||
disabled={disabled || form.conditions.length <= 1}
|
||||
label="删除条件"
|
||||
type="button"
|
||||
onClick={() => page.removeCondition(index)}
|
||||
>
|
||||
<DeleteOutlineOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
</AdminFormListRow>
|
||||
))}
|
||||
</AdminFormList>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="徽章与奖励">
|
||||
<AdminFormFieldGrid>
|
||||
<ResourceSelect
|
||||
disabled={disabled}
|
||||
label="达成展示徽章"
|
||||
resources={page.badgeResources}
|
||||
value={form.primaryBadgeResourceId}
|
||||
onChange={changeField(page.setForm, "primaryBadgeResourceId")}
|
||||
/>
|
||||
<ResourceGroupSelect
|
||||
disabled={disabled}
|
||||
groups={page.resourceGroups}
|
||||
label="达成奖励资源组"
|
||||
value={form.rewardResourceGroupId}
|
||||
onChange={changeField(page.setForm, "rewardResourceGroupId")}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="展示策略"
|
||||
select
|
||||
value={form.autoPinPolicy}
|
||||
onChange={changeField(page.setForm, "autoPinPolicy")}
|
||||
>
|
||||
{autoPinPolicyOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<AdminFormSwitchField
|
||||
checked={form.honorVisibility}
|
||||
checkedLabel="显示"
|
||||
disabled={disabled}
|
||||
label="荣誉墙"
|
||||
uncheckedLabel="隐藏"
|
||||
onChange={(checked) => page.setForm((current) => ({ ...current, honorVisibility: checked }))}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="时间与展示">
|
||||
<AdminFormFieldGrid>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="开始时间"
|
||||
type="datetime-local"
|
||||
value={form.effectiveFrom}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
onChange={changeField(page.setForm, "effectiveFrom")}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="结束时间"
|
||||
type="datetime-local"
|
||||
value={form.effectiveTo}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
onChange={changeField(page.setForm, "effectiveTo")}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="排序"
|
||||
type="number"
|
||||
value={form.sortOrder}
|
||||
onChange={changeField(page.setForm, "sortOrder")}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="展示配置 JSON"
|
||||
value={form.displayConfigJson}
|
||||
onChange={changeField(page.setForm, "displayConfigJson")}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
</AdminFormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ResourceSelect({ disabled, label, onChange, resources, value }) {
|
||||
const options = withSelectedResource(resources, value);
|
||||
return (
|
||||
<TextField select disabled={disabled} label={label} value={String(value || "")} onChange={onChange}>
|
||||
<MenuItem value="">不配置</MenuItem>
|
||||
{options.map((resource) => (
|
||||
<MenuItem key={resource.resourceId} value={String(resource.resourceId)}>
|
||||
{resource.name || resource.resourceCode || resource.resourceId}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
);
|
||||
}
|
||||
|
||||
function ResourceGroupSelect({ disabled, groups, label, onChange, value }) {
|
||||
const options = withSelectedGroup(groups, value);
|
||||
return (
|
||||
<TextField select disabled={disabled} label={label} value={String(value || "")} onChange={onChange}>
|
||||
<MenuItem value="">不配置</MenuItem>
|
||||
{options.map((group) => (
|
||||
<MenuItem key={group.groupId} value={String(group.groupId)}>
|
||||
{group.name || group.groupCode || group.groupId}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
);
|
||||
}
|
||||
|
||||
function ResourceLabel({ id, resources }) {
|
||||
if (!id) {
|
||||
return <span className={styles.emptyValue}>未配置</span>;
|
||||
}
|
||||
const resource = resources.find((item) => Number(item.resourceId) === Number(id));
|
||||
return (
|
||||
<div className={styles.stack}>
|
||||
<span className={styles.resourceText}>{resource?.name || resource?.resourceCode || `资源 #${id}`}</span>
|
||||
<span className={styles.meta}>ID {id}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResourceGroupLabel({ groups, id }) {
|
||||
if (!id) {
|
||||
return <span className={styles.emptyValue}>未配置</span>;
|
||||
}
|
||||
const group = groups.find((item) => Number(item.groupId) === Number(id));
|
||||
return (
|
||||
<div className={styles.stack}>
|
||||
<span className={styles.resourceText}>{group?.name || group?.groupCode || `资源组 #${id}`}</span>
|
||||
<span className={styles.meta}>ID {id}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function changeField(setter, key) {
|
||||
return (event) => {
|
||||
const value = event.target.value;
|
||||
setter((current) => ({ ...current, [key]: value }));
|
||||
};
|
||||
}
|
||||
|
||||
function withSelectedResource(resources, value) {
|
||||
const selectedId = Number(value || 0);
|
||||
if (!selectedId || resources.some((resource) => Number(resource.resourceId) === selectedId)) {
|
||||
return resources;
|
||||
}
|
||||
return [{ resourceId: selectedId, name: `资源 #${selectedId}` }, ...resources];
|
||||
}
|
||||
|
||||
function withSelectedGroup(groups, value) {
|
||||
const selectedId = Number(value || 0);
|
||||
if (!selectedId || groups.some((group) => Number(group.groupId) === selectedId)) {
|
||||
return groups;
|
||||
}
|
||||
return [{ groupId: selectedId, name: `资源组 #${selectedId}` }, ...groups];
|
||||
}
|
||||
|
||||
function achievementStatusLabel(status) {
|
||||
return achievementStatusLabels[status] || status || "-";
|
||||
}
|
||||
|
||||
function achievementTypeLabel(type) {
|
||||
return achievementTypeLabels[type] || type || "-";
|
||||
}
|
||||
|
||||
function autoPinPolicyLabel(policy) {
|
||||
return autoPinPolicyLabels[policy] || policy || "-";
|
||||
}
|
||||
|
||||
function collectionTypeLabel(type) {
|
||||
return collectionTypeLabels[type] || type || "-";
|
||||
}
|
||||
|
||||
function targetUnitLabel(unit) {
|
||||
return targetUnitLabels[unit] || unit || "";
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return Number(value || 0).toLocaleString("zh-CN");
|
||||
}
|
||||
12
src/features/achievements/permissions.js
Normal file
12
src/features/achievements/permissions.js
Normal file
@ -0,0 +1,12 @@
|
||||
import { PERMISSIONS } from "@/app/permissions";
|
||||
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||
|
||||
export function useAchievementAbilities() {
|
||||
const { can } = useAuth();
|
||||
|
||||
return {
|
||||
canCreate: can(PERMISSIONS.achievementCreate),
|
||||
canUpdate: can(PERMISSIONS.achievementUpdate),
|
||||
canView: can(PERMISSIONS.achievementView),
|
||||
};
|
||||
}
|
||||
12
src/features/achievements/routes.js
Normal file
12
src/features/achievements/routes.js
Normal file
@ -0,0 +1,12 @@
|
||||
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
|
||||
|
||||
export const achievementRoutes = [
|
||||
{
|
||||
label: "成就配置",
|
||||
loader: () => import("./pages/AchievementConfigPage.jsx").then((module) => module.AchievementConfigPage),
|
||||
menuCode: MENU_CODES.achievementConfig,
|
||||
pageKey: "achievement-config",
|
||||
path: "/activities/achievements",
|
||||
permission: PERMISSIONS.achievementView,
|
||||
},
|
||||
];
|
||||
107
src/features/achievements/schema.ts
Normal file
107
src/features/achievements/schema.ts
Normal file
@ -0,0 +1,107 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const numberInput = z.union([z.string(), z.number()]);
|
||||
const optionalNumberInput = numberInput.optional();
|
||||
const statusSchema = z.enum(["draft", "active", "paused", "archived"]);
|
||||
const collectionTypeSchema = z.enum(["ordinary", "activity_limited"]);
|
||||
const achievementTypeSchema = z.enum(["ordinary", "activity_limited"]);
|
||||
const autoPinPolicySchema = z.enum(["none", "if_empty", "always"]);
|
||||
|
||||
const conditionSchema = z.object({
|
||||
dimensionFilterJson: z.string().optional(),
|
||||
metricType: z.string().trim().min(1, "请输入指标类型").max(80, "指标类型不能超过 80 个字符"),
|
||||
targetUnit: z.string().trim().min(1, "请选择目标单位").max(32, "目标单位不能超过 32 个字符"),
|
||||
targetValue: numberInput,
|
||||
});
|
||||
|
||||
export const achievementFormSchema = z
|
||||
.object({
|
||||
achievementType: achievementTypeSchema,
|
||||
autoPinPolicy: autoPinPolicySchema,
|
||||
collectionId: z.string().trim().max(80, "合集 ID 不能超过 80 个字符").optional(),
|
||||
collectionType: collectionTypeSchema,
|
||||
conditions: z.array(conditionSchema).min(1, "至少配置一个达成条件").max(10, "最多配置 10 个达成条件"),
|
||||
description: z.string().trim().max(240, "描述不能超过 240 个字符").optional(),
|
||||
displayConfigJson: z.string().optional(),
|
||||
effectiveFrom: z.string().optional(),
|
||||
effectiveTo: z.string().optional(),
|
||||
honorVisibility: z.boolean(),
|
||||
primaryBadgeResourceId: optionalNumberInput,
|
||||
rewardResourceGroupId: optionalNumberInput,
|
||||
sortOrder: optionalNumberInput,
|
||||
status: statusSchema,
|
||||
title: z.string().trim().min(1, "请输入成就名称").max(64, "成就名称不能超过 64 个字符"),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
validateOptionalId(context, "primaryBadgeResourceId", value.primaryBadgeResourceId, "展示徽章必须是有效资源");
|
||||
validateOptionalId(context, "rewardResourceGroupId", value.rewardResourceGroupId, "奖励资源组必须是有效资源组");
|
||||
validateInteger(context, "sortOrder", value.sortOrder || 0, "排序必须是整数");
|
||||
validateJsonObject(context, "displayConfigJson", value.displayConfigJson, "展示配置 JSON 不正确");
|
||||
const fromMs = datetimeToMs(value.effectiveFrom);
|
||||
const toMs = datetimeToMs(value.effectiveTo);
|
||||
if (fromMs > 0 && toMs > 0 && toMs <= fromMs) {
|
||||
context.addIssue({ code: "custom", message: "结束时间必须晚于开始时间", path: ["effectiveTo"] });
|
||||
}
|
||||
value.conditions.forEach((condition, index) => {
|
||||
validatePositiveInteger(
|
||||
context,
|
||||
`conditions.${index}.targetValue`,
|
||||
condition.targetValue,
|
||||
"目标值必须是正整数",
|
||||
);
|
||||
validateJsonObject(
|
||||
context,
|
||||
`conditions.${index}.dimensionFilterJson`,
|
||||
condition.dimensionFilterJson,
|
||||
"维度过滤 JSON 不正确",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
export type AchievementForm = z.infer<typeof achievementFormSchema>;
|
||||
|
||||
function validatePositiveInteger(context: z.RefinementCtx, path: string, value: unknown, message: string) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isInteger(numeric) || numeric <= 0) {
|
||||
context.addIssue({ code: "custom", message, path: path.split(".") });
|
||||
}
|
||||
}
|
||||
|
||||
function validateOptionalId(context: z.RefinementCtx, path: string, value: unknown, message: string) {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
validatePositiveInteger(context, path, raw, message);
|
||||
}
|
||||
|
||||
function validateInteger(context: z.RefinementCtx, path: string, value: unknown, message: string) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isInteger(numeric)) {
|
||||
context.addIssue({ code: "custom", message, path: [path] });
|
||||
}
|
||||
}
|
||||
|
||||
function validateJsonObject(context: z.RefinementCtx, path: string, value: unknown, message: string) {
|
||||
const raw = String(value || "").trim();
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
context.addIssue({ code: "custom", message, path: path.split(".") });
|
||||
}
|
||||
} catch {
|
||||
context.addIssue({ code: "custom", message, path: path.split(".") });
|
||||
}
|
||||
}
|
||||
|
||||
function datetimeToMs(value: unknown) {
|
||||
const raw = String(value || "").trim();
|
||||
if (!raw) {
|
||||
return 0;
|
||||
}
|
||||
const parsed = new Date(raw).getTime();
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
239
src/features/level-config/api.ts
Normal file
239
src/features/level-config/api.ts
Normal file
@ -0,0 +1,239 @@
|
||||
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||
import { apiRequest } from "@/shared/api/request";
|
||||
import type { EntityId, PageQuery } from "@/shared/api/types";
|
||||
|
||||
export type LevelTrackCode = "wealth" | "game" | "charm";
|
||||
export type LevelConfigStatus = "active" | "disabled";
|
||||
|
||||
export interface LevelRuleDto {
|
||||
createdAtMs?: number;
|
||||
displayConfigJson?: string;
|
||||
level: number;
|
||||
longBadgeResourceId?: number;
|
||||
name?: string;
|
||||
requiredValue: number;
|
||||
rewardResourceGroupId?: number;
|
||||
sortOrder?: number;
|
||||
status?: LevelConfigStatus | string;
|
||||
track: LevelTrackCode;
|
||||
updatedAtMs?: number;
|
||||
}
|
||||
|
||||
export interface LevelTierDto {
|
||||
createdAtMs?: number;
|
||||
displayAvatarFrameResourceId?: number;
|
||||
displayBadgeResourceId?: number;
|
||||
displayConfigJson?: string;
|
||||
maxLevel: number;
|
||||
minLevel: number;
|
||||
name?: string;
|
||||
rewardResourceGroupId?: number;
|
||||
status?: LevelConfigStatus | string;
|
||||
tierId: number;
|
||||
track: LevelTrackCode;
|
||||
updatedAtMs?: number;
|
||||
}
|
||||
|
||||
export interface LevelTrackDto {
|
||||
createdAtMs?: number;
|
||||
displayConfigJson?: string;
|
||||
name?: string;
|
||||
rules: LevelRuleDto[];
|
||||
sortOrder?: number;
|
||||
status?: LevelConfigStatus | string;
|
||||
tiers: LevelTierDto[];
|
||||
track: LevelTrackCode;
|
||||
updatedAtMs?: number;
|
||||
}
|
||||
|
||||
export interface LevelConfigDto {
|
||||
serverTimeMs?: number;
|
||||
tracks: LevelTrackDto[];
|
||||
}
|
||||
|
||||
export interface LevelTrackPayload {
|
||||
display_config_json?: string;
|
||||
name: string;
|
||||
sort_order: number;
|
||||
status: LevelConfigStatus;
|
||||
}
|
||||
|
||||
export interface LevelRulePayload {
|
||||
long_badge_resource_id?: number;
|
||||
name: string;
|
||||
required_value: number;
|
||||
reward_resource_group_id?: number;
|
||||
sort_order: number;
|
||||
status: LevelConfigStatus;
|
||||
}
|
||||
|
||||
export interface LevelTierPayload {
|
||||
display_avatar_frame_resource_id?: number;
|
||||
display_badge_resource_id?: number;
|
||||
display_config_json?: string;
|
||||
max_level: number;
|
||||
min_level: number;
|
||||
name: string;
|
||||
reward_resource_group_id?: number;
|
||||
status: LevelConfigStatus;
|
||||
track: LevelTrackCode;
|
||||
}
|
||||
|
||||
type AnyRecord = Record<string, unknown>;
|
||||
|
||||
export function listLevelConfig(query: PageQuery = {}): Promise<LevelConfigDto> {
|
||||
const endpoint = API_ENDPOINTS.listLevelConfig;
|
||||
return apiRequest<unknown>(apiEndpointPath(API_OPERATIONS.listLevelConfig), {
|
||||
method: endpoint.method,
|
||||
query,
|
||||
}).then(normalizeLevelConfig);
|
||||
}
|
||||
|
||||
export function updateTrack(track: LevelTrackCode, payload: LevelTrackPayload): Promise<LevelTrackDto> {
|
||||
const endpoint = API_ENDPOINTS.updateTrack;
|
||||
return apiRequest<unknown, LevelTrackPayload>(apiEndpointPath(API_OPERATIONS.updateTrack, { track }), {
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
}).then(normalizeTrack);
|
||||
}
|
||||
|
||||
export function upsertRule(
|
||||
track: LevelTrackCode,
|
||||
level: EntityId,
|
||||
payload: LevelRulePayload,
|
||||
): Promise<LevelRuleDto> {
|
||||
const endpoint = API_ENDPOINTS.upsertRule;
|
||||
return apiRequest<unknown, LevelRulePayload>(
|
||||
apiEndpointPath(API_OPERATIONS.upsertRule, { level, track }),
|
||||
{
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
},
|
||||
).then(normalizeRule);
|
||||
}
|
||||
|
||||
export function createTier(payload: LevelTierPayload): Promise<LevelTierDto> {
|
||||
const endpoint = API_ENDPOINTS.createTier;
|
||||
return apiRequest<unknown, LevelTierPayload>(apiEndpointPath(API_OPERATIONS.createTier), {
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
}).then(normalizeTier);
|
||||
}
|
||||
|
||||
export function updateTier(tierId: EntityId, payload: LevelTierPayload): Promise<LevelTierDto> {
|
||||
const endpoint = API_ENDPOINTS.updateTier;
|
||||
return apiRequest<unknown, LevelTierPayload>(apiEndpointPath(API_OPERATIONS.updateTier, { tier_id: tierId }), {
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
}).then(normalizeTier);
|
||||
}
|
||||
|
||||
function normalizeLevelConfig(raw: unknown): LevelConfigDto {
|
||||
const source = asRecord(raw);
|
||||
return {
|
||||
serverTimeMs: numberValue(source.serverTimeMs ?? source.server_time_ms),
|
||||
tracks: arrayValue(source.tracks).map(normalizeTrack),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTrack(raw: unknown): LevelTrackDto {
|
||||
const source = asRecord(raw);
|
||||
return {
|
||||
createdAtMs: numberValue(source.createdAtMs ?? source.created_at_ms),
|
||||
displayConfigJson: jsonString(source.displayConfigJson ?? source.display_config_json),
|
||||
name: stringValue(source.name),
|
||||
rules: arrayValue(source.rules).map(normalizeRule),
|
||||
sortOrder: numberValue(source.sortOrder ?? source.sort_order),
|
||||
status: stringValue(source.status),
|
||||
tiers: arrayValue(source.tiers).map(normalizeTier),
|
||||
track: stringValue(source.track) as LevelTrackCode,
|
||||
updatedAtMs: numberValue(source.updatedAtMs ?? source.updated_at_ms),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRule(raw: unknown): LevelRuleDto {
|
||||
const source = asRecord(raw);
|
||||
const displayConfig = source.displayConfigJson ?? source.display_config_json;
|
||||
const displayConfigObject = parseDisplayConfig(displayConfig);
|
||||
|
||||
return {
|
||||
createdAtMs: numberValue(source.createdAtMs ?? source.created_at_ms),
|
||||
displayConfigJson: jsonString(displayConfig),
|
||||
level: numberValue(source.level),
|
||||
longBadgeResourceId: numberValue(
|
||||
source.longBadgeResourceId ??
|
||||
source.long_badge_resource_id ??
|
||||
displayConfigObject.longBadgeResourceId ??
|
||||
displayConfigObject.long_badge_resource_id,
|
||||
),
|
||||
name: stringValue(source.name),
|
||||
requiredValue: numberValue(source.requiredValue ?? source.required_value),
|
||||
rewardResourceGroupId: numberValue(source.rewardResourceGroupId ?? source.reward_resource_group_id),
|
||||
sortOrder: numberValue(source.sortOrder ?? source.sort_order),
|
||||
status: stringValue(source.status),
|
||||
track: stringValue(source.track) as LevelTrackCode,
|
||||
updatedAtMs: numberValue(source.updatedAtMs ?? source.updated_at_ms),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTier(raw: unknown): LevelTierDto {
|
||||
const source = asRecord(raw);
|
||||
return {
|
||||
createdAtMs: numberValue(source.createdAtMs ?? source.created_at_ms),
|
||||
displayAvatarFrameResourceId: numberValue(
|
||||
source.displayAvatarFrameResourceId ?? source.display_avatar_frame_resource_id,
|
||||
),
|
||||
displayBadgeResourceId: numberValue(source.displayBadgeResourceId ?? source.display_badge_resource_id),
|
||||
displayConfigJson: jsonString(source.displayConfigJson ?? source.display_config_json),
|
||||
maxLevel: numberValue(source.maxLevel ?? source.max_level),
|
||||
minLevel: numberValue(source.minLevel ?? source.min_level),
|
||||
name: stringValue(source.name),
|
||||
rewardResourceGroupId: numberValue(source.rewardResourceGroupId ?? source.reward_resource_group_id),
|
||||
status: stringValue(source.status),
|
||||
tierId: numberValue(source.tierId ?? source.tier_id),
|
||||
track: stringValue(source.track) as LevelTrackCode,
|
||||
updatedAtMs: numberValue(source.updatedAtMs ?? source.updated_at_ms),
|
||||
};
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): AnyRecord {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as AnyRecord) : {};
|
||||
}
|
||||
|
||||
function arrayValue(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function numberValue(value: unknown): number {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return 0;
|
||||
}
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : 0;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string {
|
||||
return value === undefined || value === null ? "" : String(value);
|
||||
}
|
||||
|
||||
function jsonString(value: unknown): string {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function parseDisplayConfig(value: unknown): AnyRecord {
|
||||
const raw = jsonString(value).trim();
|
||||
if (!raw) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
return asRecord(JSON.parse(raw));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
18
src/features/level-config/constants.js
Normal file
18
src/features/level-config/constants.js
Normal file
@ -0,0 +1,18 @@
|
||||
export const levelTrackOptions = [
|
||||
["wealth", "财富等级"],
|
||||
["game", "游戏等级"],
|
||||
["charm", "魅力等级"],
|
||||
];
|
||||
|
||||
export const levelTrackLabels = Object.fromEntries(levelTrackOptions);
|
||||
|
||||
export const levelStatusOptions = [
|
||||
["active", "启用"],
|
||||
["disabled", "禁用"],
|
||||
];
|
||||
|
||||
export const levelStatusLabels = Object.fromEntries(levelStatusOptions);
|
||||
|
||||
export const levelTrackFilterOptions = [["", "全部等级"], ...levelTrackOptions];
|
||||
|
||||
export const levelStatusFilterOptions = [["", "全部状态"], ...levelStatusOptions];
|
||||
344
src/features/level-config/hooks/useLevelConfigPage.js
Normal file
344
src/features/level-config/hooks/useLevelConfigPage.js
Normal file
@ -0,0 +1,344 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { parseForm } from "@/shared/forms/validation";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
import { listResourceGroups, listResources } from "@/features/resources/api";
|
||||
import { levelTrackLabels } from "@/features/level-config/constants.js";
|
||||
import { createTier, listLevelConfig, updateTier, updateTrack, upsertRule } from "@/features/level-config/api";
|
||||
import { useLevelConfigAbilities } from "@/features/level-config/permissions.js";
|
||||
import {
|
||||
levelRuleFormSchema,
|
||||
levelTierFormSchema,
|
||||
levelTrackFormSchema,
|
||||
} from "@/features/level-config/schema";
|
||||
|
||||
const emptyConfig = { serverTimeMs: 0, tracks: [] };
|
||||
const defaultTrack = "wealth";
|
||||
const resourcePageQuery = { page: 1, page_size: 500, status: "active" };
|
||||
|
||||
export function useLevelConfigPage() {
|
||||
const abilities = useLevelConfigAbilities();
|
||||
const { showToast } = useToast();
|
||||
const [config, setConfig] = useState(emptyConfig);
|
||||
const [resources, setResources] = useState([]);
|
||||
const [resourceGroups, setResourceGroups] = useState([]);
|
||||
const [track, setTrack] = useState(defaultTrack);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [activeAction, setActiveAction] = useState("");
|
||||
const [selectedRule, setSelectedRule] = useState(null);
|
||||
const [selectedTier, setSelectedTier] = useState(null);
|
||||
const [selectedTrack, setSelectedTrack] = useState(null);
|
||||
const [ruleForm, setRuleForm] = useState(() => buildRuleForm());
|
||||
const [tierForm, setTierForm] = useState(() => buildTierForm());
|
||||
const [trackForm, setTrackForm] = useState(() => buildTrackForm());
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [nextConfig, resourcePage, groupPage] = await Promise.all([
|
||||
listLevelConfig(),
|
||||
listResources(resourcePageQuery),
|
||||
listResourceGroups(resourcePageQuery),
|
||||
]);
|
||||
setConfig(nextConfig);
|
||||
setResources(resourcePage.items || []);
|
||||
setResourceGroups(groupPage.items || []);
|
||||
} catch (err) {
|
||||
setError(err.message || "加载等级配置失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
const tracks = useMemo(
|
||||
() => (config.tracks || []).filter((item) => item.track === track),
|
||||
[config.tracks, track],
|
||||
);
|
||||
const badgeResources = useMemo(
|
||||
() => resources.filter((resource) => resource.resourceType === "badge"),
|
||||
[resources],
|
||||
);
|
||||
const avatarFrameResources = useMemo(
|
||||
() => resources.filter((resource) => resource.resourceType === "avatar_frame"),
|
||||
[resources],
|
||||
);
|
||||
|
||||
const changeTrack = (value) => setTrack(value);
|
||||
|
||||
const openRuleCreate = (trackCode = resolveDefaultTrack(config, track)) => {
|
||||
setSelectedRule(null);
|
||||
setRuleForm(buildRuleForm(null, resolveTrack(config, trackCode)));
|
||||
setActiveAction("rule-create");
|
||||
};
|
||||
|
||||
const openRuleEdit = (rule) => {
|
||||
setSelectedRule(rule);
|
||||
setRuleForm(buildRuleForm(rule));
|
||||
setActiveAction("rule-edit");
|
||||
};
|
||||
|
||||
const openTierCreate = (trackCode = resolveDefaultTrack(config, track)) => {
|
||||
setSelectedTier(null);
|
||||
setTierForm(buildTierForm(null, resolveTrack(config, trackCode)));
|
||||
setActiveAction("tier-create");
|
||||
};
|
||||
|
||||
const openTierEdit = (tier) => {
|
||||
setSelectedTier(tier);
|
||||
setTierForm(buildTierForm(tier));
|
||||
setActiveAction("tier-edit");
|
||||
};
|
||||
|
||||
const openTrackEdit = (levelTrack) => {
|
||||
setSelectedTrack(levelTrack);
|
||||
setTrackForm(buildTrackForm(levelTrack));
|
||||
setActiveAction("track-edit");
|
||||
};
|
||||
|
||||
const closeAction = () => {
|
||||
setActiveAction("");
|
||||
setSelectedRule(null);
|
||||
setSelectedTier(null);
|
||||
setSelectedTrack(null);
|
||||
};
|
||||
|
||||
const submitRule = async (event) => {
|
||||
event.preventDefault();
|
||||
const editing = activeAction === "rule-edit";
|
||||
const parsed = parseForm(levelRuleFormSchema, ruleForm);
|
||||
const level = Number(parsed.level);
|
||||
await runAction(editing ? "rule-edit" : "rule-create", editing ? "等级规则已更新" : "等级规则已创建", async () => {
|
||||
await upsertRule(parsed.track, level, buildRulePayload(parsed));
|
||||
closeAction();
|
||||
await reload();
|
||||
});
|
||||
};
|
||||
|
||||
const submitTier = async (event) => {
|
||||
event.preventDefault();
|
||||
const editing = activeAction === "tier-edit";
|
||||
if (editing && !selectedTier?.tierId) {
|
||||
return;
|
||||
}
|
||||
const parsed = parseForm(levelTierFormSchema, tierForm);
|
||||
await runAction(editing ? "tier-edit" : "tier-create", editing ? "等级层已更新" : "等级层已创建", async () => {
|
||||
const payload = buildTierPayload(parsed);
|
||||
if (editing) {
|
||||
await updateTier(selectedTier.tierId, payload);
|
||||
} else {
|
||||
await createTier(payload);
|
||||
}
|
||||
closeAction();
|
||||
await reload();
|
||||
});
|
||||
};
|
||||
|
||||
const submitTrack = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!selectedTrack?.track) {
|
||||
return;
|
||||
}
|
||||
const parsed = parseForm(levelTrackFormSchema, trackForm);
|
||||
await runAction("track-edit", "等级类型已更新", async () => {
|
||||
await updateTrack(selectedTrack.track, buildTrackPayload(parsed));
|
||||
closeAction();
|
||||
await reload();
|
||||
});
|
||||
};
|
||||
|
||||
const toggleRule = async (rule, nextEnabled = rule.status !== "active") => {
|
||||
if (!abilities.canUpdate || !rule?.track || !rule?.level) {
|
||||
return;
|
||||
}
|
||||
const nextStatus = nextEnabled ? "active" : "disabled";
|
||||
if (rule.status === nextStatus) {
|
||||
return;
|
||||
}
|
||||
await runAction(`rule-status-${rule.track}-${rule.level}`, nextEnabled ? "等级规则已启用" : "等级规则已停用", async () => {
|
||||
await upsertRule(rule.track, rule.level, buildRulePayload(buildRuleForm({ ...rule, status: nextStatus })));
|
||||
await reload();
|
||||
});
|
||||
};
|
||||
|
||||
const toggleTier = async (tier, nextEnabled = tier.status !== "active") => {
|
||||
if (!abilities.canUpdate || !tier?.tierId) {
|
||||
return;
|
||||
}
|
||||
const nextStatus = nextEnabled ? "active" : "disabled";
|
||||
if (tier.status === nextStatus) {
|
||||
return;
|
||||
}
|
||||
await runAction(`tier-status-${tier.tierId}`, nextEnabled ? "等级层已启用" : "等级层已停用", async () => {
|
||||
await updateTier(tier.tierId, buildTierPayload(buildTierForm({ ...tier, status: nextStatus })));
|
||||
await reload();
|
||||
});
|
||||
};
|
||||
|
||||
const runAction = async (action, successMessage, submitter) => {
|
||||
setLoadingAction(action);
|
||||
try {
|
||||
await submitter();
|
||||
showToast(successMessage, "success");
|
||||
} catch (err) {
|
||||
showToast(err.message || "操作失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
activeAction,
|
||||
avatarFrameResources,
|
||||
badgeResources,
|
||||
changeTrack,
|
||||
closeAction,
|
||||
config,
|
||||
error,
|
||||
loading,
|
||||
loadingAction,
|
||||
openRuleCreate,
|
||||
openRuleEdit,
|
||||
openTierCreate,
|
||||
openTierEdit,
|
||||
openTrackEdit,
|
||||
reload,
|
||||
resourceGroups,
|
||||
resources,
|
||||
ruleForm,
|
||||
selectedRule,
|
||||
selectedTier,
|
||||
selectedTrack,
|
||||
setRuleForm,
|
||||
setTierForm,
|
||||
setTrackForm,
|
||||
submitRule,
|
||||
submitTier,
|
||||
submitTrack,
|
||||
tierForm,
|
||||
toggleRule,
|
||||
toggleTier,
|
||||
track,
|
||||
trackForm,
|
||||
tracks,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveDefaultTrack(config, selectedTrack) {
|
||||
if (selectedTrack) {
|
||||
return selectedTrack;
|
||||
}
|
||||
return config.tracks?.[0]?.track || defaultTrack;
|
||||
}
|
||||
|
||||
function resolveTrack(config, trackCode) {
|
||||
return (config.tracks || []).find((item) => item.track === trackCode) || { track: trackCode, rules: [], tiers: [] };
|
||||
}
|
||||
|
||||
function buildRuleForm(rule = null, levelTrack = null) {
|
||||
const nextLevel = rule ? rule.level : nextRuleLevel(levelTrack);
|
||||
const trackCode = rule?.track || levelTrack?.track || defaultTrack;
|
||||
return {
|
||||
level: valueOrEmpty(nextLevel),
|
||||
longBadgeResourceId: optionalId(rule?.longBadgeResourceId),
|
||||
name: rule?.name || `${levelTrackLabels[trackCode] || "等级"} ${nextLevel}级`,
|
||||
requiredValue: valueOrEmpty(rule?.requiredValue),
|
||||
rewardResourceGroupId: optionalId(rule?.rewardResourceGroupId),
|
||||
sortOrder: valueOrDefault(rule?.sortOrder, nextLevel),
|
||||
status: rule?.status || "active",
|
||||
track: trackCode,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTierForm(tier = null, levelTrack = null) {
|
||||
const minLevel = tier ? tier.minLevel : nextTierStart(levelTrack);
|
||||
const maxLevel = tier ? tier.maxLevel : minLevel + 9;
|
||||
const trackCode = tier?.track || levelTrack?.track || defaultTrack;
|
||||
return {
|
||||
displayAvatarFrameResourceId: optionalId(tier?.displayAvatarFrameResourceId),
|
||||
displayBadgeResourceId: optionalId(tier?.displayBadgeResourceId),
|
||||
displayConfigJson: tier?.displayConfigJson || "",
|
||||
maxLevel: valueOrEmpty(maxLevel),
|
||||
minLevel: valueOrEmpty(minLevel),
|
||||
name: tier?.name || `${levelTrackLabels[trackCode] || "等级"} ${minLevel}-${maxLevel}级`,
|
||||
rewardResourceGroupId: optionalId(tier?.rewardResourceGroupId),
|
||||
status: tier?.status || "active",
|
||||
track: trackCode,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTrackForm(levelTrack = null) {
|
||||
return {
|
||||
displayConfigJson: levelTrack?.displayConfigJson || "",
|
||||
name: levelTrack?.name || (levelTrack?.track ? levelTrackLabels[levelTrack.track] : ""),
|
||||
sortOrder: valueOrDefault(levelTrack?.sortOrder, 0),
|
||||
status: levelTrack?.status || "active",
|
||||
};
|
||||
}
|
||||
|
||||
function buildRulePayload(form) {
|
||||
return {
|
||||
long_badge_resource_id: Number(form.longBadgeResourceId || 0),
|
||||
name: String(form.name || "").trim(),
|
||||
required_value: Number(form.requiredValue || 0),
|
||||
reward_resource_group_id: Number(form.rewardResourceGroupId || 0),
|
||||
sort_order: Number(form.sortOrder || 0),
|
||||
status: form.status,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTierPayload(form) {
|
||||
return {
|
||||
display_avatar_frame_resource_id: Number(form.displayAvatarFrameResourceId || 0),
|
||||
display_badge_resource_id: Number(form.displayBadgeResourceId || 0),
|
||||
display_config_json: normalizedJson(form.displayConfigJson),
|
||||
max_level: Number(form.maxLevel),
|
||||
min_level: Number(form.minLevel),
|
||||
name: String(form.name || "").trim(),
|
||||
reward_resource_group_id: Number(form.rewardResourceGroupId || 0),
|
||||
status: form.status,
|
||||
track: form.track,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTrackPayload(form) {
|
||||
return {
|
||||
display_config_json: normalizedJson(form.displayConfigJson),
|
||||
name: String(form.name || "").trim(),
|
||||
sort_order: Number(form.sortOrder || 0),
|
||||
status: form.status,
|
||||
};
|
||||
}
|
||||
|
||||
function nextRuleLevel(levelTrack) {
|
||||
const levels = (levelTrack?.rules || []).map((rule) => Number(rule.level || 0));
|
||||
return Math.max(0, ...levels) + 1;
|
||||
}
|
||||
|
||||
function nextTierStart(levelTrack) {
|
||||
const levels = (levelTrack?.tiers || []).map((tier) => Number(tier.maxLevel || 0));
|
||||
return Math.max(0, ...levels) + 1;
|
||||
}
|
||||
|
||||
function optionalId(value) {
|
||||
return value ? String(value) : "";
|
||||
}
|
||||
|
||||
function valueOrEmpty(value) {
|
||||
return value === 0 || value ? String(value) : "";
|
||||
}
|
||||
|
||||
function valueOrDefault(value, fallback) {
|
||||
return value === 0 || value ? String(value) : String(fallback);
|
||||
}
|
||||
|
||||
function normalizedJson(value) {
|
||||
const raw = String(value || "").trim();
|
||||
return raw || "{}";
|
||||
}
|
||||
331
src/features/level-config/level-config.module.css
Normal file
331
src/features/level-config/level-config.module.css
Normal file
@ -0,0 +1,331 @@
|
||||
.pageBody {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: var(--space-4) var(--space-5) var(--space-5);
|
||||
background: linear-gradient(180deg, rgba(248, 250, 252, 0.72), rgba(255, 255, 255, 0));
|
||||
}
|
||||
|
||||
.trackList {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.trackTabsBar {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-4);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--bg-card);
|
||||
padding: var(--space-1);
|
||||
}
|
||||
|
||||
.trackTabs {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.trackTabs :global(.MuiTabs-flexContainer) {
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.trackTabs :global(.MuiTabs-indicator) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.trackTabs :global(.MuiTabs-scrollButtons) {
|
||||
width: 32px;
|
||||
border-radius: var(--radius-control);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.trackTabs .trackTab {
|
||||
min-width: 176px;
|
||||
min-height: 48px;
|
||||
align-items: flex-start;
|
||||
border-radius: var(--radius-control);
|
||||
color: var(--text-secondary);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
text-align: left;
|
||||
text-transform: none;
|
||||
transition:
|
||||
background var(--motion-fast) var(--ease-standard),
|
||||
box-shadow var(--motion-fast) var(--ease-standard),
|
||||
color var(--motion-fast) var(--ease-standard),
|
||||
transform var(--motion-base) var(--ease-emphasized);
|
||||
}
|
||||
|
||||
.trackTabs .trackTab:hover {
|
||||
background: var(--primary-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.trackTabs .trackTab:global(.Mui-selected) {
|
||||
background: var(--primary);
|
||||
box-shadow: var(--brand-shadow);
|
||||
color: var(--active-contrast);
|
||||
}
|
||||
|
||||
.trackTabs .trackTab:global(.Mui-selected) .trackTabMeta {
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
}
|
||||
|
||||
.trackTabLabel {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 3px;
|
||||
font-weight: 760;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.trackTabMeta {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
line-height: 16px;
|
||||
transition: color var(--motion-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.tabActions {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding-right: var(--space-1);
|
||||
}
|
||||
|
||||
.trackSection {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--bg-card);
|
||||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.trackHeader {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
background: rgba(248, 250, 252, 0.82);
|
||||
}
|
||||
|
||||
.trackTitleGroup,
|
||||
.identityText,
|
||||
.stack {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.trackTitleGroup {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.trackTitleRow {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.trackTitle {
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
font-size: 16px;
|
||||
font-weight: 760;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.trackMetaList {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--admin-font-size);
|
||||
}
|
||||
|
||||
.trackMetaList span {
|
||||
display: inline-flex;
|
||||
min-height: 22px;
|
||||
align-items: center;
|
||||
border-radius: var(--radius-pill);
|
||||
background: rgba(100, 116, 139, 0.09);
|
||||
padding: 0 var(--space-2);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.meta {
|
||||
overflow: hidden;
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--admin-font-size);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tableStack {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.tableBlock {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tableHeader {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.tableTitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--admin-font-size);
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
.tableCount {
|
||||
display: inline-flex;
|
||||
height: 24px;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--bg-card-strong);
|
||||
color: var(--text-tertiary);
|
||||
padding: 0 var(--space-2);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.compactTable {
|
||||
flex: 0 0 auto;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
|
||||
.compactTable :global(.table-scroll) {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.compactTable :global(.admin-table) {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.compactTable :global(.admin-row) {
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
.compactTable :global(.admin-row--head),
|
||||
.compactTable :global(.admin-row--head .admin-cell--fixed-left),
|
||||
.compactTable :global(.admin-row--head .admin-cell--fixed-right),
|
||||
.compactTable :global(.admin-cell--head) {
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.compactTable :global(.empty-state--table) {
|
||||
min-height: 96px;
|
||||
}
|
||||
|
||||
.identity {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.levelBadge,
|
||||
.rangeBadge {
|
||||
display: inline-flex;
|
||||
min-width: 62px;
|
||||
height: 28px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--primary-border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--primary-surface);
|
||||
color: var(--primary);
|
||||
font-size: var(--admin-font-size);
|
||||
font-weight: 760;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.rangeBadge {
|
||||
min-width: 76px;
|
||||
}
|
||||
|
||||
.name,
|
||||
.resourceText {
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
font-weight: 650;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.statusCell {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.emptyValue {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
min-height: 220px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--admin-font-size);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.trackHeader {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.trackTabsBar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pageBody,
|
||||
.tableStack,
|
||||
.trackHeader {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.trackTabs .trackTab {
|
||||
min-width: 148px;
|
||||
}
|
||||
|
||||
.tabActions {
|
||||
justify-content: flex-end;
|
||||
padding: 0 var(--space-1) var(--space-1);
|
||||
}
|
||||
}
|
||||
711
src/features/level-config/pages/LevelConfigPage.jsx
Normal file
711
src/features/level-config/pages/LevelConfigPage.jsx
Normal file
@ -0,0 +1,711 @@
|
||||
import Add from "@mui/icons-material/Add";
|
||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||
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 {
|
||||
AdminFormDialog,
|
||||
AdminFormFieldGrid,
|
||||
AdminFormSection,
|
||||
AdminFormSwitchField,
|
||||
} from "@/shared/ui/AdminFormDialog.jsx";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import {
|
||||
AdminActionIconButton,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminRowActions,
|
||||
} from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import {
|
||||
levelStatusLabels,
|
||||
levelTrackLabels,
|
||||
levelTrackOptions,
|
||||
} from "@/features/level-config/constants.js";
|
||||
import { useLevelConfigPage } from "@/features/level-config/hooks/useLevelConfigPage.js";
|
||||
import styles from "@/features/level-config/level-config.module.css";
|
||||
|
||||
export function LevelConfigPage() {
|
||||
const page = useLevelConfigPage();
|
||||
const trackTabs = buildTrackTabs(page.config.tracks);
|
||||
|
||||
return (
|
||||
<AdminListPage>
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<AdminListBody>
|
||||
<div className={styles.pageBody}>
|
||||
<div className={styles.trackTabsBar}>
|
||||
<Tabs
|
||||
allowScrollButtonsMobile
|
||||
className={styles.trackTabs}
|
||||
value={page.track}
|
||||
variant="scrollable"
|
||||
onChange={(_, nextTrack) => page.changeTrack(nextTrack)}
|
||||
>
|
||||
{trackTabs.map((item) => (
|
||||
<Tab
|
||||
className={styles.trackTab}
|
||||
key={item.value}
|
||||
label={
|
||||
<span className={styles.trackTabLabel}>
|
||||
<span>{item.label}</span>
|
||||
<span className={styles.trackTabMeta}>
|
||||
{item.ruleCount} 条规则 · {item.tierCount} 个等级层
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
value={item.value}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
<div className={styles.tabActions}>
|
||||
<AdminActionIconButton
|
||||
disabled={page.loading}
|
||||
label="刷新配置"
|
||||
type="button"
|
||||
onClick={page.reload}
|
||||
>
|
||||
<RefreshOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
<Button
|
||||
disabled={!page.abilities.canUpdate}
|
||||
startIcon={<Add fontSize="small" />}
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={() => page.openRuleCreate(page.track)}
|
||||
>
|
||||
添加规则
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!page.abilities.canUpdate}
|
||||
startIcon={<Add fontSize="small" />}
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={() => page.openTierCreate(page.track)}
|
||||
>
|
||||
添加等级层
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{page.tracks.length ? (
|
||||
<div className={styles.trackList}>
|
||||
{page.tracks.map((track) => (
|
||||
<LevelTrackSection key={track.track} page={page} track={track} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.empty}>当前无数据</div>
|
||||
)}
|
||||
</div>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
<RuleFormDialog page={page} />
|
||||
<TierFormDialog page={page} />
|
||||
<TrackFormDialog page={page} />
|
||||
</AdminListPage>
|
||||
);
|
||||
}
|
||||
|
||||
function LevelTrackSection({ page, track }) {
|
||||
const rules = track.rules || [];
|
||||
const tiers = track.tiers || [];
|
||||
const totalRules = track.rules?.length || 0;
|
||||
const totalTiers = track.tiers?.length || 0;
|
||||
const ruleColumns = buildRuleColumns(page);
|
||||
const tierColumns = buildTierColumns(page);
|
||||
|
||||
return (
|
||||
<section className={styles.trackSection}>
|
||||
<div className={styles.trackHeader}>
|
||||
<div className={styles.trackTitleGroup}>
|
||||
<div className={styles.trackTitleRow}>
|
||||
<span className={styles.trackTitle}>{track.name || levelTrackLabels[track.track] || track.track}</span>
|
||||
</div>
|
||||
<div className={styles.trackMetaList}>
|
||||
<span>{track.track}</span>
|
||||
<span>{totalRules} 条规则</span>
|
||||
<span>{totalTiers} 个等级层</span>
|
||||
<span>排序 {track.sortOrder || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
<AdminRowActions>
|
||||
<AdminActionIconButton
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="编辑等级类型"
|
||||
type="button"
|
||||
onClick={() => page.openTrackEdit(track)}
|
||||
>
|
||||
<EditOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
<AdminActionIconButton
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="添加本类型等级规则"
|
||||
type="button"
|
||||
onClick={() => page.openRuleCreate(track.track)}
|
||||
>
|
||||
<Add fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
<AdminActionIconButton
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="添加本类型等级层"
|
||||
type="button"
|
||||
onClick={() => page.openTierCreate(track.track)}
|
||||
>
|
||||
<Add fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
</AdminRowActions>
|
||||
</div>
|
||||
<div className={styles.tableStack}>
|
||||
<div className={styles.tableBlock}>
|
||||
<TableBlockHeader countLabel={`${rules.length} 条`} title="每级升级数值" />
|
||||
<DataTable
|
||||
className={styles.compactTable}
|
||||
columns={ruleColumns}
|
||||
items={rules}
|
||||
minWidth="1180px"
|
||||
rowKey={(rule) => `${rule.track}-${rule.level}`}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.tableBlock}>
|
||||
<TableBlockHeader countLabel={`${tiers.length} 个`} title="等级层展示与礼物" />
|
||||
<DataTable
|
||||
className={styles.compactTable}
|
||||
columns={tierColumns}
|
||||
items={tiers}
|
||||
minWidth="1180px"
|
||||
rowKey={(tier) => tier.tierId || `${tier.track}-${tier.minLevel}-${tier.maxLevel}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TableBlockHeader({ countLabel, title }) {
|
||||
return (
|
||||
<div className={styles.tableHeader}>
|
||||
<div className={styles.tableTitle}>{title}</div>
|
||||
<span className={styles.tableCount}>{countLabel}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildTrackTabs(tracks = []) {
|
||||
return levelTrackOptions.map(([value, label]) => {
|
||||
const track = tracks.find((item) => item.track === value);
|
||||
return {
|
||||
label: track?.name || label,
|
||||
ruleCount: track?.rules?.length || 0,
|
||||
tierCount: track?.tiers?.length || 0,
|
||||
value,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildRuleColumns(page) {
|
||||
return [
|
||||
{
|
||||
key: "level",
|
||||
label: "等级",
|
||||
width: "minmax(180px, 0.8fr)",
|
||||
render: (rule) => <RuleIdentity rule={rule} />,
|
||||
},
|
||||
{
|
||||
key: "requiredValue",
|
||||
label: "升级数值",
|
||||
width: "minmax(140px, 0.65fr)",
|
||||
render: (rule) => formatNumber(rule.requiredValue),
|
||||
},
|
||||
{
|
||||
key: "longBadge",
|
||||
label: "长徽章素材",
|
||||
width: "minmax(200px, 1fr)",
|
||||
render: (rule) => <ResourceLabel id={rule.longBadgeResourceId} resources={page.badgeResources} />,
|
||||
},
|
||||
{
|
||||
key: "reward",
|
||||
label: "升级礼物",
|
||||
width: "minmax(200px, 1fr)",
|
||||
render: (rule) => <ResourceGroupLabel id={rule.rewardResourceGroupId} groups={page.resourceGroups} />,
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
width: "minmax(116px, 0.55fr)",
|
||||
render: (rule) => <RuleStatusSwitch page={page} rule={rule} />,
|
||||
},
|
||||
{
|
||||
key: "sort",
|
||||
label: "排序",
|
||||
width: "minmax(90px, 0.45fr)",
|
||||
render: (rule) => rule.sortOrder || 0,
|
||||
},
|
||||
{
|
||||
key: "updatedAt",
|
||||
label: "更新时间",
|
||||
width: "minmax(170px, 0.75fr)",
|
||||
render: (rule) => formatMillis(rule.updatedAtMs || rule.createdAtMs),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
width: "minmax(76px, 0.35fr)",
|
||||
render: (rule) => <RuleRowActions page={page} rule={rule} />,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function buildTierColumns(page) {
|
||||
return [
|
||||
{
|
||||
key: "range",
|
||||
label: "等级层",
|
||||
width: "minmax(220px, 1fr)",
|
||||
render: (tier) => <TierIdentity tier={tier} />,
|
||||
},
|
||||
{
|
||||
key: "frame",
|
||||
label: "头像框",
|
||||
width: "minmax(200px, 0.9fr)",
|
||||
render: (tier) => <ResourceLabel id={tier.displayAvatarFrameResourceId} resources={page.avatarFrameResources} />,
|
||||
},
|
||||
{
|
||||
key: "badge",
|
||||
label: "徽章",
|
||||
width: "minmax(200px, 0.9fr)",
|
||||
render: (tier) => <ResourceLabel id={tier.displayBadgeResourceId} resources={page.badgeResources} />,
|
||||
},
|
||||
{
|
||||
key: "reward",
|
||||
label: "升级礼物",
|
||||
width: "minmax(200px, 1fr)",
|
||||
render: (tier) => <ResourceGroupLabel id={tier.rewardResourceGroupId} groups={page.resourceGroups} />,
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
width: "minmax(116px, 0.55fr)",
|
||||
render: (tier) => <TierStatusSwitch page={page} tier={tier} />,
|
||||
},
|
||||
{
|
||||
key: "updatedAt",
|
||||
label: "更新时间",
|
||||
width: "minmax(170px, 0.75fr)",
|
||||
render: (tier) => formatMillis(tier.updatedAtMs || tier.createdAtMs),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
width: "minmax(76px, 0.35fr)",
|
||||
render: (tier) => <TierRowActions page={page} tier={tier} />,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function RuleIdentity({ rule }) {
|
||||
return (
|
||||
<div className={styles.identity}>
|
||||
<span className={styles.levelBadge}>Lv.{rule.level}</span>
|
||||
<div className={styles.identityText}>
|
||||
<span className={styles.name}>{rule.name || `${levelTrackLabels[rule.track]} ${rule.level}级`}</span>
|
||||
<span className={styles.meta}>{levelTrackLabels[rule.track] || rule.track}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TierIdentity({ tier }) {
|
||||
return (
|
||||
<div className={styles.identity}>
|
||||
<span className={styles.rangeBadge}>
|
||||
{tier.minLevel}-{tier.maxLevel}
|
||||
</span>
|
||||
<div className={styles.identityText}>
|
||||
<span className={styles.name}>{tier.name || `${tier.minLevel}-${tier.maxLevel}级`}</span>
|
||||
<span className={styles.meta}>{levelTrackLabels[tier.track] || tier.track}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RuleStatusSwitch({ page, rule }) {
|
||||
const checked = rule.status === "active";
|
||||
return (
|
||||
<div className={styles.statusCell}>
|
||||
<AdminSwitch
|
||||
checked={checked}
|
||||
disabled={!page.abilities.canUpdate || page.loadingAction === `rule-status-${rule.track}-${rule.level}`}
|
||||
inputProps={{ "aria-label": checked ? "停用等级规则" : "启用等级规则" }}
|
||||
onChange={(event) => page.toggleRule(rule, event.target.checked)}
|
||||
/>
|
||||
<span className={styles.meta}>{levelStatusLabel(rule.status)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TierStatusSwitch({ page, tier }) {
|
||||
const checked = tier.status === "active";
|
||||
return (
|
||||
<div className={styles.statusCell}>
|
||||
<AdminSwitch
|
||||
checked={checked}
|
||||
disabled={!page.abilities.canUpdate || page.loadingAction === `tier-status-${tier.tierId}`}
|
||||
inputProps={{ "aria-label": checked ? "停用等级层" : "启用等级层" }}
|
||||
onChange={(event) => page.toggleTier(tier, event.target.checked)}
|
||||
/>
|
||||
<span className={styles.meta}>{levelStatusLabel(tier.status)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RuleRowActions({ page, rule }) {
|
||||
return (
|
||||
<AdminRowActions>
|
||||
<AdminActionIconButton
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="编辑等级规则"
|
||||
type="button"
|
||||
onClick={() => page.openRuleEdit(rule)}
|
||||
>
|
||||
<EditOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
</AdminRowActions>
|
||||
);
|
||||
}
|
||||
|
||||
function TierRowActions({ page, tier }) {
|
||||
return (
|
||||
<AdminRowActions>
|
||||
<AdminActionIconButton
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="编辑等级层"
|
||||
type="button"
|
||||
onClick={() => page.openTierEdit(tier)}
|
||||
>
|
||||
<EditOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
</AdminRowActions>
|
||||
);
|
||||
}
|
||||
|
||||
function RuleFormDialog({ page }) {
|
||||
const open = page.activeAction === "rule-create" || page.activeAction === "rule-edit";
|
||||
const editing = page.activeAction === "rule-edit";
|
||||
const form = page.ruleForm;
|
||||
|
||||
return (
|
||||
<AdminFormDialog
|
||||
disabled={!page.abilities.canUpdate}
|
||||
loading={page.loadingAction === page.activeAction}
|
||||
open={open}
|
||||
size="wide"
|
||||
submitLabel={editing ? "保存规则" : "创建规则"}
|
||||
title={editing ? "编辑等级规则" : "添加等级规则"}
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitRule}
|
||||
>
|
||||
<AdminFormSection title="升级配置">
|
||||
<AdminFormFieldGrid>
|
||||
<TextField
|
||||
select
|
||||
disabled={editing || !page.abilities.canUpdate}
|
||||
label="等级类型"
|
||||
value={form.track}
|
||||
onChange={changeField(page.setRuleForm, "track")}
|
||||
>
|
||||
{levelTrackOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={editing || !page.abilities.canUpdate}
|
||||
inputProps={{ min: 1 }}
|
||||
label="等级"
|
||||
type="number"
|
||||
value={form.level}
|
||||
onChange={changeField(page.setRuleForm, "level")}
|
||||
/>
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate}
|
||||
inputProps={{ min: 0 }}
|
||||
label="升级数值"
|
||||
type="number"
|
||||
value={form.requiredValue}
|
||||
onChange={changeField(page.setRuleForm, "requiredValue")}
|
||||
/>
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="展示名称"
|
||||
value={form.name}
|
||||
onChange={changeField(page.setRuleForm, "name")}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="展示与礼物">
|
||||
<AdminFormFieldGrid>
|
||||
<ResourceSelect
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="长徽章素材"
|
||||
resources={page.badgeResources}
|
||||
value={form.longBadgeResourceId}
|
||||
onChange={changeField(page.setRuleForm, "longBadgeResourceId")}
|
||||
/>
|
||||
<ResourceGroupSelect
|
||||
disabled={!page.abilities.canUpdate}
|
||||
groups={page.resourceGroups}
|
||||
label="升级礼物"
|
||||
value={form.rewardResourceGroupId}
|
||||
onChange={changeField(page.setRuleForm, "rewardResourceGroupId")}
|
||||
/>
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="排序"
|
||||
type="number"
|
||||
value={form.sortOrder}
|
||||
onChange={changeField(page.setRuleForm, "sortOrder")}
|
||||
/>
|
||||
<AdminFormSwitchField
|
||||
checked={form.status === "active"}
|
||||
checkedLabel="启用"
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="状态"
|
||||
uncheckedLabel="停用"
|
||||
onChange={(checked) =>
|
||||
page.setRuleForm((current) => ({ ...current, status: checked ? "active" : "disabled" }))
|
||||
}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
</AdminFormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function TierFormDialog({ page }) {
|
||||
const open = page.activeAction === "tier-create" || page.activeAction === "tier-edit";
|
||||
const editing = page.activeAction === "tier-edit";
|
||||
const form = page.tierForm;
|
||||
|
||||
return (
|
||||
<AdminFormDialog
|
||||
disabled={!page.abilities.canUpdate}
|
||||
loading={page.loadingAction === page.activeAction}
|
||||
open={open}
|
||||
size="wide"
|
||||
submitLabel={editing ? "保存等级层" : "创建等级层"}
|
||||
title={editing ? "编辑等级层" : "添加等级层"}
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitTier}
|
||||
>
|
||||
<AdminFormSection title="等级范围">
|
||||
<AdminFormFieldGrid>
|
||||
<TextField
|
||||
select
|
||||
disabled={editing || !page.abilities.canUpdate}
|
||||
label="等级类型"
|
||||
value={form.track}
|
||||
onChange={changeField(page.setTierForm, "track")}
|
||||
>
|
||||
{levelTrackOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate}
|
||||
inputProps={{ min: 1 }}
|
||||
label="起始等级"
|
||||
type="number"
|
||||
value={form.minLevel}
|
||||
onChange={changeField(page.setTierForm, "minLevel")}
|
||||
/>
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate}
|
||||
inputProps={{ min: 1 }}
|
||||
label="结束等级"
|
||||
type="number"
|
||||
value={form.maxLevel}
|
||||
onChange={changeField(page.setTierForm, "maxLevel")}
|
||||
/>
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="等级层名称"
|
||||
value={form.name}
|
||||
onChange={changeField(page.setTierForm, "name")}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="展示与礼物">
|
||||
<AdminFormFieldGrid>
|
||||
<ResourceSelect
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="头像框素材"
|
||||
resources={page.avatarFrameResources}
|
||||
value={form.displayAvatarFrameResourceId}
|
||||
onChange={changeField(page.setTierForm, "displayAvatarFrameResourceId")}
|
||||
/>
|
||||
<ResourceSelect
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="徽章素材"
|
||||
resources={page.badgeResources}
|
||||
value={form.displayBadgeResourceId}
|
||||
onChange={changeField(page.setTierForm, "displayBadgeResourceId")}
|
||||
/>
|
||||
<ResourceGroupSelect
|
||||
disabled={!page.abilities.canUpdate}
|
||||
groups={page.resourceGroups}
|
||||
label="升级礼物"
|
||||
value={form.rewardResourceGroupId}
|
||||
onChange={changeField(page.setTierForm, "rewardResourceGroupId")}
|
||||
/>
|
||||
<AdminFormSwitchField
|
||||
checked={form.status === "active"}
|
||||
checkedLabel="启用"
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="状态"
|
||||
uncheckedLabel="停用"
|
||||
onChange={(checked) =>
|
||||
page.setTierForm((current) => ({ ...current, status: checked ? "active" : "disabled" }))
|
||||
}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
</AdminFormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function TrackFormDialog({ page }) {
|
||||
const form = page.trackForm;
|
||||
|
||||
return (
|
||||
<AdminFormDialog
|
||||
disabled={!page.abilities.canUpdate}
|
||||
loading={page.loadingAction === "track-edit"}
|
||||
open={page.activeAction === "track-edit"}
|
||||
submitLabel="保存等级类型"
|
||||
title="编辑等级类型"
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitTrack}
|
||||
>
|
||||
<AdminFormSection title={levelTrackLabels[page.selectedTrack?.track] || "等级类型"}>
|
||||
<AdminFormFieldGrid>
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="名称"
|
||||
value={form.name}
|
||||
onChange={changeField(page.setTrackForm, "name")}
|
||||
/>
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="排序"
|
||||
type="number"
|
||||
value={form.sortOrder}
|
||||
onChange={changeField(page.setTrackForm, "sortOrder")}
|
||||
/>
|
||||
<AdminFormSwitchField
|
||||
checked={form.status === "active"}
|
||||
checkedLabel="启用"
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="状态"
|
||||
uncheckedLabel="停用"
|
||||
onChange={(checked) =>
|
||||
page.setTrackForm((current) => ({ ...current, status: checked ? "active" : "disabled" }))
|
||||
}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
</AdminFormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ResourceSelect({ disabled, label, onChange, resources, value }) {
|
||||
const options = withSelectedResource(resources, value);
|
||||
return (
|
||||
<TextField select disabled={disabled} label={label} value={String(value || "")} onChange={onChange}>
|
||||
<MenuItem value="">不配置</MenuItem>
|
||||
{options.map((resource) => (
|
||||
<MenuItem key={resource.resourceId} value={String(resource.resourceId)}>
|
||||
{resource.name || resource.resourceCode || resource.resourceId}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
);
|
||||
}
|
||||
|
||||
function ResourceGroupSelect({ disabled, groups, label, onChange, value }) {
|
||||
const options = withSelectedGroup(groups, value);
|
||||
return (
|
||||
<TextField select disabled={disabled} label={label} value={String(value || "")} onChange={onChange}>
|
||||
<MenuItem value="">不配置</MenuItem>
|
||||
{options.map((group) => (
|
||||
<MenuItem key={group.groupId} value={String(group.groupId)}>
|
||||
{group.name || group.groupCode || group.groupId}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
);
|
||||
}
|
||||
|
||||
function ResourceLabel({ id, resources }) {
|
||||
if (!id) {
|
||||
return <span className={styles.emptyValue}>未配置</span>;
|
||||
}
|
||||
const resource = resources.find((item) => Number(item.resourceId) === Number(id));
|
||||
return (
|
||||
<div className={styles.stack}>
|
||||
<span className={styles.resourceText}>{resource?.name || resource?.resourceCode || `资源 #${id}`}</span>
|
||||
<span className={styles.meta}>ID {id}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResourceGroupLabel({ groups, id }) {
|
||||
if (!id) {
|
||||
return <span className={styles.emptyValue}>未配置</span>;
|
||||
}
|
||||
const group = groups.find((item) => Number(item.groupId) === Number(id));
|
||||
return (
|
||||
<div className={styles.stack}>
|
||||
<span className={styles.resourceText}>{group?.name || group?.groupCode || `资源组 #${id}`}</span>
|
||||
<span className={styles.meta}>ID {id}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function changeField(setter, key) {
|
||||
return (event) => {
|
||||
const value = event.target.value;
|
||||
setter((current) => ({ ...current, [key]: value }));
|
||||
};
|
||||
}
|
||||
|
||||
function withSelectedResource(resources, value) {
|
||||
const selectedId = Number(value || 0);
|
||||
if (!selectedId || resources.some((resource) => Number(resource.resourceId) === selectedId)) {
|
||||
return resources;
|
||||
}
|
||||
return [{ resourceId: selectedId, name: `资源 #${selectedId}` }, ...resources];
|
||||
}
|
||||
|
||||
function withSelectedGroup(groups, value) {
|
||||
const selectedId = Number(value || 0);
|
||||
if (!selectedId || groups.some((group) => Number(group.groupId) === selectedId)) {
|
||||
return groups;
|
||||
}
|
||||
return [{ groupId: selectedId, name: `资源组 #${selectedId}` }, ...groups];
|
||||
}
|
||||
|
||||
function levelStatusLabel(status) {
|
||||
return levelStatusLabels[status] || status || "-";
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return Number(value || 0).toLocaleString("zh-CN");
|
||||
}
|
||||
11
src/features/level-config/permissions.js
Normal file
11
src/features/level-config/permissions.js
Normal file
@ -0,0 +1,11 @@
|
||||
import { PERMISSIONS } from "@/app/permissions";
|
||||
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||
|
||||
export function useLevelConfigAbilities() {
|
||||
const { can } = useAuth();
|
||||
|
||||
return {
|
||||
canUpdate: can(PERMISSIONS.levelConfigUpdate),
|
||||
canView: can(PERMISSIONS.levelConfigView),
|
||||
};
|
||||
}
|
||||
12
src/features/level-config/routes.js
Normal file
12
src/features/level-config/routes.js
Normal file
@ -0,0 +1,12 @@
|
||||
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
|
||||
|
||||
export const levelConfigRoutes = [
|
||||
{
|
||||
label: "等级配置",
|
||||
loader: () => import("./pages/LevelConfigPage.jsx").then((module) => module.LevelConfigPage),
|
||||
menuCode: MENU_CODES.appUserLevelConfig,
|
||||
pageKey: "app-user-level-config",
|
||||
path: "/app/users/level-config",
|
||||
permission: PERMISSIONS.levelConfigView,
|
||||
},
|
||||
];
|
||||
99
src/features/level-config/schema.ts
Normal file
99
src/features/level-config/schema.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const trackSchema = z.enum(["wealth", "game", "charm"]);
|
||||
const statusSchema = z.enum(["active", "disabled"]);
|
||||
const numberInput = z.union([z.string(), z.number()]);
|
||||
|
||||
export const levelTrackFormSchema = z
|
||||
.object({
|
||||
displayConfigJson: z.string().optional(),
|
||||
name: z.string().trim().min(1, "请输入等级名称").max(64, "等级名称不能超过 64 个字符"),
|
||||
sortOrder: numberInput.optional(),
|
||||
status: statusSchema,
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
validateInteger(context, "sortOrder", value.sortOrder || 0, "排序必须是整数");
|
||||
});
|
||||
|
||||
export const levelRuleFormSchema = z
|
||||
.object({
|
||||
level: numberInput,
|
||||
longBadgeResourceId: numberInput.optional(),
|
||||
name: z.string().trim().min(1, "请输入等级展示名称").max(64, "等级展示名称不能超过 64 个字符"),
|
||||
requiredValue: numberInput,
|
||||
rewardResourceGroupId: numberInput.optional(),
|
||||
sortOrder: numberInput.optional(),
|
||||
status: statusSchema,
|
||||
track: trackSchema,
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
validatePositiveInteger(context, "level", value.level, "等级必须是正整数");
|
||||
validateNonNegativeInteger(context, "requiredValue", value.requiredValue, "升级数值必须是非负整数");
|
||||
validateOptionalId(context, "longBadgeResourceId", value.longBadgeResourceId, "长徽章素材必须是有效资源");
|
||||
validateOptionalId(context, "rewardResourceGroupId", value.rewardResourceGroupId, "升级礼物必须是有效资源组");
|
||||
validateInteger(context, "sortOrder", value.sortOrder || 0, "排序必须是整数");
|
||||
});
|
||||
|
||||
export const levelTierFormSchema = z
|
||||
.object({
|
||||
displayAvatarFrameResourceId: numberInput.optional(),
|
||||
displayBadgeResourceId: numberInput.optional(),
|
||||
displayConfigJson: z.string().optional(),
|
||||
maxLevel: numberInput,
|
||||
minLevel: numberInput,
|
||||
name: z.string().trim().min(1, "请输入等级层名称").max(64, "等级层名称不能超过 64 个字符"),
|
||||
rewardResourceGroupId: numberInput.optional(),
|
||||
status: statusSchema,
|
||||
track: trackSchema,
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
const minLevel = Number(value.minLevel);
|
||||
const maxLevel = Number(value.maxLevel);
|
||||
|
||||
validatePositiveInteger(context, "minLevel", value.minLevel, "起始等级必须是正整数");
|
||||
validatePositiveInteger(context, "maxLevel", value.maxLevel, "结束等级必须是正整数");
|
||||
if (Number.isInteger(minLevel) && Number.isInteger(maxLevel) && maxLevel < minLevel) {
|
||||
context.addIssue({ code: "custom", message: "结束等级不能小于起始等级", path: ["maxLevel"] });
|
||||
}
|
||||
validateOptionalId(
|
||||
context,
|
||||
"displayAvatarFrameResourceId",
|
||||
value.displayAvatarFrameResourceId,
|
||||
"头像框素材必须是有效资源",
|
||||
);
|
||||
validateOptionalId(context, "displayBadgeResourceId", value.displayBadgeResourceId, "徽章素材必须是有效资源");
|
||||
validateOptionalId(context, "rewardResourceGroupId", value.rewardResourceGroupId, "升级礼物必须是有效资源组");
|
||||
});
|
||||
|
||||
export type LevelRuleForm = z.infer<typeof levelRuleFormSchema>;
|
||||
export type LevelTierForm = z.infer<typeof levelTierFormSchema>;
|
||||
export type LevelTrackForm = z.infer<typeof levelTrackFormSchema>;
|
||||
|
||||
function validatePositiveInteger(context: z.RefinementCtx, path: string, value: unknown, message: string) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isInteger(numeric) || numeric <= 0) {
|
||||
context.addIssue({ code: "custom", message, path: [path] });
|
||||
}
|
||||
}
|
||||
|
||||
function validateNonNegativeInteger(context: z.RefinementCtx, path: string, value: unknown, message: string) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isInteger(numeric) || numeric < 0) {
|
||||
context.addIssue({ code: "custom", message, path: [path] });
|
||||
}
|
||||
}
|
||||
|
||||
function validateInteger(context: z.RefinementCtx, path: string, value: unknown, message: string) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isInteger(numeric)) {
|
||||
context.addIssue({ code: "custom", message, path: [path] });
|
||||
}
|
||||
}
|
||||
|
||||
function validateOptionalId(context: z.RefinementCtx, path: string, value: unknown, message: string) {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
validatePositiveInteger(context, path, raw, message);
|
||||
}
|
||||
39
src/features/operations/api.test.ts
Normal file
39
src/features/operations/api.test.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { setAccessToken } from "@/shared/api/request";
|
||||
import { createCoinAdjustment, listCoinAdjustments, lookupCoinAdjustmentTarget } from "./api";
|
||||
|
||||
afterEach(() => {
|
||||
setAccessToken("");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("coin adjustment APIs use generated admin paths", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
async () => new Response(JSON.stringify({ code: 0, data: { items: [], page: 1, pageSize: 20, total: 0 } })),
|
||||
),
|
||||
);
|
||||
|
||||
await listCoinAdjustments({ page: 1, page_size: 20, user_keyword: "1001" });
|
||||
await lookupCoinAdjustmentTarget("1001");
|
||||
await createCoinAdjustment({
|
||||
amount: 500,
|
||||
commandId: "coin-adjustment-test",
|
||||
reason: "运营发放",
|
||||
targetUserId: "1001",
|
||||
});
|
||||
|
||||
const [listUrl, listInit] = vi.mocked(fetch).mock.calls[0];
|
||||
const [lookupUrl, lookupInit] = vi.mocked(fetch).mock.calls[1];
|
||||
const [createUrl, createInit] = vi.mocked(fetch).mock.calls[2];
|
||||
|
||||
expect(String(listUrl)).toContain("/api/v1/admin/operations/coin-adjustments?");
|
||||
expect(String(listUrl)).toContain("user_keyword=1001");
|
||||
expect(listInit?.method).toBe("GET");
|
||||
expect(String(lookupUrl)).toContain("/api/v1/admin/operations/coin-adjustments/target?");
|
||||
expect(String(lookupUrl)).toContain("user_id=1001");
|
||||
expect(lookupInit?.method).toBe("GET");
|
||||
expect(String(createUrl)).toContain("/api/v1/admin/operations/coin-adjustments");
|
||||
expect(createInit?.method).toBe("POST");
|
||||
});
|
||||
@ -1,6 +1,14 @@
|
||||
import { apiRequest } from "@/shared/api/request";
|
||||
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||
import type { ApiPage, CoinLedgerEntryDto, PageQuery } from "@/shared/api/types";
|
||||
import type {
|
||||
ApiPage,
|
||||
CoinAdjustmentCreateDto,
|
||||
CoinAdjustmentDto,
|
||||
CoinAdjustmentPayload,
|
||||
CoinLedgerEntryDto,
|
||||
CoinLedgerUserDto,
|
||||
PageQuery,
|
||||
} from "@/shared/api/types";
|
||||
|
||||
export function listCoinLedger(query: PageQuery = {}): Promise<ApiPage<CoinLedgerEntryDto>> {
|
||||
const endpoint = API_ENDPOINTS.listCoinLedger;
|
||||
@ -9,3 +17,30 @@ export function listCoinLedger(query: PageQuery = {}): Promise<ApiPage<CoinLedge
|
||||
query,
|
||||
});
|
||||
}
|
||||
|
||||
export function listCoinAdjustments(query: PageQuery = {}): Promise<ApiPage<CoinAdjustmentDto>> {
|
||||
const endpoint = API_ENDPOINTS.listCoinAdjustments;
|
||||
return apiRequest<ApiPage<CoinAdjustmentDto>>(apiEndpointPath(API_OPERATIONS.listCoinAdjustments), {
|
||||
method: endpoint.method,
|
||||
query,
|
||||
});
|
||||
}
|
||||
|
||||
export function lookupCoinAdjustmentTarget(userId: string): Promise<CoinLedgerUserDto> {
|
||||
const endpoint = API_ENDPOINTS.lookupCoinAdjustmentTarget;
|
||||
return apiRequest<CoinLedgerUserDto>(apiEndpointPath(API_OPERATIONS.lookupCoinAdjustmentTarget), {
|
||||
method: endpoint.method,
|
||||
query: { user_id: userId },
|
||||
});
|
||||
}
|
||||
|
||||
export function createCoinAdjustment(payload: CoinAdjustmentPayload): Promise<CoinAdjustmentCreateDto> {
|
||||
const endpoint = API_ENDPOINTS.createCoinAdjustment;
|
||||
return apiRequest<CoinAdjustmentCreateDto, CoinAdjustmentPayload>(
|
||||
apiEndpointPath(API_OPERATIONS.createCoinAdjustment),
|
||||
{
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -16,6 +16,10 @@
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.adjustmentAmount {
|
||||
font-size: calc(var(--admin-font-size) + 4px);
|
||||
}
|
||||
|
||||
.clickableRow:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
@ -163,8 +167,30 @@
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.lookupRow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: start;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.targetPreview {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--bg-card-strong);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.detailGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.lookupRow {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
403
src/features/operations/pages/CoinAdjustmentPage.jsx
Normal file
403
src/features/operations/pages/CoinAdjustmentPage.jsx
Normal file
@ -0,0 +1,403 @@
|
||||
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
|
||||
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
|
||||
import SearchOutlined from "@mui/icons-material/SearchOutlined";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { useMemo, useState } from "react";
|
||||
import { createCoinAdjustment, listCoinAdjustments, lookupCoinAdjustmentTarget } from "@/features/operations/api";
|
||||
import { useCoinAdjustmentAbilities } from "@/features/operations/permissions.js";
|
||||
import styles from "@/features/operations/operations.module.css";
|
||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||
import {
|
||||
AdminFormAmountField,
|
||||
AdminFormDialog,
|
||||
AdminFormFieldGrid,
|
||||
AdminFormSection,
|
||||
} from "@/shared/ui/AdminFormDialog.jsx";
|
||||
import {
|
||||
AdminActionIconButton,
|
||||
AdminFilterResetButton,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminListToolbar,
|
||||
} 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 { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
import { createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
const adjustmentReasons = ["运营发放", "活动补发", "系统补偿", "异常扣回", "风控扣减"];
|
||||
|
||||
const emptyForm = () => ({
|
||||
amount: "",
|
||||
mode: "increase",
|
||||
reason: adjustmentReasons[0],
|
||||
userKeyword: "",
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: "user",
|
||||
label: "接收用户",
|
||||
render: (item) => <AdjustmentUserCell item={item} />,
|
||||
width: "minmax(260px, 1.35fr)",
|
||||
},
|
||||
{
|
||||
key: "amount",
|
||||
label: "增减数量",
|
||||
render: (item) => <AdjustmentAmount item={item} />,
|
||||
width: "minmax(130px, 0.65fr)",
|
||||
},
|
||||
{
|
||||
key: "reason",
|
||||
label: "发放理由",
|
||||
render: (item) => item.reason || "-",
|
||||
width: "minmax(170px, 0.85fr)",
|
||||
},
|
||||
{
|
||||
key: "operator",
|
||||
label: "发放人",
|
||||
render: (item) => operatorName(item),
|
||||
width: "minmax(150px, 0.8fr)",
|
||||
},
|
||||
{
|
||||
key: "balance",
|
||||
label: "余额",
|
||||
render: (item) => formatNumber(item.availableAfter),
|
||||
width: "minmax(120px, 0.65fr)",
|
||||
},
|
||||
{
|
||||
key: "createdAtMs",
|
||||
label: "时间",
|
||||
render: (item) => formatMillis(item.createdAtMs),
|
||||
width: "minmax(170px, 0.85fr)",
|
||||
},
|
||||
];
|
||||
|
||||
export function CoinAdjustmentPage() {
|
||||
const abilities = useCoinAdjustmentAbilities();
|
||||
const { showToast } = useToast();
|
||||
const [page, setPage] = useState(1);
|
||||
const [userKeyword, setUserKeyword] = useState("");
|
||||
const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" });
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [targetUser, setTargetUser] = useState(null);
|
||||
const [lookupLoading, setLookupLoading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
end_at_ms: timeRange.endMs || "",
|
||||
start_at_ms: timeRange.startMs || "",
|
||||
user_keyword: userKeyword,
|
||||
}),
|
||||
[timeRange.endMs, timeRange.startMs, userKeyword],
|
||||
);
|
||||
const query = usePaginatedQuery({
|
||||
errorMessage: "获取金币增减列表失败",
|
||||
fetcher: listCoinAdjustments,
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
queryKey: ["coin-adjustments", filters, page],
|
||||
});
|
||||
const data = query.data || { items: [], total: 0, page, pageSize };
|
||||
const items = data.items || [];
|
||||
const total = data.total || 0;
|
||||
|
||||
const tableColumns = columns.map((column) =>
|
||||
column.key === "user"
|
||||
? {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "长 ID / 短 ID / 昵称",
|
||||
value: userKeyword,
|
||||
onChange: (value) => {
|
||||
setUserKeyword(value);
|
||||
setPage(1);
|
||||
},
|
||||
}),
|
||||
}
|
||||
: column,
|
||||
);
|
||||
|
||||
const resetFilters = () => {
|
||||
setUserKeyword("");
|
||||
setTimeRange({ endMs: "", startMs: "" });
|
||||
setPage(1);
|
||||
};
|
||||
const changeTimeRange = (nextRange) => {
|
||||
setTimeRange(nextRange);
|
||||
setPage(1);
|
||||
};
|
||||
const openDialog = () => {
|
||||
setForm(emptyForm());
|
||||
setTargetUser(null);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
const closeDialog = () => {
|
||||
if (submitting) {
|
||||
return;
|
||||
}
|
||||
setDialogOpen(false);
|
||||
setTargetUser(null);
|
||||
};
|
||||
const changeForm = (patch) => {
|
||||
setForm((current) => ({ ...current, ...patch }));
|
||||
};
|
||||
const searchTargetUser = async () => {
|
||||
const keyword = form.userKeyword.trim();
|
||||
if (!keyword) {
|
||||
showToast("请输入用户 ID", "error");
|
||||
return;
|
||||
}
|
||||
setLookupLoading(true);
|
||||
try {
|
||||
const user = await lookupCoinAdjustmentTarget(keyword);
|
||||
setTargetUser(user);
|
||||
} catch (err) {
|
||||
setTargetUser(null);
|
||||
showToast(err.message || "查询用户失败", "error");
|
||||
} finally {
|
||||
setLookupLoading(false);
|
||||
}
|
||||
};
|
||||
const submitAdjustment = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!targetUser?.userId) {
|
||||
showToast("请先搜索用户", "error");
|
||||
return;
|
||||
}
|
||||
let payload;
|
||||
try {
|
||||
payload = buildAdjustmentPayload(form, targetUser);
|
||||
} catch (err) {
|
||||
showToast(err.message || "金币增减参数不正确", "error");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await createCoinAdjustment(payload);
|
||||
showToast("金币已调整", "success");
|
||||
setForm(emptyForm());
|
||||
setTargetUser(null);
|
||||
setDialogOpen(false);
|
||||
await query.reload();
|
||||
} catch (err) {
|
||||
showToast(err.message || "金币调整失败", "error");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
actions={
|
||||
abilities.canCreate ? (
|
||||
<AdminActionIconButton label="金币增减" primary onClick={openDialog}>
|
||||
<MonetizationOnOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null
|
||||
}
|
||||
filters={
|
||||
<>
|
||||
<TimeRangeFilter value={timeRange} onChange={changeTimeRange} />
|
||||
<AdminFilterResetButton
|
||||
disabled={!userKeyword && !timeRange.startMs && !timeRange.endMs}
|
||||
onClick={resetFilters}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<DataState error={query.error} loading={query.loading} onRetry={query.reload}>
|
||||
<AdminListBody>
|
||||
<DataTable
|
||||
columns={tableColumns}
|
||||
items={items}
|
||||
minWidth="1010px"
|
||||
pagination={
|
||||
total > 0
|
||||
? {
|
||||
page,
|
||||
pageSize: data.pageSize || pageSize,
|
||||
total,
|
||||
onPageChange: setPage,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
rowKey={(item) => item.entryId}
|
||||
/>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
<AdminFormDialog
|
||||
disabled={!abilities.canCreate}
|
||||
loading={submitting}
|
||||
open={dialogOpen}
|
||||
size="large"
|
||||
submitDisabled={!abilities.canCreate || submitting || !targetUser?.userId}
|
||||
submitLabel="确认"
|
||||
title="金币增减"
|
||||
onClose={closeDialog}
|
||||
onSubmit={submitAdjustment}
|
||||
>
|
||||
<AdminFormSection title="接收用户">
|
||||
<div className={styles.lookupRow}>
|
||||
<TextField
|
||||
disabled={!abilities.canCreate || submitting}
|
||||
label="用户 ID"
|
||||
required
|
||||
value={form.userKeyword}
|
||||
onChange={(event) => {
|
||||
changeForm({ userKeyword: event.target.value });
|
||||
setTargetUser(null);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
searchTargetUser();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
disabled={!abilities.canCreate || lookupLoading || submitting}
|
||||
startIcon={<SearchOutlined fontSize="small" />}
|
||||
onClick={searchTargetUser}
|
||||
>
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
{targetUser ? <TargetUserPreview user={targetUser} /> : null}
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="金币信息">
|
||||
<AdminFormFieldGrid columns="repeat(3, minmax(0, 1fr))">
|
||||
<TextField
|
||||
disabled={!abilities.canCreate || submitting}
|
||||
label="类型"
|
||||
required
|
||||
select
|
||||
value={form.mode}
|
||||
onChange={(event) => changeForm({ mode: event.target.value })}
|
||||
>
|
||||
<MenuItem value="increase">增加</MenuItem>
|
||||
<MenuItem value="decrease">扣减</MenuItem>
|
||||
</TextField>
|
||||
<AdminFormAmountField
|
||||
disabled={!abilities.canCreate || submitting}
|
||||
inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }}
|
||||
label="发放数量"
|
||||
required
|
||||
unit="金币"
|
||||
value={form.amount}
|
||||
onChange={(event) => changeForm({ amount: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={!abilities.canCreate || submitting}
|
||||
label="发放理由"
|
||||
required
|
||||
select
|
||||
value={form.reason}
|
||||
onChange={(event) => changeForm({ reason: event.target.value })}
|
||||
>
|
||||
{adjustmentReasons.map((reason) => (
|
||||
<MenuItem key={reason} value={reason}>
|
||||
{reason}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
</AdminFormDialog>
|
||||
</AdminListPage>
|
||||
);
|
||||
}
|
||||
|
||||
function AdjustmentUserCell({ item }) {
|
||||
const user = item.user || {};
|
||||
const name = user.username || "-";
|
||||
|
||||
return (
|
||||
<div className={styles.identity}>
|
||||
<span className={styles.avatar}>
|
||||
{user.avatar ? <img alt="" src={user.avatar} /> : <PersonOutlineOutlined fontSize="small" />}
|
||||
</span>
|
||||
<span className={styles.identityText}>
|
||||
<span className={styles.primaryText}>{name}</span>
|
||||
<span className={styles.meta}>{userIdText(user, item.userId)}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TargetUserPreview({ user }) {
|
||||
return (
|
||||
<div className={styles.targetPreview}>
|
||||
<span className={styles.avatar}>
|
||||
{user.avatar ? <img alt="" src={user.avatar} /> : <PersonOutlineOutlined fontSize="small" />}
|
||||
</span>
|
||||
<span className={styles.identityText}>
|
||||
<span className={styles.primaryText}>{user.username || "-"}</span>
|
||||
<span className={styles.meta}>{userIdText(user, user.userId)}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdjustmentAmount({ item }) {
|
||||
const delta = Number(item.availableDelta ?? item.amount ?? 0);
|
||||
const expense = delta < 0;
|
||||
const sign = expense ? "-" : "+";
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`${styles.amount} ${styles.adjustmentAmount} ${
|
||||
expense ? styles.amountExpense : styles.amountIncome
|
||||
}`}
|
||||
>
|
||||
{sign}
|
||||
{formatNumber(Math.abs(delta))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function buildAdjustmentPayload(form, targetUser) {
|
||||
const rawAmount = String(form.amount || "").trim();
|
||||
if (!/^[1-9]\d*$/.test(rawAmount)) {
|
||||
throw new Error("请输入金币数量");
|
||||
}
|
||||
const amount = Number(rawAmount);
|
||||
if (!Number.isSafeInteger(amount)) {
|
||||
throw new Error("金币数量过大");
|
||||
}
|
||||
const signedAmount = form.mode === "decrease" ? -amount : amount;
|
||||
const reason = String(form.reason || "").trim();
|
||||
if (!reason) {
|
||||
throw new Error("请选择发放理由");
|
||||
}
|
||||
return {
|
||||
amount: signedAmount,
|
||||
commandId: `coin-adjustment-${Date.now()}`,
|
||||
reason,
|
||||
targetUserId: String(targetUser.userId),
|
||||
};
|
||||
}
|
||||
|
||||
function userIdText(user, fallbackUserId) {
|
||||
const ids = [user.displayUserId, user.userId || fallbackUserId].filter(Boolean);
|
||||
return ids.length ? ids.join(" / ") : "-";
|
||||
}
|
||||
|
||||
function operatorName(item) {
|
||||
const operator = item.operator || {};
|
||||
return operator.name || operator.username || item.operatorUserId || "-";
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return Number(value || 0).toLocaleString("zh-CN");
|
||||
}
|
||||
11
src/features/operations/permissions.js
Normal file
11
src/features/operations/permissions.js
Normal file
@ -0,0 +1,11 @@
|
||||
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||
import { PERMISSIONS } from "@/app/permissions";
|
||||
|
||||
export function useCoinAdjustmentAbilities() {
|
||||
const { can } = useAuth();
|
||||
|
||||
return {
|
||||
canCreate: can(PERMISSIONS.coinAdjustmentCreate),
|
||||
canView: can(PERMISSIONS.coinAdjustmentView),
|
||||
};
|
||||
}
|
||||
@ -9,4 +9,12 @@ export const operationsRoutes = [
|
||||
path: "/operations/coin-ledger",
|
||||
permission: PERMISSIONS.coinLedgerView,
|
||||
},
|
||||
{
|
||||
label: "金币增减",
|
||||
loader: () => import("./pages/CoinAdjustmentPage.jsx").then((module) => module.CoinAdjustmentPage),
|
||||
menuCode: MENU_CODES.operationCoinAdjustment,
|
||||
pageKey: "operation-coin-adjustment",
|
||||
path: "/operations/coin-adjustments",
|
||||
permission: PERMISSIONS.coinAdjustmentView,
|
||||
},
|
||||
];
|
||||
|
||||
62
src/features/region-blocks/api.ts
Normal file
62
src/features/region-blocks/api.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||
import { apiRequest } from "@/shared/api/request";
|
||||
import type { ApiList } from "@/shared/api/types";
|
||||
|
||||
export interface RegionBlockDto {
|
||||
blockId: number;
|
||||
keyword: string;
|
||||
countryCode: string;
|
||||
enabled: boolean;
|
||||
createdAtMs: number;
|
||||
updatedAtMs: number;
|
||||
}
|
||||
|
||||
export interface RegionBlockPayload {
|
||||
keywords: string[];
|
||||
}
|
||||
|
||||
type RawRegionBlock = RegionBlockDto & Record<string, unknown>;
|
||||
|
||||
export function listRegionBlocks(): Promise<ApiList<RegionBlockDto>> {
|
||||
const endpoint = API_ENDPOINTS.listRegionBlocks;
|
||||
return apiRequest<ApiList<RawRegionBlock>>(apiEndpointPath(API_OPERATIONS.listRegionBlocks), {
|
||||
method: endpoint.method,
|
||||
}).then((data) => ({
|
||||
items: (data.items || []).map(normalizeRegionBlock),
|
||||
total: Number(data.total || 0),
|
||||
}));
|
||||
}
|
||||
|
||||
export function replaceRegionBlocks(payload: RegionBlockPayload): Promise<ApiList<RegionBlockDto>> {
|
||||
const endpoint = API_ENDPOINTS.replaceRegionBlocks;
|
||||
return apiRequest<ApiList<RawRegionBlock>, RegionBlockPayload>(
|
||||
apiEndpointPath(API_OPERATIONS.replaceRegionBlocks),
|
||||
{
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
},
|
||||
).then((data) => ({
|
||||
items: (data.items || []).map(normalizeRegionBlock),
|
||||
total: Number(data.total || 0),
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeRegionBlock(item: RawRegionBlock): RegionBlockDto {
|
||||
return {
|
||||
blockId: numberValue(item.blockId ?? item.block_id),
|
||||
keyword: stringValue(item.keyword),
|
||||
countryCode: stringValue(item.countryCode ?? item.country_code),
|
||||
enabled: Boolean(item.enabled),
|
||||
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
||||
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||
};
|
||||
}
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return value === undefined || value === null ? "" : String(value);
|
||||
}
|
||||
|
||||
function numberValue(value: unknown) {
|
||||
const number = Number(value || 0);
|
||||
return Number.isFinite(number) ? number : 0;
|
||||
}
|
||||
150
src/features/region-blocks/hooks/useRegionBlockPage.js
Normal file
150
src/features/region-blocks/hooks/useRegionBlockPage.js
Normal file
@ -0,0 +1,150 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { parseForm, FormValidationError } from "@/shared/forms/validation";
|
||||
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
import { listRegionBlocks, replaceRegionBlocks } from "@/features/region-blocks/api";
|
||||
import { useRegionBlockAbilities } from "@/features/region-blocks/permissions.js";
|
||||
import { regionBlockKeywordSchema, regionBlockReplaceSchema } from "@/features/region-blocks/schema";
|
||||
|
||||
const emptyData = { items: [], total: 0 };
|
||||
const countryCodePattern = /^[A-Za-z]{2,3}$/;
|
||||
|
||||
export function useRegionBlockPage() {
|
||||
const abilities = useRegionBlockAbilities();
|
||||
const { showToast } = useToast();
|
||||
const [keywordInput, setKeywordInput] = useState("");
|
||||
const [draftKeywords, setDraftKeywords] = useState([]);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const {
|
||||
data = emptyData,
|
||||
error,
|
||||
loading,
|
||||
reload,
|
||||
} = useAdminQuery(listRegionBlocks, {
|
||||
errorMessage: "加载地区屏蔽配置失败",
|
||||
initialData: emptyData,
|
||||
queryKey: ["region-blocks"],
|
||||
});
|
||||
const serverKeywords = useMemo(() => (data.items || []).map((item) => item.keyword).filter(Boolean), [data.items]);
|
||||
const serverKeywordKey = serverKeywords.join("\n");
|
||||
const serverBlockByKeyword = useMemo(() => {
|
||||
const blocks = new Map();
|
||||
(data.items || []).forEach((item) => blocks.set(normalizeKey(item.keyword), item));
|
||||
return blocks;
|
||||
}, [data.items]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dirty) {
|
||||
setDraftKeywords(serverKeywords);
|
||||
}
|
||||
}, [dirty, serverKeywordKey, serverKeywords]);
|
||||
|
||||
const draftItems = useMemo(
|
||||
() =>
|
||||
draftKeywords.map((keyword, index) => {
|
||||
const saved = serverBlockByKeyword.get(normalizeKey(keyword));
|
||||
return (
|
||||
saved || {
|
||||
blockId: `draft-${index}-${keyword}`,
|
||||
countryCode: countryCodePattern.test(keyword) ? keyword.toUpperCase() : "",
|
||||
createdAtMs: 0,
|
||||
enabled: true,
|
||||
keyword,
|
||||
updatedAtMs: 0,
|
||||
}
|
||||
);
|
||||
}),
|
||||
[draftKeywords, serverBlockByKeyword],
|
||||
);
|
||||
|
||||
const addKeyword = () => {
|
||||
if (!abilities.canUpdate) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const keyword = parseForm(regionBlockKeywordSchema, keywordInput);
|
||||
if (draftKeywords.some((item) => normalizeKey(item) === normalizeKey(keyword))) {
|
||||
showToast("屏蔽词已存在", "warning");
|
||||
return;
|
||||
}
|
||||
setDraftKeywords([...draftKeywords, normalizeKeyword(keyword)]);
|
||||
setKeywordInput("");
|
||||
setDirty(true);
|
||||
} catch (err) {
|
||||
showValidationError(err, showToast, "屏蔽词参数不正确");
|
||||
}
|
||||
};
|
||||
|
||||
const submitInput = (event) => {
|
||||
event.preventDefault();
|
||||
addKeyword();
|
||||
};
|
||||
|
||||
const removeKeyword = (keyword) => {
|
||||
if (!abilities.canUpdate) {
|
||||
return;
|
||||
}
|
||||
setDraftKeywords(draftKeywords.filter((item) => normalizeKey(item) !== normalizeKey(keyword)));
|
||||
setDirty(true);
|
||||
};
|
||||
|
||||
const resetDraft = () => {
|
||||
setDraftKeywords(serverKeywords);
|
||||
setKeywordInput("");
|
||||
setDirty(false);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!abilities.canUpdate || saving) {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = parseForm(regionBlockReplaceSchema, { keywords: draftKeywords });
|
||||
const saved = await replaceRegionBlocks(payload);
|
||||
setDraftKeywords((saved.items || []).map((item) => item.keyword).filter(Boolean));
|
||||
setDirty(false);
|
||||
showToast("地区屏蔽配置已保存", "success");
|
||||
await reload();
|
||||
} catch (err) {
|
||||
showValidationError(err, showToast, "保存地区屏蔽配置失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
addKeyword,
|
||||
dirty,
|
||||
draftItems,
|
||||
error,
|
||||
keywordInput,
|
||||
loading,
|
||||
reload,
|
||||
removeKeyword,
|
||||
resetDraft,
|
||||
save,
|
||||
saving,
|
||||
setKeywordInput,
|
||||
submitInput,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeKeyword(value) {
|
||||
const keyword = value.trim();
|
||||
return countryCodePattern.test(keyword) ? keyword.toUpperCase() : keyword;
|
||||
}
|
||||
|
||||
function normalizeKey(value) {
|
||||
return String(value || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function showValidationError(err, showToast, fallback) {
|
||||
if (err instanceof FormValidationError) {
|
||||
showToast(err.message, "error");
|
||||
return;
|
||||
}
|
||||
showToast(err?.message || fallback, "error");
|
||||
}
|
||||
135
src/features/region-blocks/pages/RegionBlockPage.jsx
Normal file
135
src/features/region-blocks/pages/RegionBlockPage.jsx
Normal file
@ -0,0 +1,135 @@
|
||||
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||
import RestartAltOutlined from "@mui/icons-material/RestartAltOutlined";
|
||||
import SaveOutlined from "@mui/icons-material/SaveOutlined";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
||||
import { AdminListBody, AdminListPage, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import { useRegionBlockPage } from "@/features/region-blocks/hooks/useRegionBlockPage.js";
|
||||
import styles from "@/features/region-blocks/region-blocks.module.css";
|
||||
|
||||
export function RegionBlockPage() {
|
||||
const page = useRegionBlockPage();
|
||||
const columns = buildColumns(page);
|
||||
|
||||
return (
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
disabled={!page.dirty || page.saving}
|
||||
startIcon={<RestartAltOutlined fontSize="small" />}
|
||||
onClick={page.resetDraft}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
<Button
|
||||
disabled={page.loading || page.saving}
|
||||
startIcon={<RefreshOutlined fontSize="small" />}
|
||||
onClick={page.reload}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!page.abilities.canUpdate || !page.dirty || page.saving}
|
||||
startIcon={<SaveOutlined fontSize="small" />}
|
||||
variant="primary"
|
||||
onClick={page.save}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
filters={
|
||||
<form className={styles.addForm} onSubmit={page.submitInput}>
|
||||
<TextField
|
||||
className={styles.keywordInput}
|
||||
disabled={!page.abilities.canUpdate || page.saving}
|
||||
label="屏蔽词"
|
||||
placeholder="国家码或国家名称"
|
||||
size="small"
|
||||
value={page.keywordInput}
|
||||
onChange={(event) => page.setKeywordInput(event.target.value)}
|
||||
/>
|
||||
<Button
|
||||
disabled={!page.abilities.canUpdate || page.saving}
|
||||
startIcon={<AddOutlined fontSize="small" />}
|
||||
type="submit"
|
||||
variant="primary"
|
||||
>
|
||||
添加
|
||||
</Button>
|
||||
</form>
|
||||
}
|
||||
/>
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<AdminListBody>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
items={page.draftItems}
|
||||
minWidth="880px"
|
||||
rowKey={(item) => item.blockId || item.keyword}
|
||||
/>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
</AdminListPage>
|
||||
);
|
||||
}
|
||||
|
||||
function buildColumns(page) {
|
||||
return [
|
||||
{
|
||||
key: "keyword",
|
||||
label: "屏蔽词",
|
||||
width: "minmax(220px, 1.2fr)",
|
||||
render: (item) => <KeywordCell item={item} />,
|
||||
},
|
||||
{
|
||||
key: "countryCode",
|
||||
label: "国家码",
|
||||
width: "minmax(120px, 0.6fr)",
|
||||
render: (item) => item.countryCode || "保存后解析",
|
||||
},
|
||||
{
|
||||
key: "updatedAtMs",
|
||||
label: "更新时间",
|
||||
width: "minmax(180px, 0.85fr)",
|
||||
render: (item) => (item.updatedAtMs ? formatMillis(item.updatedAtMs) : "-"),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
width: "minmax(96px, 0.4fr)",
|
||||
render: (item) => (
|
||||
<Tooltip arrow title="移除">
|
||||
<span>
|
||||
<IconButton
|
||||
disabled={!page.abilities.canUpdate || page.saving}
|
||||
label="移除"
|
||||
tone="danger"
|
||||
onClick={() => page.removeKeyword(item.keyword)}
|
||||
>
|
||||
<DeleteOutlineOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function KeywordCell({ item }) {
|
||||
return (
|
||||
<div className={styles.keywordCell}>
|
||||
<span className={styles.keyword}>{item.keyword}</span>
|
||||
<span className={styles.meta}>{item.enabled ? "启用" : "停用"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
src/features/region-blocks/permissions.js
Normal file
11
src/features/region-blocks/permissions.js
Normal file
@ -0,0 +1,11 @@
|
||||
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||
import { PERMISSIONS } from "@/app/permissions";
|
||||
|
||||
export function useRegionBlockAbilities() {
|
||||
const { can } = useAuth();
|
||||
|
||||
return {
|
||||
canUpdate: can(PERMISSIONS.regionBlockUpdate),
|
||||
canView: can(PERMISSIONS.regionBlockView),
|
||||
};
|
||||
}
|
||||
41
src/features/region-blocks/region-blocks.module.css
Normal file
41
src/features/region-blocks/region-blocks.module.css
Normal file
@ -0,0 +1,41 @@
|
||||
.addForm {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.keywordInput {
|
||||
flex: 0 1 320px;
|
||||
}
|
||||
|
||||
.keywordCell {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.keyword {
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
font-weight: 650;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--admin-font-size);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.addForm {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.keywordInput {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
12
src/features/region-blocks/routes.js
Normal file
12
src/features/region-blocks/routes.js
Normal file
@ -0,0 +1,12 @@
|
||||
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
|
||||
|
||||
export const regionBlockRoutes = [
|
||||
{
|
||||
label: "地区屏蔽",
|
||||
loader: () => import("./pages/RegionBlockPage.jsx").then((module) => module.RegionBlockPage),
|
||||
menuCode: MENU_CODES.appUserRegionBlocks,
|
||||
pageKey: "app-user-region-blocks",
|
||||
path: "/app/users/region-blocks",
|
||||
permission: PERMISSIONS.regionBlockView,
|
||||
},
|
||||
];
|
||||
13
src/features/region-blocks/schema.ts
Normal file
13
src/features/region-blocks/schema.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const regionBlockKeywordSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "请输入屏蔽词")
|
||||
.max(128, "屏蔽词不能超过 128 个字符");
|
||||
|
||||
export const regionBlockReplaceSchema = z.object({
|
||||
keywords: z.array(regionBlockKeywordSchema).max(200, "最多配置 200 个屏蔽词"),
|
||||
});
|
||||
|
||||
export type RegionBlockReplaceForm = z.infer<typeof regionBlockReplaceSchema>;
|
||||
@ -0,0 +1,123 @@
|
||||
import SaveOutlined from "@mui/icons-material/SaveOutlined";
|
||||
import Drawer from "@mui/material/Drawer";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
|
||||
const rewardTypeOptions = [
|
||||
["coin", "金币"],
|
||||
["resource_group", "资源组"],
|
||||
];
|
||||
|
||||
export function RegistrationRewardConfigDrawer({
|
||||
abilities,
|
||||
configLoading,
|
||||
configSaving,
|
||||
form,
|
||||
onClose,
|
||||
onSubmit,
|
||||
open,
|
||||
resourceGroups,
|
||||
setForm,
|
||||
}) {
|
||||
const disabled = !abilities.canUpdate || configLoading || configSaving;
|
||||
|
||||
return (
|
||||
<Drawer anchor="right" open={open} onClose={configSaving ? undefined : onClose}>
|
||||
<form className="form-drawer" onSubmit={onSubmit}>
|
||||
<h2>注册奖励配置</h2>
|
||||
<section className="form-drawer__section">
|
||||
<div className="form-drawer__section-title">发放状态</div>
|
||||
<div className="form-drawer__grid">
|
||||
<AdminSwitch
|
||||
checked={form.enabled}
|
||||
checkedLabel="开启"
|
||||
disabled={disabled}
|
||||
label="注册奖励状态"
|
||||
uncheckedLabel="关闭"
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, enabled: event.target.checked }))
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
inputProps={{ min: 0 }}
|
||||
label="每日限制份数"
|
||||
type="number"
|
||||
value={form.dailyLimit}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, dailyLimit: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<section className="form-drawer__section">
|
||||
<div className="form-drawer__section-title">奖励内容</div>
|
||||
<div className="form-drawer__grid">
|
||||
<TextField
|
||||
select
|
||||
disabled={disabled}
|
||||
label="奖励类型"
|
||||
value={form.rewardType}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, rewardType: event.target.value }))
|
||||
}
|
||||
>
|
||||
{rewardTypeOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
{form.rewardType === "resource_group" ? (
|
||||
<TextField
|
||||
select
|
||||
disabled={disabled}
|
||||
label="资源组"
|
||||
value={form.resourceGroupId}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
resourceGroupId: event.target.value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<MenuItem value="">请选择资源组</MenuItem>
|
||||
{resourceGroups.map((group) => (
|
||||
<MenuItem key={group.groupId} value={String(group.groupId)}>
|
||||
{group.name || group.groupCode || group.groupId}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
) : (
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
inputProps={{ min: 1 }}
|
||||
label="金币数量"
|
||||
type="number"
|
||||
value={form.coinAmount}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, coinAmount: event.target.value }))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<div className="form-drawer__actions">
|
||||
<Button disabled={configSaving} type="button" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={disabled}
|
||||
startIcon={<SaveOutlined fontSize="small" />}
|
||||
type="submit"
|
||||
variant="primary"
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,83 @@
|
||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { AdminActionIconButton } from "@/shared/ui/AdminListLayout.jsx";
|
||||
import styles from "@/features/registration-reward/registration-reward.module.css";
|
||||
|
||||
export function RegistrationRewardConfigSummary({
|
||||
canUpdate,
|
||||
config,
|
||||
configLoading,
|
||||
onEdit,
|
||||
onRefresh,
|
||||
resourceGroups,
|
||||
}) {
|
||||
return (
|
||||
<section className={styles.summaryPanel}>
|
||||
<div className={styles.summaryHeader}>
|
||||
<span className={styles.title}>注册奖励配置</span>
|
||||
</div>
|
||||
<div className={styles.summaryItems}>
|
||||
<SummaryItem label="发放状态">
|
||||
<ConfigStatus config={config} loading={configLoading} />
|
||||
</SummaryItem>
|
||||
<SummaryItem label="每日限制">{config ? formatNumber(config.dailyLimit) : configLoading ? "加载中" : "-"}</SummaryItem>
|
||||
<SummaryItem label="奖励">{config ? rewardLabel(config, resourceGroups) : configLoading ? "加载中" : "-"}</SummaryItem>
|
||||
</div>
|
||||
<div className={styles.summaryActions}>
|
||||
<AdminActionIconButton disabled={configLoading} label="刷新配置" type="button" onClick={onRefresh}>
|
||||
<RefreshOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
<Button
|
||||
disabled={!canUpdate || configLoading || !config}
|
||||
startIcon={<EditOutlined fontSize="small" />}
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={onEdit}
|
||||
>
|
||||
编辑配置
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryItem({ children, label }) {
|
||||
return (
|
||||
<div className={styles.summaryItem}>
|
||||
<span className={styles.summaryLabel}>{label}</span>
|
||||
<span className={styles.summaryValue}>{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigStatus({ config, loading }) {
|
||||
if (!config) {
|
||||
return (
|
||||
<span className="status-badge status-badge--stopped">
|
||||
<span className="status-point" />
|
||||
{loading ? "加载中" : "-"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={["status-badge", config.enabled ? "status-badge--running" : "status-badge--stopped"].join(" ")}>
|
||||
<span className="status-point" />
|
||||
{config.enabled ? "已开启" : "已关闭"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function rewardLabel(config, resourceGroups) {
|
||||
if (config.rewardType === "resource_group") {
|
||||
const group = resourceGroups.find((item) => Number(item.groupId) === Number(config.resourceGroupId));
|
||||
return `资源组 / ${group?.name || group?.groupCode || (config.resourceGroupId ? `#${config.resourceGroupId}` : "-")}`;
|
||||
}
|
||||
|
||||
return `${formatNumber(config.coinAmount)} 金币`;
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
|
||||
}
|
||||
@ -23,6 +23,8 @@ const emptyForm = {
|
||||
export function useRegistrationRewardPage() {
|
||||
const abilities = useRegistrationRewardAbilities();
|
||||
const { showToast } = useToast();
|
||||
const [config, setConfig] = useState(null);
|
||||
const [configDrawerOpen, setConfigDrawerOpen] = useState(false);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [configLoading, setConfigLoading] = useState(false);
|
||||
const [configSaving, setConfigSaving] = useState(false);
|
||||
@ -52,6 +54,7 @@ export function useRegistrationRewardPage() {
|
||||
getRegistrationRewardConfig(),
|
||||
listResourceGroups({ page: 1, page_size: 100, status: "active" }),
|
||||
]);
|
||||
setConfig(config);
|
||||
setForm(formFromConfig(config));
|
||||
setResourceGroups(groups.items || []);
|
||||
} catch (err) {
|
||||
@ -65,6 +68,22 @@ export function useRegistrationRewardPage() {
|
||||
void reloadConfig();
|
||||
}, [reloadConfig]);
|
||||
|
||||
const openConfigDrawer = useCallback(() => {
|
||||
if (config) {
|
||||
setForm(formFromConfig(config));
|
||||
}
|
||||
setConfigDrawerOpen(true);
|
||||
}, [config]);
|
||||
|
||||
const closeConfigDrawer = useCallback(() => {
|
||||
setConfigDrawerOpen(false);
|
||||
if (config) {
|
||||
setForm(formFromConfig(config));
|
||||
return;
|
||||
}
|
||||
setForm(emptyForm);
|
||||
}, [config]);
|
||||
|
||||
const changeQuery = (value) => {
|
||||
setQuery(value);
|
||||
setPage(1);
|
||||
@ -89,7 +108,9 @@ export function useRegistrationRewardPage() {
|
||||
setConfigSaving(true);
|
||||
try {
|
||||
const saved = await updateRegistrationRewardConfig(payloadFromForm(form));
|
||||
setConfig(saved);
|
||||
setForm(formFromConfig(saved));
|
||||
setConfigDrawerOpen(false);
|
||||
showToast("注册奖励配置已保存", "success");
|
||||
await reloadClaims();
|
||||
} catch (err) {
|
||||
@ -106,9 +127,13 @@ export function useRegistrationRewardPage() {
|
||||
claims,
|
||||
claimsError,
|
||||
claimsLoading,
|
||||
closeConfigDrawer,
|
||||
config,
|
||||
configDrawerOpen,
|
||||
configLoading,
|
||||
configSaving,
|
||||
form,
|
||||
openConfigDrawer,
|
||||
page,
|
||||
query,
|
||||
reloadClaims,
|
||||
|
||||
@ -1,28 +1,14 @@
|
||||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||
import SaveOutlined from "@mui/icons-material/SaveOutlined";
|
||||
import Avatar from "@mui/material/Avatar";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import {
|
||||
AdminActionIconButton,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminListToolbar,
|
||||
} from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { AdminListBody, AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import { RegistrationRewardConfigDrawer } from "@/features/registration-reward/components/RegistrationRewardConfigDrawer.jsx";
|
||||
import { RegistrationRewardConfigSummary } from "@/features/registration-reward/components/RegistrationRewardConfigSummary.jsx";
|
||||
import { useRegistrationRewardPage } from "@/features/registration-reward/hooks/useRegistrationRewardPage.js";
|
||||
import styles from "@/features/registration-reward/registration-reward.module.css";
|
||||
|
||||
const rewardTypeOptions = [
|
||||
["coin", "金币"],
|
||||
["resource_group", "资源组"],
|
||||
];
|
||||
|
||||
const claimStatusOptions = [
|
||||
["pending", "发放中"],
|
||||
["granted", "已发放"],
|
||||
@ -103,117 +89,13 @@ export function RegistrationRewardPage() {
|
||||
|
||||
return (
|
||||
<AdminListPage>
|
||||
<form className={styles.configPanel} onSubmit={page.submitConfig}>
|
||||
<div className={styles.configHeader}>
|
||||
<div className={styles.stack}>
|
||||
<span className={styles.title}>注册奖励配置</span>
|
||||
<span className={styles.meta}>注册成功后由服务端自动发放</span>
|
||||
</div>
|
||||
<div className={styles.configActions}>
|
||||
<AdminActionIconButton
|
||||
disabled={page.configLoading}
|
||||
label="刷新配置"
|
||||
type="button"
|
||||
onClick={page.reloadConfig}
|
||||
>
|
||||
<RefreshOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
<Button
|
||||
disabled={!page.abilities.canUpdate || page.configLoading || page.configSaving}
|
||||
startIcon={<SaveOutlined fontSize="small" />}
|
||||
type="submit"
|
||||
variant="primary"
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.configSections}>
|
||||
<section className={styles.configSection}>
|
||||
<div className={styles.configSectionTitle}>发放状态</div>
|
||||
<div className={styles.configGrid}>
|
||||
<AdminSwitch
|
||||
checked={page.form.enabled}
|
||||
checkedLabel="开启"
|
||||
disabled={!page.abilities.canUpdate || page.configLoading}
|
||||
label="注册奖励状态"
|
||||
uncheckedLabel="关闭"
|
||||
onChange={(event) =>
|
||||
page.setForm((current) => ({ ...current, enabled: event.target.checked }))
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate || page.configLoading}
|
||||
inputProps={{ min: 0 }}
|
||||
label="每日限制份数"
|
||||
type="number"
|
||||
value={page.form.dailyLimit}
|
||||
onChange={(event) =>
|
||||
page.setForm((current) => ({ ...current, dailyLimit: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<section className={styles.configSection}>
|
||||
<div className={styles.configSectionTitle}>奖励内容</div>
|
||||
<div className={styles.configGrid}>
|
||||
<TextField
|
||||
select
|
||||
disabled={!page.abilities.canUpdate || page.configLoading}
|
||||
label="奖励类型"
|
||||
value={page.form.rewardType}
|
||||
onChange={(event) =>
|
||||
page.setForm((current) => ({ ...current, rewardType: event.target.value }))
|
||||
}
|
||||
>
|
||||
{rewardTypeOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
{page.form.rewardType === "resource_group" ? (
|
||||
<TextField
|
||||
select
|
||||
disabled={!page.abilities.canUpdate || page.configLoading}
|
||||
label="资源组"
|
||||
value={page.form.resourceGroupId}
|
||||
onChange={(event) =>
|
||||
page.setForm((current) => ({
|
||||
...current,
|
||||
resourceGroupId: event.target.value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<MenuItem value="">请选择资源组</MenuItem>
|
||||
{page.resourceGroups.map((group) => (
|
||||
<MenuItem key={group.groupId} value={String(group.groupId)}>
|
||||
{group.name || group.groupCode || group.groupId}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
) : (
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate || page.configLoading}
|
||||
inputProps={{ min: 1 }}
|
||||
label="金币数量"
|
||||
type="number"
|
||||
value={page.form.coinAmount}
|
||||
onChange={(event) =>
|
||||
page.setForm((current) => ({ ...current, coinAmount: event.target.value }))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</form>
|
||||
<AdminListToolbar
|
||||
actions={
|
||||
<AdminActionIconButton label="刷新记录" type="button" onClick={page.reloadClaims}>
|
||||
<RefreshOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
}
|
||||
<RegistrationRewardConfigSummary
|
||||
canUpdate={page.abilities.canUpdate}
|
||||
config={page.config}
|
||||
configLoading={page.configLoading}
|
||||
resourceGroups={page.resourceGroups}
|
||||
onEdit={page.openConfigDrawer}
|
||||
onRefresh={page.reloadConfig}
|
||||
/>
|
||||
<DataState error={page.claimsError} loading={page.claimsLoading} onRetry={page.reloadClaims}>
|
||||
<AdminListBody>
|
||||
@ -235,6 +117,17 @@ export function RegistrationRewardPage() {
|
||||
/>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
<RegistrationRewardConfigDrawer
|
||||
abilities={page.abilities}
|
||||
configLoading={page.configLoading}
|
||||
configSaving={page.configSaving}
|
||||
form={page.form}
|
||||
open={page.configDrawerOpen}
|
||||
resourceGroups={page.resourceGroups}
|
||||
setForm={page.setForm}
|
||||
onClose={page.closeConfigDrawer}
|
||||
onSubmit={page.submitConfig}
|
||||
/>
|
||||
</AdminListPage>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,55 +1,70 @@
|
||||
.configPanel {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.configHeader {
|
||||
.summaryPanel {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
gap: var(--space-5);
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.configActions {
|
||||
.summaryHeader {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.summaryActions {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.summaryItems {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-7);
|
||||
}
|
||||
|
||||
.summaryItem {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.configSections {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
.summaryLabel {
|
||||
flex: 0 0 auto;
|
||||
overflow: hidden;
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--admin-font-size);
|
||||
font-weight: 650;
|
||||
line-height: var(--admin-line-height);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.configSection {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.configSection + .configSection {
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.configSectionTitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
.summaryValue {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
line-height: 18px;
|
||||
line-height: var(--admin-line-height);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.configGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.switchField {
|
||||
min-height: var(--control-height);
|
||||
margin: 0;
|
||||
.summaryValue > :global(.status-badge) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.identity {
|
||||
@ -83,12 +98,27 @@
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.configHeader {
|
||||
.summaryPanel {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.configGrid {
|
||||
grid-template-columns: 1fr;
|
||||
.summaryHeader {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.summaryItems {
|
||||
width: 100%;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.summaryItem {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.summaryActions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { setAccessToken } from "@/shared/api/request";
|
||||
import {
|
||||
createEmojiPack,
|
||||
createGift,
|
||||
createResource,
|
||||
createResourceGroup,
|
||||
@ -12,6 +13,8 @@ import {
|
||||
enableResourceGroup,
|
||||
grantResource,
|
||||
grantResourceGroup,
|
||||
listEmojiPackCategories,
|
||||
listEmojiPacks,
|
||||
listResourceGrants,
|
||||
listGifts,
|
||||
listResources,
|
||||
@ -212,3 +215,39 @@ test("resource APIs use generated admin paths", async () => {
|
||||
expect(grantGroupInit?.method).toBe("POST");
|
||||
expect(JSON.parse(String(grantGroupInit?.body))).toMatchObject({ groupId: 22, targetUserId: 1001 });
|
||||
});
|
||||
|
||||
test("emoji pack APIs use generated admin paths", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response(JSON.stringify({ code: 0, data: [] }))),
|
||||
);
|
||||
|
||||
await listEmojiPackCategories();
|
||||
await listEmojiPacks({ page: 1, page_size: 10, status: "active" });
|
||||
await createEmojiPack({
|
||||
animationUrl: "https://media.haiyihy.com/emoji/wave.svga",
|
||||
category: "热门",
|
||||
coverUrl: "https://media.haiyihy.com/emoji/wave.png",
|
||||
name: "Wave",
|
||||
pricingType: "paid",
|
||||
regionIds: [0],
|
||||
sortOrder: 0,
|
||||
});
|
||||
|
||||
const [categoryUrl, categoryInit] = vi.mocked(fetch).mock.calls[0];
|
||||
const [listUrl, listInit] = vi.mocked(fetch).mock.calls[1];
|
||||
const [createUrl, createInit] = vi.mocked(fetch).mock.calls[2];
|
||||
|
||||
expect(String(categoryUrl)).toContain("/api/v1/admin/emoji-packs/categories");
|
||||
expect(categoryInit?.method).toBe("GET");
|
||||
expect(String(listUrl)).toContain("/api/v1/admin/emoji-packs?");
|
||||
expect(String(listUrl)).toContain("status=active");
|
||||
expect(listInit?.method).toBe("GET");
|
||||
expect(String(createUrl)).toContain("/api/v1/admin/emoji-packs");
|
||||
expect(createInit?.method).toBe("POST");
|
||||
expect(JSON.parse(String(createInit?.body))).toMatchObject({
|
||||
category: "热门",
|
||||
name: "Wave",
|
||||
pricingType: "paid",
|
||||
});
|
||||
});
|
||||
|
||||
@ -25,6 +25,7 @@ export interface ResourceDto {
|
||||
|
||||
export interface ResourcePayload {
|
||||
amount: number;
|
||||
animationUrl?: string;
|
||||
assetUrl: string;
|
||||
name: string;
|
||||
previewUrl: string;
|
||||
@ -34,6 +35,32 @@ export interface ResourcePayload {
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface EmojiPackDto {
|
||||
appCode?: string;
|
||||
resourceId: number;
|
||||
resourceCode?: string;
|
||||
name?: string;
|
||||
status?: string;
|
||||
category?: string;
|
||||
pricingType?: string;
|
||||
coverUrl?: string;
|
||||
animationUrl?: string;
|
||||
regionIds?: number[];
|
||||
sortOrder?: number;
|
||||
createdAtMs?: number;
|
||||
updatedAtMs?: number;
|
||||
}
|
||||
|
||||
export interface EmojiPackPayload {
|
||||
animationUrl: string;
|
||||
category: string;
|
||||
coverUrl: string;
|
||||
name: string;
|
||||
pricingType: string;
|
||||
regionIds: number[];
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface ResourceGroupItemDto {
|
||||
groupItemId: number;
|
||||
itemType?: string;
|
||||
@ -124,6 +151,13 @@ export interface ResourceGrantOperatorDto {
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface ResourceGrantUserDto {
|
||||
avatar?: string;
|
||||
displayUserId?: string;
|
||||
userId?: number | string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface ResourceGrantDto {
|
||||
appCode?: string;
|
||||
commandId?: string;
|
||||
@ -137,7 +171,8 @@ export interface ResourceGrantDto {
|
||||
operatorUserId?: number;
|
||||
reason?: string;
|
||||
status?: string;
|
||||
targetUserId?: number;
|
||||
targetUser?: ResourceGrantUserDto;
|
||||
targetUserId?: number | string;
|
||||
updatedAtMs?: number;
|
||||
}
|
||||
|
||||
@ -295,6 +330,29 @@ export function disableGift(giftId: string): Promise<GiftDto> {
|
||||
});
|
||||
}
|
||||
|
||||
export function listEmojiPacks(query: PageQuery = {}): Promise<ApiPage<EmojiPackDto>> {
|
||||
const endpoint = API_ENDPOINTS.listEmojiPacks;
|
||||
return apiRequest<ApiPage<EmojiPackDto>>(apiEndpointPath(API_OPERATIONS.listEmojiPacks), {
|
||||
method: endpoint.method,
|
||||
query,
|
||||
});
|
||||
}
|
||||
|
||||
export function listEmojiPackCategories(): Promise<string[]> {
|
||||
const endpoint = API_ENDPOINTS.listEmojiPackCategories;
|
||||
return apiRequest<string[]>(apiEndpointPath(API_OPERATIONS.listEmojiPackCategories), {
|
||||
method: endpoint.method,
|
||||
});
|
||||
}
|
||||
|
||||
export function createEmojiPack(payload: EmojiPackPayload): Promise<EmojiPackDto> {
|
||||
const endpoint = API_ENDPOINTS.createEmojiPack;
|
||||
return apiRequest<EmojiPackDto, EmojiPackPayload>(apiEndpointPath(API_OPERATIONS.createEmojiPack), {
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
});
|
||||
}
|
||||
|
||||
export function listResourceGrants(query: PageQuery = {}): Promise<ApiPage<ResourceGrantDto>> {
|
||||
const endpoint = API_ENDPOINTS.listResourceGrants;
|
||||
return apiRequest<ApiPage<ResourceGrantDto>>(apiEndpointPath(API_OPERATIONS.listResourceGrants), {
|
||||
|
||||
@ -23,6 +23,11 @@ export const resourceGrantSubjectLabels = {
|
||||
resource_group: "资源组",
|
||||
};
|
||||
|
||||
export const emojiPackPricingLabels = {
|
||||
free: "免费",
|
||||
paid: "付费",
|
||||
};
|
||||
|
||||
export const resourceTypeFilters = [
|
||||
["", "全部类型"],
|
||||
["avatar_frame", "头像框"],
|
||||
@ -33,6 +38,9 @@ export const resourceTypeFilters = [
|
||||
["badge", "徽章"],
|
||||
["floating_screen", "飘屏"],
|
||||
["gift", "礼物"],
|
||||
["mic_seat_icon", "麦位图标"],
|
||||
["mic_seat_animation", "麦位动效"],
|
||||
["emoji_pack", "表情包"],
|
||||
];
|
||||
|
||||
export const resourceTypeLabels = Object.fromEntries(resourceTypeFilters.filter(([value]) => value));
|
||||
|
||||
@ -4,6 +4,7 @@ import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
|
||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
import {
|
||||
createEmojiPack,
|
||||
createGift,
|
||||
createResource,
|
||||
createResourceGroup,
|
||||
@ -15,6 +16,8 @@ import {
|
||||
enableResourceGroup,
|
||||
grantResource,
|
||||
grantResourceGroup,
|
||||
listEmojiPackCategories,
|
||||
listEmojiPacks,
|
||||
listGifts,
|
||||
listResourceGrants,
|
||||
listResourceGroups,
|
||||
@ -25,6 +28,7 @@ import {
|
||||
} from "@/features/resources/api";
|
||||
import { useResourceAbilities } from "@/features/resources/permissions.js";
|
||||
import {
|
||||
emojiPackFormSchema,
|
||||
giftFormSchema,
|
||||
resourceFormSchema,
|
||||
resourceGrantFormSchema,
|
||||
@ -35,7 +39,7 @@ const pageSize = 10;
|
||||
const dayMillis = 24 * 60 * 60 * 1000;
|
||||
const emptyData = { items: [], page: 1, pageSize, total: 0 };
|
||||
const emptyResourceForm = (resource = {}) => ({
|
||||
assetUrl: resource.assetUrl || "",
|
||||
animationUrl: resource.animationUrl || resource.assetUrl || "",
|
||||
enabled: resource.status ? resource.status === "active" : true,
|
||||
name: resource.name || "",
|
||||
previewUrl: resource.previewUrl || "",
|
||||
@ -43,6 +47,15 @@ const emptyResourceForm = (resource = {}) => ({
|
||||
resourceType: resource.resourceType || "avatar_frame",
|
||||
walletAssetAmount: resource.walletAssetAmount ? String(resource.walletAssetAmount) : "",
|
||||
});
|
||||
const emptyEmojiPackForm = () => ({
|
||||
animationUrl: "",
|
||||
category: "默认",
|
||||
coverUrl: "",
|
||||
name: "",
|
||||
pricingType: "free",
|
||||
regionIds: ["0"],
|
||||
sortOrder: "0",
|
||||
});
|
||||
const emptyResourceGroupForm = (group = {}) => ({
|
||||
description: group.description || "",
|
||||
enabled: group.status ? group.status === "active" : true,
|
||||
@ -619,6 +632,138 @@ export function useGiftListPage() {
|
||||
};
|
||||
}
|
||||
|
||||
export function useEmojiPackListPage() {
|
||||
const abilities = useResourceAbilities();
|
||||
const { showToast } = useToast();
|
||||
const [query, setQuery] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [activeAction, setActiveAction] = useState("");
|
||||
const [form, setForm] = useState(emptyEmojiPackForm);
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
const [categoryOptions, setCategoryOptions] = useState([]);
|
||||
const [categoryOptionsLoading, setCategoryOptionsLoading] = useState(false);
|
||||
const { loadingRegions, regionOptions } = useRegionOptions();
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
keyword: query,
|
||||
status,
|
||||
}),
|
||||
[query, status],
|
||||
);
|
||||
const result = usePaginatedQuery({
|
||||
errorMessage: "加载表情包列表失败",
|
||||
fetcher: listEmojiPacks,
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
queryKey: ["emoji-packs", filters, page],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
setCategoryOptionsLoading(true);
|
||||
listEmojiPackCategories()
|
||||
.then((options) => {
|
||||
if (!ignore) {
|
||||
setCategoryOptions(mergeEmojiPackCategoryOptions(options));
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!ignore) {
|
||||
showToast(err.message || "加载表情包分类失败", "error");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!ignore) {
|
||||
setCategoryOptionsLoading(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
ignore = true;
|
||||
};
|
||||
}, [showToast]);
|
||||
|
||||
const openCreateEmojiPack = () => {
|
||||
setForm(emptyEmojiPackForm());
|
||||
setActiveAction("create");
|
||||
};
|
||||
|
||||
const closeAction = () => {
|
||||
setActiveAction("");
|
||||
};
|
||||
|
||||
const submitEmojiPack = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!abilities.canCreateEmojiPack) {
|
||||
return;
|
||||
}
|
||||
setLoadingAction("emoji-pack-create");
|
||||
try {
|
||||
const payload = buildEmojiPackPayload(parseForm(emojiPackFormSchema, form));
|
||||
await createEmojiPack(payload);
|
||||
setCategoryOptions((options) => mergeEmojiPackCategoryOptions(options, payload.category));
|
||||
showToast("表情包已创建", "success");
|
||||
closeAction();
|
||||
setForm(emptyEmojiPackForm());
|
||||
await result.reload();
|
||||
} catch (err) {
|
||||
showToast(err.message || "操作失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
const toggleEmojiPack = async (emojiPack, nextEnabled = emojiPack.status !== "active") => {
|
||||
if (!abilities.canUpdate || !emojiPack?.resourceId) {
|
||||
return;
|
||||
}
|
||||
if ((emojiPack.status === "active") === nextEnabled) {
|
||||
return;
|
||||
}
|
||||
setLoadingAction(`emoji-pack-status-${emojiPack.resourceId}`);
|
||||
try {
|
||||
if (nextEnabled) {
|
||||
await enableResource(emojiPack.resourceId);
|
||||
} else {
|
||||
await disableResource(emojiPack.resourceId);
|
||||
}
|
||||
showToast(nextEnabled ? "表情包已启用" : "表情包已禁用", "success");
|
||||
await result.reload();
|
||||
} catch (err) {
|
||||
showToast(err.message || "操作失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setQuery("");
|
||||
setStatus("");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return {
|
||||
...sharedPageState({ page, query, result, setPage, setQuery }),
|
||||
abilities,
|
||||
activeAction,
|
||||
categoryOptions,
|
||||
categoryOptionsLoading,
|
||||
changeStatus: resetSetter(setStatus, setPage),
|
||||
closeAction,
|
||||
form,
|
||||
loadingAction,
|
||||
loadingRegions,
|
||||
openCreateEmojiPack,
|
||||
regionOptions,
|
||||
resetFilters,
|
||||
setForm,
|
||||
status,
|
||||
submitEmojiPack,
|
||||
toggleEmojiPack,
|
||||
};
|
||||
}
|
||||
|
||||
export function useResourceGrantListPage() {
|
||||
const abilities = useResourceAbilities();
|
||||
const { showToast } = useToast();
|
||||
@ -759,9 +904,11 @@ function resetSetter(setValue, setPage) {
|
||||
function buildResourcePayload(form) {
|
||||
const resourceType = form.resourceType;
|
||||
const isCoin = resourceType === "coin";
|
||||
const animationUrl = form.animationUrl.trim();
|
||||
return {
|
||||
amount: isCoin ? Number(form.walletAssetAmount) : 0,
|
||||
assetUrl: form.assetUrl.trim(),
|
||||
animationUrl,
|
||||
assetUrl: animationUrl,
|
||||
name: form.name.trim(),
|
||||
previewUrl: form.previewUrl.trim(),
|
||||
resourceCode: form.resourceCode.trim(),
|
||||
@ -771,6 +918,36 @@ function buildResourcePayload(form) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildEmojiPackPayload(form) {
|
||||
const regionIds = (form.regionIds || [])
|
||||
.map(Number)
|
||||
.filter((regionId) => Number.isInteger(regionId) && regionId >= 0);
|
||||
return {
|
||||
animationUrl: form.animationUrl.trim(),
|
||||
category: form.category.trim(),
|
||||
coverUrl: form.coverUrl.trim(),
|
||||
name: form.name.trim(),
|
||||
pricingType: form.pricingType,
|
||||
regionIds: regionIds.length ? regionIds : [0],
|
||||
sortOrder: Number(form.sortOrder || 0),
|
||||
};
|
||||
}
|
||||
|
||||
function mergeEmojiPackCategoryOptions(options = [], value = "默认") {
|
||||
const merged = new Set(["默认"]);
|
||||
options.forEach((option) => {
|
||||
const category = String(option || "").trim();
|
||||
if (category) {
|
||||
merged.add(category);
|
||||
}
|
||||
});
|
||||
const category = String(value || "").trim();
|
||||
if (category) {
|
||||
merged.add(category);
|
||||
}
|
||||
return Array.from(merged).sort((left, right) => left.localeCompare(right, "zh-Hans-CN"));
|
||||
}
|
||||
|
||||
function buildResourceGroupPayload(form) {
|
||||
return {
|
||||
description: (form.description || "").trim(),
|
||||
|
||||
320
src/features/resources/pages/EmojiPackListPage.jsx
Normal file
320
src/features/resources/pages/EmojiPackListPage.jsx
Normal file
@ -0,0 +1,320 @@
|
||||
import Add from "@mui/icons-material/Add";
|
||||
import ImageOutlined from "@mui/icons-material/ImageOutlined";
|
||||
import Autocomplete from "@mui/material/Autocomplete";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||
import {
|
||||
AdminFormDialog,
|
||||
AdminFormFieldGrid,
|
||||
AdminFormSection,
|
||||
AdminFormSwitchField,
|
||||
} from "@/shared/ui/AdminFormDialog.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { AdminActionIconButton, AdminListBody, AdminListPage, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { MultiValueAutocomplete } from "@/shared/ui/MultiValueAutocomplete.jsx";
|
||||
import { UploadField } from "@/shared/ui/UploadField.jsx";
|
||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import { emojiPackPricingLabels, resourceStatusFilters } from "@/features/resources/constants.js";
|
||||
import { useEmojiPackListPage } from "@/features/resources/hooks/useResourcePages.js";
|
||||
import styles from "@/features/resources/resources.module.css";
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: "emoji",
|
||||
label: "表情包",
|
||||
width: "minmax(260px, 1.4fr)",
|
||||
render: (item) => <EmojiPackIdentity item={item} />,
|
||||
},
|
||||
{
|
||||
key: "animation",
|
||||
label: "动效图",
|
||||
width: "minmax(220px, 1.2fr)",
|
||||
render: (item) => item.animationUrl || "-",
|
||||
},
|
||||
{
|
||||
key: "category",
|
||||
label: "分类 / 类型",
|
||||
width: "minmax(150px, 0.75fr)",
|
||||
render: (item) => <EmojiPackTags item={item} />,
|
||||
},
|
||||
{
|
||||
key: "regions",
|
||||
label: "区域",
|
||||
width: "minmax(180px, 0.9fr)",
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
width: "minmax(90px, 0.45fr)",
|
||||
},
|
||||
{
|
||||
key: "time",
|
||||
label: "更新时间",
|
||||
width: "minmax(170px, 0.8fr)",
|
||||
render: (item) => formatMillis(item.updatedAtMs || item.createdAtMs),
|
||||
},
|
||||
];
|
||||
|
||||
export function EmojiPackListPage() {
|
||||
const page = useEmojiPackListPage();
|
||||
const items = page.data.items || [];
|
||||
const total = page.data.total || 0;
|
||||
const regionOptions = [{ label: "全部区域", value: "0" }, ...page.regionOptions];
|
||||
const regionLabelById = buildRegionLabelMap(regionOptions);
|
||||
const createDisabled = !page.abilities.canCreateEmojiPack || !page.abilities.canUpload;
|
||||
const tableColumns = columns.map((column) =>
|
||||
column.key === "emoji"
|
||||
? {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索表情包名称",
|
||||
value: page.query,
|
||||
onChange: page.changeQuery,
|
||||
}),
|
||||
}
|
||||
: column.key === "status"
|
||||
? {
|
||||
...column,
|
||||
filter: createOptionsColumnFilter({
|
||||
options: resourceStatusFilters,
|
||||
placeholder: "搜索状态",
|
||||
value: page.status,
|
||||
onChange: page.changeStatus,
|
||||
}),
|
||||
render: (item) => <EmojiPackStatusSwitch item={item} page={page} />,
|
||||
}
|
||||
: column.key === "regions"
|
||||
? {
|
||||
...column,
|
||||
render: (item) => <RegionTags regionIds={item.regionIds} regionLabelById={regionLabelById} />,
|
||||
}
|
||||
: column,
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
actions={
|
||||
page.abilities.canCreateEmojiPack ? (
|
||||
<AdminActionIconButton label="添加表情包" primary onClick={page.openCreateEmojiPack}>
|
||||
<Add fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<AdminListBody>
|
||||
<DataTable
|
||||
columns={tableColumns}
|
||||
items={items}
|
||||
minWidth="1120px"
|
||||
pagination={
|
||||
total > 0
|
||||
? {
|
||||
page: page.page,
|
||||
pageSize: page.data.pageSize || 10,
|
||||
total,
|
||||
onPageChange: page.setPage,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
rowKey={(item) => item.resourceId}
|
||||
/>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
<EmojiPackFormDialog
|
||||
categoryOptions={mergeCategoryOptions(page.categoryOptions, page.form.category)}
|
||||
disabled={createDisabled}
|
||||
form={page.form}
|
||||
loadingCategories={page.categoryOptionsLoading}
|
||||
loading={page.loadingAction === "emoji-pack-create"}
|
||||
open={page.activeAction === "create"}
|
||||
regionLabelById={regionLabelById}
|
||||
regionOptions={regionOptions.map((option) => option.value)}
|
||||
setForm={page.setForm}
|
||||
uploadDisabled={!page.abilities.canUpload}
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitEmojiPack}
|
||||
/>
|
||||
</AdminListPage>
|
||||
);
|
||||
}
|
||||
|
||||
function EmojiPackFormDialog({
|
||||
categoryOptions,
|
||||
disabled,
|
||||
form,
|
||||
loadingCategories,
|
||||
loading,
|
||||
onClose,
|
||||
onSubmit,
|
||||
open,
|
||||
regionLabelById,
|
||||
regionOptions,
|
||||
setForm,
|
||||
uploadDisabled,
|
||||
}) {
|
||||
return (
|
||||
<AdminFormDialog
|
||||
loading={loading}
|
||||
open={open}
|
||||
size="large"
|
||||
submitDisabled={disabled || loading}
|
||||
title="添加表情包"
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<AdminFormSection title="基础信息">
|
||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="名称"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(event) => setForm({ ...form, name: event.target.value })}
|
||||
/>
|
||||
<Autocomplete
|
||||
freeSolo
|
||||
disabled={disabled}
|
||||
loading={loadingCategories}
|
||||
options={categoryOptions}
|
||||
value={form.category}
|
||||
onChange={(_, value) => setForm({ ...form, category: normalizeCategoryInput(value) })}
|
||||
onInputChange={(_, value) => setForm({ ...form, category: normalizeCategoryInput(value) })}
|
||||
renderInput={(params) => <TextField {...params} label="分类" required />}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="排序"
|
||||
type="number"
|
||||
value={form.sortOrder}
|
||||
onChange={(event) => setForm({ ...form, sortOrder: event.target.value })}
|
||||
/>
|
||||
<MultiValueAutocomplete
|
||||
disabled={disabled}
|
||||
label="区域"
|
||||
labels={regionLabelById}
|
||||
options={regionOptions}
|
||||
value={form.regionIds}
|
||||
onChange={(regionIds) => setForm({ ...form, regionIds: normalizeSelectedRegions(regionIds) })}
|
||||
/>
|
||||
<AdminFormSwitchField
|
||||
checked={form.pricingType === "free"}
|
||||
checkedLabel="免费"
|
||||
disabled={disabled}
|
||||
label="付费类型"
|
||||
uncheckedLabel="付费"
|
||||
onChange={(checked) => setForm({ ...form, pricingType: checked ? "free" : "paid" })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="素材">
|
||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||
<UploadField
|
||||
disabled={disabled || uploadDisabled}
|
||||
label="封面图"
|
||||
value={form.coverUrl}
|
||||
onChange={(coverUrl) => setForm({ ...form, coverUrl })}
|
||||
/>
|
||||
<UploadField
|
||||
disabled={disabled || uploadDisabled}
|
||||
label="动效图"
|
||||
value={form.animationUrl}
|
||||
onChange={(animationUrl) => setForm({ ...form, animationUrl })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
</AdminFormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function EmojiPackTags({ item }) {
|
||||
const pricingType = item.pricingType || "free";
|
||||
return (
|
||||
<div className="admin-tag-list">
|
||||
<span className="admin-tag">{item.category || "默认"}</span>
|
||||
<span className="admin-tag">{emojiPackPricingLabels[pricingType] || pricingType}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmojiPackStatusSwitch({ item, page }) {
|
||||
const checked = item.status === "active";
|
||||
return (
|
||||
<AdminSwitch
|
||||
checked={checked}
|
||||
disabled={!page.abilities.canUpdate || page.loadingAction === `emoji-pack-status-${item.resourceId}`}
|
||||
inputProps={{ "aria-label": checked ? "禁用表情包" : "启用表情包" }}
|
||||
onChange={(event) => page.toggleEmojiPack(item, event.target.checked)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EmojiPackIdentity({ item }) {
|
||||
const preview = imageURL(item.coverUrl);
|
||||
return (
|
||||
<div className={styles.identity}>
|
||||
<span className={styles.thumb}>
|
||||
{preview ? <img src={preview} alt="" /> : <ImageOutlined fontSize="small" />}
|
||||
</span>
|
||||
<div className={styles.identityText}>
|
||||
<span className={styles.name}>{item.name || "-"}</span>
|
||||
<span className={styles.meta}>{item.resourceCode || item.resourceId}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RegionTags({ regionIds = [], regionLabelById }) {
|
||||
const values = regionIds.length ? regionIds.map(String) : ["0"];
|
||||
return (
|
||||
<div className="admin-tag-list">
|
||||
{values.map((value) => (
|
||||
<span className="admin-tag" key={value}>
|
||||
{regionLabelById[value] || value}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeSelectedRegions(regionIds) {
|
||||
const values = (regionIds || []).map(String);
|
||||
if (!values.length || values.includes("0")) {
|
||||
return ["0"];
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function normalizeCategoryInput(value) {
|
||||
return String(value || "").trimStart();
|
||||
}
|
||||
|
||||
function mergeCategoryOptions(options = [], value = "默认") {
|
||||
const merged = new Set(["默认"]);
|
||||
options.forEach((option) => {
|
||||
const category = String(option || "").trim();
|
||||
if (category) {
|
||||
merged.add(category);
|
||||
}
|
||||
});
|
||||
const category = String(value || "").trim();
|
||||
if (category) {
|
||||
merged.add(category);
|
||||
}
|
||||
return Array.from(merged).sort((left, right) => left.localeCompare(right, "zh-Hans-CN"));
|
||||
}
|
||||
|
||||
function buildRegionLabelMap(regionOptions) {
|
||||
return Object.fromEntries(regionOptions.map((option) => [String(option.value), option.label]));
|
||||
}
|
||||
|
||||
function imageURL(value) {
|
||||
const url = String(value || "").trim();
|
||||
if (!url) {
|
||||
return "";
|
||||
}
|
||||
return /\.(avif|gif|jpe?g|png|webp)(\?|#|$)/i.test(url) ? url : "";
|
||||
}
|
||||
@ -35,9 +35,9 @@ const grantColumns = [
|
||||
},
|
||||
{
|
||||
key: "target",
|
||||
label: "用户 ID",
|
||||
width: "minmax(120px, 0.7fr)",
|
||||
render: (grant) => grant.targetUserId || "-",
|
||||
label: "用户信息",
|
||||
width: "minmax(240px, 1.1fr)",
|
||||
render: (grant) => <GrantTargetUser grant={grant} />,
|
||||
},
|
||||
{
|
||||
key: "operator",
|
||||
@ -293,6 +293,26 @@ function GrantItems({ grant }) {
|
||||
);
|
||||
}
|
||||
|
||||
function GrantTargetUser({ grant }) {
|
||||
const user = grant.targetUser || grant.target_user || grant.user || {};
|
||||
const userId = user.userId || user.user_id || grant.targetUserId;
|
||||
const displayUserId = user.displayUserId || user.display_user_id;
|
||||
const username = user.username || "";
|
||||
const name = username || (displayUserId ? `用户 ${displayUserId}` : userId ? `用户 ${userId}` : "-");
|
||||
return (
|
||||
<div className={styles.identity}>
|
||||
<span className={styles.operatorAvatar}>
|
||||
{user.avatar ? <img alt="" src={user.avatar} /> : <PersonOutlineOutlined fontSize="small" />}
|
||||
</span>
|
||||
<span className={styles.identityText}>
|
||||
<span className={styles.name}>{name}</span>
|
||||
<span className={styles.meta}>长ID {userId || "-"}</span>
|
||||
{displayUserId ? <span className={styles.meta}>短ID {displayUserId}</span> : null}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GrantOperator({ grant }) {
|
||||
const operator = grant.operator || {};
|
||||
const source = operator.source || grant.grantSource || "";
|
||||
|
||||
@ -23,7 +23,6 @@ import {
|
||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import {
|
||||
grantStrategyLabels,
|
||||
resourceStatusFilters,
|
||||
resourceTypeFilters,
|
||||
resourceTypeLabels,
|
||||
@ -31,7 +30,7 @@ import {
|
||||
import { useResourceListPage } from "@/features/resources/hooks/useResourcePages.js";
|
||||
import styles from "@/features/resources/resources.module.css";
|
||||
|
||||
const resourceTypeOptions = resourceTypeFilters.filter(([value]) => value);
|
||||
const resourceTypeOptions = resourceTypeFilters.filter(([value]) => value && value !== "emoji_pack");
|
||||
|
||||
const baseColumns = [
|
||||
{
|
||||
@ -46,18 +45,6 @@ const baseColumns = [
|
||||
width: "minmax(120px, 0.7fr)",
|
||||
render: (resource) => resourceTypeLabels[resource.resourceType] || resource.resourceType || "-",
|
||||
},
|
||||
{
|
||||
key: "grant",
|
||||
label: "发放规则",
|
||||
width: "minmax(170px, 1fr)",
|
||||
render: (resource) => <GrantInfo resource={resource} />,
|
||||
},
|
||||
{
|
||||
key: "scopes",
|
||||
label: "使用范围",
|
||||
width: "minmax(180px, 1fr)",
|
||||
render: (resource) => <TagList values={resource.usageScopes} />,
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
@ -102,22 +89,22 @@ export function ResourceListPage() {
|
||||
}),
|
||||
}
|
||||
: column.key === "status"
|
||||
? {
|
||||
...column,
|
||||
filter: createOptionsColumnFilter({
|
||||
options: resourceStatusFilters,
|
||||
placeholder: "搜索状态",
|
||||
value: page.status,
|
||||
onChange: page.changeStatus,
|
||||
}),
|
||||
render: (resource) => <ResourceStatusSwitch page={page} resource={resource} />,
|
||||
}
|
||||
: column.key === "actions"
|
||||
? {
|
||||
...column,
|
||||
render: (resource) => <ResourceRowActions page={page} resource={resource} />,
|
||||
}
|
||||
: column,
|
||||
? {
|
||||
...column,
|
||||
filter: createOptionsColumnFilter({
|
||||
options: resourceStatusFilters,
|
||||
placeholder: "搜索状态",
|
||||
value: page.status,
|
||||
onChange: page.changeStatus,
|
||||
}),
|
||||
render: (resource) => <ResourceStatusSwitch page={page} resource={resource} />,
|
||||
}
|
||||
: column.key === "actions"
|
||||
? {
|
||||
...column,
|
||||
render: (resource) => <ResourceRowActions page={page} resource={resource} />,
|
||||
}
|
||||
: column,
|
||||
);
|
||||
|
||||
return (
|
||||
@ -136,7 +123,7 @@ export function ResourceListPage() {
|
||||
<DataTable
|
||||
columns={columns}
|
||||
items={items}
|
||||
minWidth="1120px"
|
||||
minWidth="820px"
|
||||
pagination={
|
||||
total > 0
|
||||
? {
|
||||
@ -217,7 +204,9 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
|
||||
required
|
||||
select
|
||||
value={form.resourceType}
|
||||
onChange={(event) => setForm({ ...form, resourceType: event.target.value, walletAssetAmount: "" })}
|
||||
onChange={(event) =>
|
||||
setForm({ ...form, resourceType: event.target.value, walletAssetAmount: "" })
|
||||
}
|
||||
>
|
||||
{resourceTypeOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
@ -241,15 +230,17 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
|
||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||
<UploadField
|
||||
disabled={disabled || uploadDisabled}
|
||||
kind="file"
|
||||
label="资源封面"
|
||||
value={form.previewUrl}
|
||||
onChange={(previewUrl) => setForm({ ...form, previewUrl })}
|
||||
/>
|
||||
<UploadField
|
||||
disabled={disabled || uploadDisabled}
|
||||
label="资源素材"
|
||||
value={form.assetUrl}
|
||||
onChange={(assetUrl) => setForm({ ...form, assetUrl })}
|
||||
kind="file"
|
||||
label="动效素材"
|
||||
value={form.animationUrl}
|
||||
onChange={(animationUrl) => setForm({ ...form, animationUrl })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
@ -282,21 +273,6 @@ function ResourceIdentity({ resource }) {
|
||||
);
|
||||
}
|
||||
|
||||
function GrantInfo({ resource }) {
|
||||
const strategy = grantStrategyLabels[resource.grantStrategy] || resource.grantStrategy || "-";
|
||||
if (resource.walletAssetType || resource.walletAssetAmount) {
|
||||
return (
|
||||
<div className={styles.stack}>
|
||||
<span>{strategy}</span>
|
||||
<span className={styles.meta}>
|
||||
{resource.walletAssetType || "-"} {formatNumber(resource.walletAssetAmount)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return strategy;
|
||||
}
|
||||
|
||||
function ResourceStatusSwitch({ page, resource }) {
|
||||
const checked = resource.status === "active";
|
||||
return (
|
||||
@ -309,22 +285,6 @@ function ResourceStatusSwitch({ page, resource }) {
|
||||
);
|
||||
}
|
||||
|
||||
function TagList({ values = [] }) {
|
||||
const items = values.filter(Boolean);
|
||||
if (!items.length) {
|
||||
return "-";
|
||||
}
|
||||
return (
|
||||
<div className="admin-tag-list">
|
||||
{items.map((value) => (
|
||||
<span className="admin-tag" key={value}>
|
||||
{value}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function imageURL(value) {
|
||||
const url = String(value || "").trim();
|
||||
if (!url) {
|
||||
@ -332,7 +292,3 @@ function imageURL(value) {
|
||||
}
|
||||
return /\.(avif|gif|jpe?g|png|webp)(\?|#|$)/i.test(url) ? url : "";
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return Number(value || 0).toLocaleString("zh-CN");
|
||||
}
|
||||
|
||||
@ -2,19 +2,21 @@ import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||
import { PERMISSIONS } from "@/app/permissions";
|
||||
|
||||
export function useResourceAbilities() {
|
||||
const { can } = useAuth();
|
||||
const { can } = useAuth();
|
||||
|
||||
return {
|
||||
canCreate: can(PERMISSIONS.resourceCreate),
|
||||
canCreateGift: can(PERMISSIONS.giftCreate),
|
||||
canCreateGrant: can(PERMISSIONS.resourceGrantCreate),
|
||||
canCreateGroup: can(PERMISSIONS.resourceGroupCreate),
|
||||
canStatusGift: can(PERMISSIONS.giftStatus),
|
||||
canUpdateGroup: can(PERMISSIONS.resourceGroupUpdate),
|
||||
canUpdateGift: can(PERMISSIONS.giftUpdate),
|
||||
canUpdate: can(PERMISSIONS.resourceUpdate),
|
||||
canUpload: can(PERMISSIONS.uploadCreate),
|
||||
canView: can(PERMISSIONS.resourceView),
|
||||
canViewGrant: can(PERMISSIONS.resourceGrantView)
|
||||
};
|
||||
return {
|
||||
canCreate: can(PERMISSIONS.resourceCreate),
|
||||
canCreateGift: can(PERMISSIONS.giftCreate),
|
||||
canCreateGrant: can(PERMISSIONS.resourceGrantCreate),
|
||||
canCreateGroup: can(PERMISSIONS.resourceGroupCreate),
|
||||
canCreateEmojiPack: can(PERMISSIONS.emojiPackCreate),
|
||||
canStatusGift: can(PERMISSIONS.giftStatus),
|
||||
canUpdateGroup: can(PERMISSIONS.resourceGroupUpdate),
|
||||
canUpdateGift: can(PERMISSIONS.giftUpdate),
|
||||
canUpdate: can(PERMISSIONS.resourceUpdate),
|
||||
canUpload: can(PERMISSIONS.uploadCreate),
|
||||
canView: can(PERMISSIONS.resourceView),
|
||||
canViewEmojiPack: can(PERMISSIONS.emojiPackView),
|
||||
canViewGrant: can(PERMISSIONS.resourceGrantView),
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,36 +1,44 @@
|
||||
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
|
||||
|
||||
export const resourceRoutes = [
|
||||
{
|
||||
label: "资源列表",
|
||||
loader: () => import("./pages/ResourceListPage.jsx").then((module) => module.ResourceListPage),
|
||||
menuCode: MENU_CODES.resourceList,
|
||||
pageKey: "resource-list",
|
||||
path: "/resources",
|
||||
permission: PERMISSIONS.resourceView
|
||||
},
|
||||
{
|
||||
label: "资源组列表",
|
||||
loader: () => import("./pages/ResourceGroupListPage.jsx").then((module) => module.ResourceGroupListPage),
|
||||
menuCode: MENU_CODES.resourceGroupList,
|
||||
pageKey: "resource-group-list",
|
||||
path: "/resource-groups",
|
||||
permission: PERMISSIONS.resourceGroupView
|
||||
},
|
||||
{
|
||||
label: "礼物列表",
|
||||
loader: () => import("./pages/GiftListPage.jsx").then((module) => module.GiftListPage),
|
||||
menuCode: MENU_CODES.giftList,
|
||||
pageKey: "gift-list",
|
||||
path: "/gifts",
|
||||
permission: PERMISSIONS.giftView
|
||||
},
|
||||
{
|
||||
label: "资源赠送",
|
||||
loader: () => import("./pages/ResourceGrantListPage.jsx").then((module) => module.ResourceGrantListPage),
|
||||
menuCode: MENU_CODES.resourceGrantList,
|
||||
pageKey: "resource-grant-list",
|
||||
path: "/resource-grants",
|
||||
permission: PERMISSIONS.resourceGrantView
|
||||
}
|
||||
{
|
||||
label: "资源列表",
|
||||
loader: () => import("./pages/ResourceListPage.jsx").then((module) => module.ResourceListPage),
|
||||
menuCode: MENU_CODES.resourceList,
|
||||
pageKey: "resource-list",
|
||||
path: "/resources",
|
||||
permission: PERMISSIONS.resourceView,
|
||||
},
|
||||
{
|
||||
label: "资源组列表",
|
||||
loader: () => import("./pages/ResourceGroupListPage.jsx").then((module) => module.ResourceGroupListPage),
|
||||
menuCode: MENU_CODES.resourceGroupList,
|
||||
pageKey: "resource-group-list",
|
||||
path: "/resource-groups",
|
||||
permission: PERMISSIONS.resourceGroupView,
|
||||
},
|
||||
{
|
||||
label: "礼物列表",
|
||||
loader: () => import("./pages/GiftListPage.jsx").then((module) => module.GiftListPage),
|
||||
menuCode: MENU_CODES.giftList,
|
||||
pageKey: "gift-list",
|
||||
path: "/gifts",
|
||||
permission: PERMISSIONS.giftView,
|
||||
},
|
||||
{
|
||||
label: "表情包列表",
|
||||
loader: () => import("./pages/EmojiPackListPage.jsx").then((module) => module.EmojiPackListPage),
|
||||
menuCode: MENU_CODES.emojiPackList,
|
||||
pageKey: "emoji-pack-list",
|
||||
path: "/emoji-packs",
|
||||
permission: PERMISSIONS.emojiPackView,
|
||||
},
|
||||
{
|
||||
label: "资源赠送",
|
||||
loader: () => import("./pages/ResourceGrantListPage.jsx").then((module) => module.ResourceGrantListPage),
|
||||
menuCode: MENU_CODES.resourceGrantList,
|
||||
pageKey: "resource-grant-list",
|
||||
path: "/resource-grants",
|
||||
permission: PERMISSIONS.resourceGrantView,
|
||||
},
|
||||
];
|
||||
|
||||
@ -1,6 +1,18 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const resourceTypes = ["avatar_frame", "profile_card", "coin", "vehicle", "chat_bubble", "badge", "floating_screen", "gift"];
|
||||
const resourceTypes = [
|
||||
"avatar_frame",
|
||||
"profile_card",
|
||||
"coin",
|
||||
"vehicle",
|
||||
"chat_bubble",
|
||||
"badge",
|
||||
"floating_screen",
|
||||
"gift",
|
||||
"mic_seat_icon",
|
||||
"mic_seat_animation",
|
||||
"emoji_pack",
|
||||
];
|
||||
const walletAssetTypes = ["COIN", "DIAMOND"];
|
||||
const optionalWalletAssetTypeSchema = z
|
||||
.preprocess((value) => {
|
||||
@ -14,7 +26,7 @@ const optionalWalletAssetTypeSchema = z
|
||||
|
||||
export const resourceCreateFormSchema = z
|
||||
.object({
|
||||
assetUrl: z.string().trim().min(1, "请上传资源素材").max(512, "资源素材不能超过 512 个字符"),
|
||||
animationUrl: z.string().trim().min(1, "请上传动效素材").max(512, "动效素材不能超过 512 个字符"),
|
||||
enabled: z.boolean(),
|
||||
name: z.string().trim().min(1, "请输入资源名称").max(128, "资源名称不能超过 128 个字符"),
|
||||
previewUrl: z.string().trim().min(1, "请上传资源封面").max(512, "资源封面不能超过 512 个字符"),
|
||||
@ -38,6 +50,16 @@ export const resourceCreateFormSchema = z
|
||||
|
||||
export const resourceFormSchema = resourceCreateFormSchema;
|
||||
|
||||
export const emojiPackFormSchema = z.object({
|
||||
animationUrl: z.string().trim().min(1, "请上传动效图").max(512, "动效图不能超过 512 个字符"),
|
||||
category: z.string().trim().min(1, "请输入分类").max(64, "分类不能超过 64 个字符"),
|
||||
coverUrl: z.string().trim().min(1, "请上传封面图").max(512, "封面图不能超过 512 个字符"),
|
||||
name: z.string().trim().min(1, "请输入名称").max(128, "名称不能超过 128 个字符"),
|
||||
pricingType: z.enum(["free", "paid"]),
|
||||
regionIds: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
sortOrder: z.union([z.string(), z.number()]).optional(),
|
||||
});
|
||||
|
||||
const groupItemSchema = z.object({
|
||||
durationDays: z.union([z.string(), z.number()]).optional(),
|
||||
itemType: z.enum(["resource", "wallet_asset"]),
|
||||
@ -119,7 +141,18 @@ export const giftFormSchema = z
|
||||
enabled: z.boolean(),
|
||||
giftId: z.string().trim().min(1, "请输入礼物 ID").max(96, "礼物 ID 不能超过 96 个字符"),
|
||||
giftPointAmount: z.union([z.string(), z.number()]).optional(),
|
||||
giftTypeCode: z.enum(["normal", "cp", "lucky", "super_lucky", "exclusive", "noble", "flag", "activity", "magic", "custom"]),
|
||||
giftTypeCode: z.enum([
|
||||
"normal",
|
||||
"cp",
|
||||
"lucky",
|
||||
"super_lucky",
|
||||
"exclusive",
|
||||
"noble",
|
||||
"flag",
|
||||
"activity",
|
||||
"magic",
|
||||
"custom",
|
||||
]),
|
||||
heatValue: z.union([z.string(), z.number()]).optional(),
|
||||
name: z.string().trim().min(1, "请输入礼物名称").max(128, "礼物名称不能超过 128 个字符"),
|
||||
presentationJson: z.string().trim().optional(),
|
||||
|
||||
@ -1,11 +1,17 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { FormValidationError, parseForm } from "@/shared/forms/validation";
|
||||
import { giftFormSchema, resourceFormSchema, resourceGrantFormSchema, resourceGroupCreateFormSchema } from "./schema.js";
|
||||
import {
|
||||
emojiPackFormSchema,
|
||||
giftFormSchema,
|
||||
resourceFormSchema,
|
||||
resourceGrantFormSchema,
|
||||
resourceGroupCreateFormSchema
|
||||
} from "./schema.js";
|
||||
|
||||
describe("resource form schema", () => {
|
||||
test("allows profile card resources", () => {
|
||||
const payload = parseForm(resourceFormSchema, {
|
||||
assetUrl: "https://media.haiyihy.com/resource/profile-card.png",
|
||||
animationUrl: "https://media.haiyihy.com/resource/profile-card.pag",
|
||||
enabled: true,
|
||||
name: "Profile Card",
|
||||
previewUrl: "https://media.haiyihy.com/resource/profile-card-cover.png",
|
||||
@ -84,6 +90,21 @@ describe("resource form schema", () => {
|
||||
expect(payload.regionIds).toEqual(["0", "1001"]);
|
||||
});
|
||||
|
||||
test("validates emoji pack category and pricing type", () => {
|
||||
const payload = parseForm(emojiPackFormSchema, {
|
||||
animationUrl: "https://media.haiyihy.com/emoji/wave.svga",
|
||||
category: "热门",
|
||||
coverUrl: "https://media.haiyihy.com/emoji/wave.png",
|
||||
name: "Wave",
|
||||
pricingType: "paid",
|
||||
regionIds: ["0"],
|
||||
sortOrder: "0"
|
||||
});
|
||||
|
||||
expect(payload.category).toBe("热门");
|
||||
expect(payload.pricingType).toBe("paid");
|
||||
});
|
||||
|
||||
test("validates resource grant form fields", () => {
|
||||
const payload = parseForm(resourceGrantFormSchema, {
|
||||
durationDays: "7",
|
||||
|
||||
@ -11,6 +11,7 @@ export function RoomDetailDrawer({ onClose, open, room }) {
|
||||
const owner = room?.owner || {};
|
||||
const normalizedStatus = room?.status === "active" ? "active" : "closed";
|
||||
const ownerDisplayId = ownerLongShortId(owner, room);
|
||||
const closeOperator = room?.closedByAdminName || (room?.closedByAdminId ? String(room.closedByAdminId) : "");
|
||||
|
||||
return (
|
||||
<SideDrawer open={open} title="房间详情" width="wide" onClose={onClose}>
|
||||
@ -42,6 +43,17 @@ export function RoomDetailDrawer({ onClose, open, room }) {
|
||||
title="基础信息"
|
||||
/>
|
||||
|
||||
{normalizedStatus === "closed" ? (
|
||||
<DetailSection
|
||||
items={[
|
||||
["操作人", closeOperator],
|
||||
["操作时间", formatMillis(room.closedAtMs)],
|
||||
["关闭原因", room.closeReason]
|
||||
]}
|
||||
title="关闭信息"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<section className={styles.detailSection}>
|
||||
<h3 className={styles.detailSectionTitle}>房主信息</h3>
|
||||
<div className={styles.detailOwner}>
|
||||
|
||||
@ -16,7 +16,7 @@ const emptyForm = () => ({
|
||||
title: "",
|
||||
visibleRegionId: 0,
|
||||
});
|
||||
const emptyStatusForm = () => ({ status: "active" });
|
||||
const emptyStatusForm = () => ({ closeReason: "", status: "active" });
|
||||
|
||||
export function useRoomsPage() {
|
||||
const abilities = useRoomAbilities();
|
||||
@ -100,9 +100,9 @@ export function useRoomsPage() {
|
||||
setActiveAction("detail");
|
||||
};
|
||||
|
||||
const openStatus = (room) => {
|
||||
const openStatus = (room, statusValue = "closed") => {
|
||||
setActiveRoom(room);
|
||||
setStatusForm({ status: room.status === "closed" ? "closed" : "active" });
|
||||
setStatusForm({ closeReason: "", status: statusValue });
|
||||
setActiveAction("status");
|
||||
};
|
||||
|
||||
@ -132,7 +132,7 @@ export function useRoomsPage() {
|
||||
if (!activeRoom) {
|
||||
return;
|
||||
}
|
||||
await runAction("status", "房间状态已更新", async () => {
|
||||
await runAction(`status-${activeRoom.roomId}`, "房间状态已更新", async () => {
|
||||
const payload = parseForm(roomStatusSchema, statusForm);
|
||||
await updateRoom(activeRoom.roomId, payload);
|
||||
closeAction();
|
||||
@ -140,6 +140,17 @@ export function useRoomsPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const toggleRoomStatus = async (room, checked) => {
|
||||
if (!checked) {
|
||||
openStatus(room, "closed");
|
||||
return;
|
||||
}
|
||||
await runAction(`status-${room.roomId}`, "房间状态已更新", async () => {
|
||||
await updateRoom(room.roomId, { status: "active" });
|
||||
await reload();
|
||||
});
|
||||
};
|
||||
|
||||
const removeRoom = async (room) => {
|
||||
const ok = await confirm({
|
||||
confirmText: "删除",
|
||||
@ -200,6 +211,7 @@ export function useRoomsPage() {
|
||||
statusForm,
|
||||
submitStatus,
|
||||
submitEdit,
|
||||
toggleRoomStatus,
|
||||
toggleContributionSort,
|
||||
};
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import { AdminFormDialog, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
|
||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
||||
@ -53,7 +54,7 @@ const columns = [
|
||||
key: "status",
|
||||
label: "状态",
|
||||
width: "minmax(100px, 0.7fr)",
|
||||
render: (room) => <RoomStatus status={room.status} />,
|
||||
render: (room) => roomStatusLabels[room.status === "active" ? "active" : "closed"],
|
||||
},
|
||||
{
|
||||
key: "time",
|
||||
@ -98,7 +99,7 @@ export function RoomListPage() {
|
||||
value: page.status,
|
||||
onChange: page.changeStatus,
|
||||
}),
|
||||
render: (room) => <RoomStatus status={room.status} />,
|
||||
render: (room) => <RoomStatusSwitch page={page} room={room} />,
|
||||
}
|
||||
: column.key === "roomContribution"
|
||||
? {
|
||||
@ -188,6 +189,26 @@ export function RoomListPage() {
|
||||
/>
|
||||
</ActionModal>
|
||||
|
||||
<ActionModal
|
||||
disabled={!page.abilities.canUpdate}
|
||||
loading={page.loadingAction === `status-${page.activeRoom?.roomId}`}
|
||||
open={page.activeAction === "status"}
|
||||
sectionTitle="关闭信息"
|
||||
title="关闭房间"
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitStatus}
|
||||
>
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="关闭原因"
|
||||
multiline
|
||||
minRows={3}
|
||||
required
|
||||
value={page.statusForm.closeReason}
|
||||
onChange={(event) => page.setStatusForm({ ...page.statusForm, closeReason: event.target.value })}
|
||||
/>
|
||||
</ActionModal>
|
||||
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -268,14 +289,19 @@ function RoomActions({ page, room }) {
|
||||
);
|
||||
}
|
||||
|
||||
function RoomStatus({ status }) {
|
||||
const normalizedStatus = status === "active" ? "active" : "closed";
|
||||
const tone = normalizedStatus === "active" ? "running" : "stopped";
|
||||
function RoomStatusSwitch({ page, room }) {
|
||||
const checked = room.status === "active";
|
||||
return (
|
||||
<span className={`status-badge status-badge--${tone}`}>
|
||||
<span className="status-point" />
|
||||
{roomStatusLabels[normalizedStatus]}
|
||||
</span>
|
||||
<div className={styles.statusCell}>
|
||||
<AdminSwitch
|
||||
checked={checked}
|
||||
checkedLabel="正常"
|
||||
disabled={!page.abilities.canUpdate || page.loadingAction === `status-${room.roomId}`}
|
||||
inputProps={{ "aria-label": checked ? "关闭房间" : "恢复房间" }}
|
||||
uncheckedLabel="关闭"
|
||||
onChange={(event) => page.toggleRoomStatus(room, event.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -217,6 +217,13 @@
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.statusCell {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.statusButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@ -10,12 +10,18 @@ export const roomUpdateSchema = z.object({
|
||||
visibleRegionId: z.coerce.number().int().min(0, "区域 ID 不正确").default(0),
|
||||
});
|
||||
|
||||
export const roomStatusSchema = z.object({
|
||||
status: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((value) => value === "active" || value === "closed", "房间状态不正确"),
|
||||
});
|
||||
export const roomStatusSchema = z
|
||||
.object({
|
||||
closeReason: z.string().trim().max(512, "关闭原因不能超过 512 个字符").optional().default(""),
|
||||
status: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((value) => value === "active" || value === "closed", "房间状态不正确"),
|
||||
})
|
||||
.refine((value) => value.status !== "closed" || value.closeReason.length > 0, {
|
||||
message: "请输入关闭原因",
|
||||
path: ["closeReason"],
|
||||
});
|
||||
|
||||
export const roomConfigSchema = z
|
||||
.object({
|
||||
|
||||
@ -27,14 +27,17 @@ export const API_OPERATIONS = {
|
||||
cancelRoomPin: "cancelRoomPin",
|
||||
changePassword: "changePassword",
|
||||
closeAgency: "closeAgency",
|
||||
createAchievementDefinition: "createAchievementDefinition",
|
||||
createAgency: "createAgency",
|
||||
createAppVersion: "createAppVersion",
|
||||
createBanner: "createBanner",
|
||||
createBD: "createBD",
|
||||
createBDLeader: "createBDLeader",
|
||||
createCatalog: "createCatalog",
|
||||
createCoinAdjustment: "createCoinAdjustment",
|
||||
createCoinSeller: "createCoinSeller",
|
||||
createCountry: "createCountry",
|
||||
createEmojiPack: "createEmojiPack",
|
||||
createGift: "createGift",
|
||||
createMenu: "createMenu",
|
||||
createPermission: "createPermission",
|
||||
@ -46,6 +49,7 @@ export const API_OPERATIONS = {
|
||||
createRole: "createRole",
|
||||
createRoomPin: "createRoomPin",
|
||||
createTaskDefinition: "createTaskDefinition",
|
||||
createTier: "createTier",
|
||||
createUser: "createUser",
|
||||
createUserExportJob: "createUserExportJob",
|
||||
creditCoinSellerStock: "creditCoinSellerStock",
|
||||
@ -83,6 +87,7 @@ export const API_OPERATIONS = {
|
||||
getUser: "getUser",
|
||||
grantResource: "grantResource",
|
||||
grantResourceGroup: "grantResourceGroup",
|
||||
listAchievementDefinitions: "listAchievementDefinitions",
|
||||
listAgencies: "listAgencies",
|
||||
listApps: "listApps",
|
||||
listAppVersions: "listAppVersions",
|
||||
@ -90,19 +95,24 @@ export const API_OPERATIONS = {
|
||||
listBDLeaders: "listBDLeaders",
|
||||
listBDs: "listBDs",
|
||||
listCatalog: "listCatalog",
|
||||
listCoinAdjustments: "listCoinAdjustments",
|
||||
listCoinLedger: "listCoinLedger",
|
||||
listCoinSellers: "listCoinSellers",
|
||||
listCountries: "listCountries",
|
||||
listEmojiPackCategories: "listEmojiPackCategories",
|
||||
listEmojiPacks: "listEmojiPacks",
|
||||
listGifts: "listGifts",
|
||||
listH5Links: "listH5Links",
|
||||
listHosts: "listHosts",
|
||||
listJobs: "listJobs",
|
||||
listLevelConfig: "listLevelConfig",
|
||||
listLoginLogs: "listLoginLogs",
|
||||
listOperationLogs: "listOperationLogs",
|
||||
listPermissions: "listPermissions",
|
||||
listPlatforms: "listPlatforms",
|
||||
listRechargeBills: "listRechargeBills",
|
||||
listRechargeProducts: "listRechargeProducts",
|
||||
listRegionBlocks: "listRegionBlocks",
|
||||
listRegions: "listRegions",
|
||||
listRegistrationRewardClaims: "listRegistrationRewardClaims",
|
||||
listResourceGrants: "listResourceGrants",
|
||||
@ -116,9 +126,11 @@ export const API_OPERATIONS = {
|
||||
listUsers: "listUsers",
|
||||
login: "login",
|
||||
logout: "logout",
|
||||
lookupCoinAdjustmentTarget: "lookupCoinAdjustmentTarget",
|
||||
me: "me",
|
||||
navigationMenus: "navigationMenus",
|
||||
refresh: "refresh",
|
||||
replaceRegionBlocks: "replaceRegionBlocks",
|
||||
replaceRegionCountries: "replaceRegionCountries",
|
||||
replaceRoleDataScopes: "replaceRoleDataScopes",
|
||||
replaceRolePermissions: "replaceRolePermissions",
|
||||
@ -132,6 +144,7 @@ export const API_OPERATIONS = {
|
||||
setTaskDefinitionStatus: "setTaskDefinitionStatus",
|
||||
sortMenus: "sortMenus",
|
||||
syncPermissions: "syncPermissions",
|
||||
updateAchievementDefinition: "updateAchievementDefinition",
|
||||
updateAppVersion: "updateAppVersion",
|
||||
updateBanner: "updateBanner",
|
||||
updateCatalog: "updateCatalog",
|
||||
@ -152,10 +165,13 @@ export const API_OPERATIONS = {
|
||||
updateRoom: "updateRoom",
|
||||
updateRoomConfig: "updateRoomConfig",
|
||||
updateTaskDefinition: "updateTaskDefinition",
|
||||
updateTier: "updateTier",
|
||||
updateTrack: "updateTrack",
|
||||
updateUser: "updateUser",
|
||||
updateUserStatus: "updateUserStatus",
|
||||
uploadFile: "uploadFile",
|
||||
uploadImage: "uploadImage"
|
||||
uploadImage: "uploadImage",
|
||||
upsertRule: "upsertRule"
|
||||
} as const;
|
||||
|
||||
export type ApiOperationId = (typeof API_OPERATIONS)[keyof typeof API_OPERATIONS];
|
||||
@ -257,6 +273,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "agency:status",
|
||||
permissions: ["agency:status"]
|
||||
},
|
||||
createAchievementDefinition: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.createAchievementDefinition,
|
||||
path: "/v1/admin/activity/achievements",
|
||||
permission: "achievement:create",
|
||||
permissions: ["achievement:create"]
|
||||
},
|
||||
createAgency: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.createAgency,
|
||||
@ -299,6 +322,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "game:create",
|
||||
permissions: ["game:create"]
|
||||
},
|
||||
createCoinAdjustment: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.createCoinAdjustment,
|
||||
path: "/v1/admin/operations/coin-adjustments",
|
||||
permission: "coin-adjustment:create",
|
||||
permissions: ["coin-adjustment:create"]
|
||||
},
|
||||
createCoinSeller: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.createCoinSeller,
|
||||
@ -313,6 +343,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "country:create",
|
||||
permissions: ["country:create"]
|
||||
},
|
||||
createEmojiPack: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.createEmojiPack,
|
||||
path: "/v1/admin/emoji-packs",
|
||||
permission: "emoji-pack:create",
|
||||
permissions: ["emoji-pack:create"]
|
||||
},
|
||||
createGift: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.createGift,
|
||||
@ -390,6 +427,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "daily-task:create",
|
||||
permissions: ["daily-task:create"]
|
||||
},
|
||||
createTier: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.createTier,
|
||||
path: "/v1/admin/users/level-config/tiers",
|
||||
permission: "level-config:update",
|
||||
permissions: ["level-config:update"]
|
||||
},
|
||||
createUser: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.createUser,
|
||||
@ -649,6 +693,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "resource-grant:create",
|
||||
permissions: ["resource-grant:create"]
|
||||
},
|
||||
listAchievementDefinitions: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listAchievementDefinitions,
|
||||
path: "/v1/admin/activity/achievements",
|
||||
permission: "achievement:view",
|
||||
permissions: ["achievement:view"]
|
||||
},
|
||||
listAgencies: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listAgencies,
|
||||
@ -696,6 +747,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "game:view",
|
||||
permissions: ["game:view"]
|
||||
},
|
||||
listCoinAdjustments: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listCoinAdjustments,
|
||||
path: "/v1/admin/operations/coin-adjustments",
|
||||
permission: "coin-adjustment:view",
|
||||
permissions: ["coin-adjustment:view"]
|
||||
},
|
||||
listCoinLedger: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listCoinLedger,
|
||||
@ -717,6 +775,20 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "country:view",
|
||||
permissions: ["country:view"]
|
||||
},
|
||||
listEmojiPackCategories: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listEmojiPackCategories,
|
||||
path: "/v1/admin/emoji-packs/categories",
|
||||
permission: "emoji-pack:view",
|
||||
permissions: ["emoji-pack:view"]
|
||||
},
|
||||
listEmojiPacks: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listEmojiPacks,
|
||||
path: "/v1/admin/emoji-packs",
|
||||
permission: "emoji-pack:view",
|
||||
permissions: ["emoji-pack:view"]
|
||||
},
|
||||
listGifts: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listGifts,
|
||||
@ -745,6 +817,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "job:view",
|
||||
permissions: ["job:view"]
|
||||
},
|
||||
listLevelConfig: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listLevelConfig,
|
||||
path: "/v1/admin/users/level-config",
|
||||
permission: "level-config:view",
|
||||
permissions: ["level-config:view"]
|
||||
},
|
||||
listLoginLogs: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listLoginLogs,
|
||||
@ -787,6 +866,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "payment-product:view",
|
||||
permissions: ["payment-product:view"]
|
||||
},
|
||||
listRegionBlocks: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listRegionBlocks,
|
||||
path: "/v1/admin/users/region-blocks",
|
||||
permission: "region-block:view",
|
||||
permissions: ["region-block:view"]
|
||||
},
|
||||
listRegions: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listRegions,
|
||||
@ -874,6 +960,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
operationId: API_OPERATIONS.logout,
|
||||
path: "/v1/auth/logout"
|
||||
},
|
||||
lookupCoinAdjustmentTarget: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.lookupCoinAdjustmentTarget,
|
||||
path: "/v1/admin/operations/coin-adjustments/target",
|
||||
permission: "coin-adjustment:create",
|
||||
permissions: ["coin-adjustment:create"]
|
||||
},
|
||||
me: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.me,
|
||||
@ -889,6 +982,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
operationId: API_OPERATIONS.refresh,
|
||||
path: "/v1/auth/refresh"
|
||||
},
|
||||
replaceRegionBlocks: {
|
||||
method: "PUT",
|
||||
operationId: API_OPERATIONS.replaceRegionBlocks,
|
||||
path: "/v1/admin/users/region-blocks",
|
||||
permission: "region-block:update",
|
||||
permissions: ["region-block:update"]
|
||||
},
|
||||
replaceRegionCountries: {
|
||||
method: "PUT",
|
||||
operationId: API_OPERATIONS.replaceRegionCountries,
|
||||
@ -978,6 +1078,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "permission:sync",
|
||||
permissions: ["permission:sync"]
|
||||
},
|
||||
updateAchievementDefinition: {
|
||||
method: "PUT",
|
||||
operationId: API_OPERATIONS.updateAchievementDefinition,
|
||||
path: "/v1/admin/activity/achievements/{achievement_id}",
|
||||
permission: "achievement:update",
|
||||
permissions: ["achievement:update"]
|
||||
},
|
||||
updateAppVersion: {
|
||||
method: "PUT",
|
||||
operationId: API_OPERATIONS.updateAppVersion,
|
||||
@ -1118,6 +1225,20 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "daily-task:update",
|
||||
permissions: ["daily-task:update"]
|
||||
},
|
||||
updateTier: {
|
||||
method: "PUT",
|
||||
operationId: API_OPERATIONS.updateTier,
|
||||
path: "/v1/admin/users/level-config/tiers/{tier_id}",
|
||||
permission: "level-config:update",
|
||||
permissions: ["level-config:update"]
|
||||
},
|
||||
updateTrack: {
|
||||
method: "PUT",
|
||||
operationId: API_OPERATIONS.updateTrack,
|
||||
path: "/v1/admin/users/level-config/tracks/{track}",
|
||||
permission: "level-config:update",
|
||||
permissions: ["level-config:update"]
|
||||
},
|
||||
updateUser: {
|
||||
method: "PATCH",
|
||||
operationId: API_OPERATIONS.updateUser,
|
||||
@ -1145,6 +1266,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
path: "/v1/admin/files/image/upload",
|
||||
permission: "upload:create",
|
||||
permissions: ["upload:create"]
|
||||
},
|
||||
upsertRule: {
|
||||
method: "PUT",
|
||||
operationId: API_OPERATIONS.upsertRule,
|
||||
path: "/v1/admin/users/level-config/rules/{track}/{level}",
|
||||
permission: "level-config:update",
|
||||
permissions: ["level-config:update"]
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
393
src/shared/api/generated/schema.d.ts
vendored
393
src/shared/api/generated/schema.d.ts
vendored
@ -4,6 +4,38 @@
|
||||
*/
|
||||
|
||||
export interface paths {
|
||||
"/admin/activity/achievements": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["listAchievementDefinitions"];
|
||||
put?: never;
|
||||
post: operations["createAchievementDefinition"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/activity/achievements/{achievement_id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put: operations["updateAchievementDefinition"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/activity/daily-tasks": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -420,6 +452,38 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/emoji-packs": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["listEmojiPacks"];
|
||||
put?: never;
|
||||
post: operations["createEmojiPack"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/emoji-packs/categories": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["listEmojiPackCategories"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/files/image/upload": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -612,6 +676,38 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/operations/coin-adjustments": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["listCoinAdjustments"];
|
||||
put?: never;
|
||||
post: operations["createCoinAdjustment"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/operations/coin-adjustments/target": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["lookupCoinAdjustmentTarget"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/operations/coin-ledger": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -1012,6 +1108,102 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/users/level-config": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["listLevelConfig"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/users/level-config/rules/{track}/{level}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put: operations["upsertRule"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/users/level-config/tiers": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["createTier"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/users/level-config/tiers/{tier_id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put: operations["updateTier"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/users/level-config/tracks/{track}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put: operations["updateTrack"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/users/region-blocks": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["listRegionBlocks"];
|
||||
put: operations["replaceRegionBlocks"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/app/users": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -2102,6 +2294,44 @@ export interface components {
|
||||
}
|
||||
export type $defs = Record<string, never>;
|
||||
export interface operations {
|
||||
listAchievementDefinitions: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
createAchievementDefinition: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
updateAchievementDefinition: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
achievement_id: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
listTaskDefinitions: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -2638,6 +2868,42 @@ export interface operations {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
listEmojiPacks: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
createEmojiPack: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
listEmojiPackCategories: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
uploadImage: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -2837,6 +3103,42 @@ export interface operations {
|
||||
200: components["responses"]["HostProfilePageResponse"];
|
||||
};
|
||||
};
|
||||
listCoinAdjustments: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
createCoinAdjustment: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
lookupCoinAdjustmentTarget: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
listCoinLedger: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -3319,6 +3621,97 @@ export interface operations {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
listLevelConfig: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
upsertRule: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
track: number;
|
||||
level: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
createTier: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
updateTier: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
tier_id: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
updateTrack: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
track: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
listRegionBlocks: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
replaceRegionBlocks: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
appListUsers: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@ -206,6 +206,44 @@ export interface CoinLedgerEntryDto {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface CoinAdjustmentOperatorDto {
|
||||
adminId?: string;
|
||||
name?: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface CoinAdjustmentDto {
|
||||
amount: number;
|
||||
availableAfter: number;
|
||||
availableDelta: number;
|
||||
commandId?: string;
|
||||
createdAtMs?: number;
|
||||
direction: "income" | "expense" | string;
|
||||
entryId: number;
|
||||
operator?: CoinAdjustmentOperatorDto;
|
||||
operatorUserId?: string;
|
||||
reason?: string;
|
||||
transactionId: string;
|
||||
user: CoinLedgerUserDto;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface CoinAdjustmentPayload {
|
||||
amount: number;
|
||||
commandId?: string;
|
||||
reason: string;
|
||||
targetUserId: string;
|
||||
}
|
||||
|
||||
export interface CoinAdjustmentCreateDto {
|
||||
amount: number;
|
||||
availableDelta: number;
|
||||
balanceAfter: number;
|
||||
reason?: string;
|
||||
transactionId: string;
|
||||
user: CoinLedgerUserDto;
|
||||
}
|
||||
|
||||
export interface RechargeBillDto {
|
||||
appCode?: string;
|
||||
coinAmount: number;
|
||||
@ -464,6 +502,10 @@ export interface AppUserPasswordPayload {
|
||||
}
|
||||
|
||||
export interface RoomDto {
|
||||
closeReason?: string;
|
||||
closedAtMs?: number;
|
||||
closedByAdminId?: number;
|
||||
closedByAdminName?: string;
|
||||
coverUrl?: string;
|
||||
createdAtMs?: number;
|
||||
heat?: number;
|
||||
@ -524,6 +566,7 @@ export interface RoomOwnerDto {
|
||||
}
|
||||
|
||||
export interface RoomUpdatePayload {
|
||||
closeReason?: string;
|
||||
coverUrl?: string;
|
||||
description?: string;
|
||||
mode?: string;
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import Refresh from "@mui/icons-material/Refresh";
|
||||
import Skeleton from "@mui/material/Skeleton";
|
||||
import { Button } from "./Button.jsx";
|
||||
import { usePageLoadingStatus } from "./PageLoadBoundary.jsx";
|
||||
|
||||
@ -8,7 +9,7 @@ export function DataState({ error, loading, children, onRetry, reportPageLoading
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="data-state data-state--loading">
|
||||
<div className="page-skeleton__block" />
|
||||
<ContentSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -29,3 +30,33 @@ export function DataState({ error, loading, children, onRetry, reportPageLoading
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
function ContentSkeleton() {
|
||||
return (
|
||||
<div className="data-state__skeleton" aria-hidden="true">
|
||||
<div className="data-state__skeleton-toolbar">
|
||||
<div className="data-state__skeleton-filters">
|
||||
<Skeleton animation="wave" className="data-state__skeleton-filter" variant="rounded" />
|
||||
<Skeleton animation="wave" className="data-state__skeleton-filter" variant="rounded" />
|
||||
<Skeleton animation="wave" className="data-state__skeleton-reset" variant="rounded" />
|
||||
</div>
|
||||
<div className="data-state__skeleton-actions">
|
||||
<Skeleton animation="wave" className="data-state__skeleton-action" variant="rounded" />
|
||||
<Skeleton animation="wave" className="data-state__skeleton-action" variant="rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="data-state__skeleton-table">
|
||||
<Skeleton animation="wave" className="data-state__skeleton-head" variant="rounded" />
|
||||
{Array.from({ length: 7 }, (_, index) => (
|
||||
<div className="data-state__skeleton-row" key={index}>
|
||||
<Skeleton animation="wave" className="data-state__skeleton-cell data-state__skeleton-cell--main" variant="rounded" />
|
||||
<Skeleton animation="wave" className="data-state__skeleton-cell" variant="rounded" />
|
||||
<Skeleton animation="wave" className="data-state__skeleton-cell" variant="rounded" />
|
||||
<Skeleton animation="wave" className="data-state__skeleton-cell data-state__skeleton-cell--short" variant="rounded" />
|
||||
<Skeleton animation="wave" className="data-state__skeleton-cell data-state__skeleton-cell--action" variant="rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -19,6 +19,7 @@ export function UploadField({
|
||||
kind = "image",
|
||||
label,
|
||||
onChange,
|
||||
uploadKind,
|
||||
value = ""
|
||||
}) {
|
||||
const inputId = useId();
|
||||
@ -27,10 +28,12 @@ export function UploadField({
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [localPreview, setLocalPreview] = useState(null);
|
||||
const isImage = kind === "image";
|
||||
const uploadMode = uploadKind || kind;
|
||||
const useImageUpload = uploadMode === "image";
|
||||
const source = localPreview?.url || value;
|
||||
const assetKind = localPreview?.assetKind || getAssetKind(value, kind);
|
||||
const displayName = localPreview?.name || getDisplayValue(value);
|
||||
const inputAccept = accept || (isImage ? imageAccept : undefined);
|
||||
const inputAccept = accept !== undefined ? accept : useImageUpload && isImage ? imageAccept : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@ -65,7 +68,7 @@ export function UploadField({
|
||||
});
|
||||
setUploading(true);
|
||||
try {
|
||||
const result = isImage ? await uploadImage(file) : await uploadFile(file);
|
||||
const result = useImageUpload ? await uploadImage(file) : await uploadFile(file);
|
||||
onChange(result.url);
|
||||
setLocalPreview(null);
|
||||
showToast("上传成功", "success");
|
||||
@ -146,7 +149,7 @@ function AssetPreview({ assetKind, isImage, src }) {
|
||||
if (assetKind === "pag") {
|
||||
return <PAGAssetPreview src={src} />;
|
||||
}
|
||||
if (isImage) {
|
||||
if (assetKind === "image" || isImage) {
|
||||
return <RasterAssetPreview src={src} />;
|
||||
}
|
||||
return (
|
||||
@ -328,6 +331,9 @@ function getAssetKind(value, kind, contentType = "") {
|
||||
if (extension === "pag") {
|
||||
return "pag";
|
||||
}
|
||||
if (isRasterImageExtension(extension)) {
|
||||
return "image";
|
||||
}
|
||||
const normalizedType = String(contentType || "").toLowerCase();
|
||||
if (normalizedType.includes("svga")) {
|
||||
return "svga";
|
||||
@ -335,9 +341,20 @@ function getAssetKind(value, kind, contentType = "") {
|
||||
if (normalizedType.includes("pag")) {
|
||||
return "pag";
|
||||
}
|
||||
if (isRasterImageContentType(normalizedType)) {
|
||||
return "image";
|
||||
}
|
||||
return kind === "image" ? "image" : "file";
|
||||
}
|
||||
|
||||
function isRasterImageExtension(extension) {
|
||||
return ["avif", "gif", "jpg", "jpeg", "png", "webp"].includes(extension);
|
||||
}
|
||||
|
||||
function isRasterImageContentType(contentType) {
|
||||
return ["image/avif", "image/gif", "image/jpeg", "image/png", "image/webp"].includes(contentType);
|
||||
}
|
||||
|
||||
function getExtension(value) {
|
||||
if (!value) {
|
||||
return "";
|
||||
@ -359,6 +376,9 @@ function safePathname(value) {
|
||||
}
|
||||
|
||||
function assetKindLabel(assetKind, isImage) {
|
||||
if (assetKind === "image") {
|
||||
return "图片";
|
||||
}
|
||||
if (assetKind === "svga") {
|
||||
return "SVGA";
|
||||
}
|
||||
|
||||
32
src/shared/ui/UploadField.test.jsx
Normal file
32
src/shared/ui/UploadField.test.jsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { fireEvent, render, waitFor } from "@testing-library/react";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { uploadFile, uploadImage } from "@/shared/api/upload";
|
||||
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
|
||||
import { UploadField } from "./UploadField.jsx";
|
||||
|
||||
vi.mock("@/shared/api/upload", () => ({
|
||||
uploadFile: vi.fn(async () => ({ url: "https://media.haiyihy.com/admin/files/effect.bin" })),
|
||||
uploadImage: vi.fn(async () => ({ url: "https://media.haiyihy.com/admin/images/effect.png" }))
|
||||
}));
|
||||
|
||||
test("file upload field does not restrict picker type and uses generic upload endpoint", async () => {
|
||||
const onChange = vi.fn();
|
||||
const { container } = render(
|
||||
<ToastProvider>
|
||||
<UploadField kind="file" label="动效素材" value="" onChange={onChange} />
|
||||
</ToastProvider>
|
||||
);
|
||||
const input = container.querySelector('input[type="file"]');
|
||||
|
||||
expect(input).not.toHaveAttribute("accept");
|
||||
|
||||
fireEvent.change(input, {
|
||||
target: {
|
||||
files: [new File(["effect"], "effect.bin", { type: "application/octet-stream" })]
|
||||
}
|
||||
});
|
||||
|
||||
await waitFor(() => expect(uploadFile).toHaveBeenCalledTimes(1));
|
||||
expect(uploadImage).not.toHaveBeenCalled();
|
||||
expect(onChange).toHaveBeenCalledWith("https://media.haiyihy.com/admin/files/effect.bin");
|
||||
});
|
||||
@ -1029,11 +1029,93 @@
|
||||
}
|
||||
|
||||
.data-state--loading {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: stretch;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.data-state--loading .page-skeleton__block {
|
||||
min-height: 220px;
|
||||
.data-state__skeleton {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-height: 360px;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.data-state__skeleton-toolbar {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.data-state__skeleton-filters,
|
||||
.data-state__skeleton-actions {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.data-state__skeleton-actions {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.data-state__skeleton-filter {
|
||||
width: 150px;
|
||||
height: var(--control-height);
|
||||
}
|
||||
|
||||
.data-state__skeleton-reset,
|
||||
.data-state__skeleton-action {
|
||||
width: 84px;
|
||||
height: var(--control-height);
|
||||
}
|
||||
|
||||
.data-state__skeleton-table {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.data-state__skeleton-head {
|
||||
height: var(--table-head-height);
|
||||
border-radius: 0;
|
||||
background-color: var(--bg-card-strong);
|
||||
}
|
||||
|
||||
.data-state__skeleton-row {
|
||||
display: grid;
|
||||
min-height: var(--table-row-height);
|
||||
align-items: center;
|
||||
grid-template-columns: minmax(180px, 1.35fr) minmax(100px, 0.7fr) minmax(140px, 0.95fr) minmax(90px, 0.55fr) minmax(54px, 0.35fr);
|
||||
gap: var(--space-4);
|
||||
padding: 0 var(--space-4);
|
||||
border-top: 1px solid var(--row-border);
|
||||
}
|
||||
|
||||
.data-state__skeleton-cell {
|
||||
width: min(100%, 150px);
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.data-state__skeleton-cell--main {
|
||||
width: min(100%, 220px);
|
||||
}
|
||||
|
||||
.data-state__skeleton-cell--short {
|
||||
width: 72px;
|
||||
}
|
||||
|
||||
.data-state__skeleton-cell--action {
|
||||
width: 34px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.data-state__title {
|
||||
|
||||
1
var/admin-dev.pid
Normal file
1
var/admin-dev.pid
Normal file
@ -0,0 +1 @@
|
||||
99664
|
||||
Loading…
x
Reference in New Issue
Block a user