优化UI完善功能
This commit is contained in:
parent
f5a6c6685c
commit
bdeb3cd15a
@ -10,6 +10,56 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"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": {
|
"/admin/activity/daily-tasks": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listTaskDefinitions",
|
"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": {
|
"/admin/files/image/upload": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "uploadImage",
|
"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": {
|
"/admin/operations/coin-ledger": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listCoinLedger",
|
"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": {
|
"/app/users": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "appListUsers",
|
"operationId": "appListUsers",
|
||||||
|
|||||||
@ -84,6 +84,7 @@ export const fallbackNavigation = [
|
|||||||
routeNavItem("app-user-list", { icon: ManageAccountsOutlined }),
|
routeNavItem("app-user-list", { icon: ManageAccountsOutlined }),
|
||||||
routeNavItem("app-user-bans", { icon: BlockOutlined }),
|
routeNavItem("app-user-bans", { icon: BlockOutlined }),
|
||||||
routeNavItem("app-user-login-logs", { icon: LoginOutlined }),
|
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("resource-group-list", { icon: CategoryOutlined }),
|
||||||
routeNavItem("gift-list", { icon: CardGiftcardOutlined }),
|
routeNavItem("gift-list", { icon: CardGiftcardOutlined }),
|
||||||
routeNavItem("resource-grant-list", { icon: SendOutlined }),
|
routeNavItem("resource-grant-list", { icon: SendOutlined }),
|
||||||
|
routeNavItem("emoji-pack-list", { icon: ImageOutlined }),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -126,7 +128,10 @@ export const fallbackNavigation = [
|
|||||||
icon: ReceiptLongOutlined,
|
icon: ReceiptLongOutlined,
|
||||||
id: "operations",
|
id: "operations",
|
||||||
label: "运营管理",
|
label: "运营管理",
|
||||||
children: [routeNavItem("operation-coin-ledger", { icon: ReceiptLongOutlined })],
|
children: [
|
||||||
|
routeNavItem("operation-coin-ledger", { icon: ReceiptLongOutlined }),
|
||||||
|
routeNavItem("operation-coin-adjustment", { icon: WalletOutlined }),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: "activities",
|
code: "activities",
|
||||||
|
|||||||
@ -26,6 +26,8 @@ export const PERMISSIONS = {
|
|||||||
coinSellerUpdate: "coin-seller:update",
|
coinSellerUpdate: "coin-seller:update",
|
||||||
coinSellerStockCredit: "coin-seller:stock-credit",
|
coinSellerStockCredit: "coin-seller:stock-credit",
|
||||||
coinLedgerView: "coin-ledger:view",
|
coinLedgerView: "coin-ledger:view",
|
||||||
|
coinAdjustmentView: "coin-adjustment:view",
|
||||||
|
coinAdjustmentCreate: "coin-adjustment:create",
|
||||||
paymentBillView: "payment-bill:view",
|
paymentBillView: "payment-bill:view",
|
||||||
paymentProductView: "payment-product:view",
|
paymentProductView: "payment-product:view",
|
||||||
paymentProductCreate: "payment-product:create",
|
paymentProductCreate: "payment-product:create",
|
||||||
@ -59,6 +61,10 @@ export const PERMISSIONS = {
|
|||||||
appUserUpdate: "app-user:update",
|
appUserUpdate: "app-user:update",
|
||||||
appUserStatus: "app-user:status",
|
appUserStatus: "app-user:status",
|
||||||
appUserPassword: "app-user:password",
|
appUserPassword: "app-user:password",
|
||||||
|
levelConfigView: "level-config:view",
|
||||||
|
levelConfigUpdate: "level-config:update",
|
||||||
|
regionBlockView: "region-block:view",
|
||||||
|
regionBlockUpdate: "region-block:update",
|
||||||
roomView: "room:view",
|
roomView: "room:view",
|
||||||
roomUpdate: "room:update",
|
roomUpdate: "room:update",
|
||||||
roomDelete: "room:delete",
|
roomDelete: "room:delete",
|
||||||
@ -85,10 +91,15 @@ export const PERMISSIONS = {
|
|||||||
giftCreate: "gift:create",
|
giftCreate: "gift:create",
|
||||||
giftUpdate: "gift:update",
|
giftUpdate: "gift:update",
|
||||||
giftStatus: "gift:status",
|
giftStatus: "gift:status",
|
||||||
|
emojiPackView: "emoji-pack:view",
|
||||||
|
emojiPackCreate: "emoji-pack:create",
|
||||||
dailyTaskView: "daily-task:view",
|
dailyTaskView: "daily-task:view",
|
||||||
dailyTaskCreate: "daily-task:create",
|
dailyTaskCreate: "daily-task:create",
|
||||||
dailyTaskUpdate: "daily-task:update",
|
dailyTaskUpdate: "daily-task:update",
|
||||||
dailyTaskStatus: "daily-task:status",
|
dailyTaskStatus: "daily-task:status",
|
||||||
|
achievementView: "achievement:view",
|
||||||
|
achievementCreate: "achievement:create",
|
||||||
|
achievementUpdate: "achievement:update",
|
||||||
registrationRewardView: "registration-reward:view",
|
registrationRewardView: "registration-reward:view",
|
||||||
registrationRewardUpdate: "registration-reward:update",
|
registrationRewardUpdate: "registration-reward:update",
|
||||||
uploadCreate: "upload:create",
|
uploadCreate: "upload:create",
|
||||||
@ -114,6 +125,8 @@ export const MENU_CODES = {
|
|||||||
appUserList: "app-user-list",
|
appUserList: "app-user-list",
|
||||||
appUserBans: "app-user-bans",
|
appUserBans: "app-user-bans",
|
||||||
appUserLoginLogs: "app-user-login-logs",
|
appUserLoginLogs: "app-user-login-logs",
|
||||||
|
appUserLevelConfig: "app-user-level-config",
|
||||||
|
appUserRegionBlocks: "app-user-region-blocks",
|
||||||
rooms: "rooms",
|
rooms: "rooms",
|
||||||
roomList: "room-list",
|
roomList: "room-list",
|
||||||
roomPins: "room-pins",
|
roomPins: "room-pins",
|
||||||
@ -127,11 +140,14 @@ export const MENU_CODES = {
|
|||||||
resourceGroupList: "resource-group-list",
|
resourceGroupList: "resource-group-list",
|
||||||
resourceGrantList: "resource-grant-list",
|
resourceGrantList: "resource-grant-list",
|
||||||
giftList: "gift-list",
|
giftList: "gift-list",
|
||||||
|
emojiPackList: "emoji-pack-list",
|
||||||
operations: "operations",
|
operations: "operations",
|
||||||
operationCoinLedger: "operation-coin-ledger",
|
operationCoinLedger: "operation-coin-ledger",
|
||||||
|
operationCoinAdjustment: "operation-coin-adjustment",
|
||||||
activities: "activities",
|
activities: "activities",
|
||||||
dailyTaskList: "daily-task-list",
|
dailyTaskList: "daily-task-list",
|
||||||
registrationReward: "registration-reward",
|
registrationReward: "registration-reward",
|
||||||
|
achievementConfig: "achievement-config",
|
||||||
geo: "geo",
|
geo: "geo",
|
||||||
hostOrg: "host-org",
|
hostOrg: "host-org",
|
||||||
hostOrgCountries: "host-org-countries",
|
hostOrgCountries: "host-org-countries",
|
||||||
|
|||||||
@ -1,15 +1,18 @@
|
|||||||
import { authRoutes } from "@/features/auth/routes.js";
|
import { authRoutes } from "@/features/auth/routes.js";
|
||||||
|
import { achievementRoutes } from "@/features/achievements/routes.js";
|
||||||
import { appConfigRoutes } from "@/features/app-config/routes.js";
|
import { appConfigRoutes } from "@/features/app-config/routes.js";
|
||||||
import { appUserRoutes } from "@/features/app-users/routes.js";
|
import { appUserRoutes } from "@/features/app-users/routes.js";
|
||||||
import { dashboardRoutes } from "@/features/dashboard/routes.js";
|
import { dashboardRoutes } from "@/features/dashboard/routes.js";
|
||||||
import { dailyTaskRoutes } from "@/features/daily-tasks/routes.js";
|
import { dailyTaskRoutes } from "@/features/daily-tasks/routes.js";
|
||||||
import { gameRoutes } from "@/features/games/routes.js";
|
import { gameRoutes } from "@/features/games/routes.js";
|
||||||
import { hostOrgRoutes } from "@/features/host-org/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 { logsRoutes } from "@/features/logs/routes.js";
|
||||||
import { menusRoutes } from "@/features/menus/routes.js";
|
import { menusRoutes } from "@/features/menus/routes.js";
|
||||||
import { operationsRoutes } from "@/features/operations/routes.js";
|
import { operationsRoutes } from "@/features/operations/routes.js";
|
||||||
import { paymentRoutes } from "@/features/payment/routes.js";
|
import { paymentRoutes } from "@/features/payment/routes.js";
|
||||||
import { registrationRewardRoutes } from "@/features/registration-reward/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 { resourceRoutes } from "@/features/resources/routes.js";
|
||||||
import { rolesRoutes } from "@/features/roles/routes.js";
|
import { rolesRoutes } from "@/features/roles/routes.js";
|
||||||
import { roomRoutes } from "@/features/rooms/routes.js";
|
import { roomRoutes } from "@/features/rooms/routes.js";
|
||||||
@ -22,9 +25,12 @@ export const publicRoutes: PublicRoute[] = authRoutes;
|
|||||||
export const adminRoutes: AdminRoute[] = [
|
export const adminRoutes: AdminRoute[] = [
|
||||||
...dashboardRoutes,
|
...dashboardRoutes,
|
||||||
...appUserRoutes,
|
...appUserRoutes,
|
||||||
|
...levelConfigRoutes,
|
||||||
|
...regionBlockRoutes,
|
||||||
...roomRoutes,
|
...roomRoutes,
|
||||||
...appConfigRoutes,
|
...appConfigRoutes,
|
||||||
...dailyTaskRoutes,
|
...dailyTaskRoutes,
|
||||||
|
...achievementRoutes,
|
||||||
...registrationRewardRoutes,
|
...registrationRewardRoutes,
|
||||||
...resourceRoutes,
|
...resourceRoutes,
|
||||||
...operationsRoutes,
|
...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 { apiRequest } from "@/shared/api/request";
|
||||||
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
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>> {
|
export function listCoinLedger(query: PageQuery = {}): Promise<ApiPage<CoinLedgerEntryDto>> {
|
||||||
const endpoint = API_ENDPOINTS.listCoinLedger;
|
const endpoint = API_ENDPOINTS.listCoinLedger;
|
||||||
@ -9,3 +17,30 @@ export function listCoinLedger(query: PageQuery = {}): Promise<ApiPage<CoinLedge
|
|||||||
query,
|
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);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.adjustmentAmount {
|
||||||
|
font-size: calc(var(--admin-font-size) + 4px);
|
||||||
|
}
|
||||||
|
|
||||||
.clickableRow:hover {
|
.clickableRow:hover {
|
||||||
background: var(--primary-hover);
|
background: var(--primary-hover);
|
||||||
}
|
}
|
||||||
@ -163,8 +167,30 @@
|
|||||||
word-break: break-word;
|
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) {
|
@media (max-width: 640px) {
|
||||||
.detailGrid {
|
.detailGrid {
|
||||||
grid-template-columns: 1fr;
|
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",
|
path: "/operations/coin-ledger",
|
||||||
permission: PERMISSIONS.coinLedgerView,
|
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() {
|
export function useRegistrationRewardPage() {
|
||||||
const abilities = useRegistrationRewardAbilities();
|
const abilities = useRegistrationRewardAbilities();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
|
const [config, setConfig] = useState(null);
|
||||||
|
const [configDrawerOpen, setConfigDrawerOpen] = useState(false);
|
||||||
const [form, setForm] = useState(emptyForm);
|
const [form, setForm] = useState(emptyForm);
|
||||||
const [configLoading, setConfigLoading] = useState(false);
|
const [configLoading, setConfigLoading] = useState(false);
|
||||||
const [configSaving, setConfigSaving] = useState(false);
|
const [configSaving, setConfigSaving] = useState(false);
|
||||||
@ -52,6 +54,7 @@ export function useRegistrationRewardPage() {
|
|||||||
getRegistrationRewardConfig(),
|
getRegistrationRewardConfig(),
|
||||||
listResourceGroups({ page: 1, page_size: 100, status: "active" }),
|
listResourceGroups({ page: 1, page_size: 100, status: "active" }),
|
||||||
]);
|
]);
|
||||||
|
setConfig(config);
|
||||||
setForm(formFromConfig(config));
|
setForm(formFromConfig(config));
|
||||||
setResourceGroups(groups.items || []);
|
setResourceGroups(groups.items || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -65,6 +68,22 @@ export function useRegistrationRewardPage() {
|
|||||||
void reloadConfig();
|
void reloadConfig();
|
||||||
}, [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) => {
|
const changeQuery = (value) => {
|
||||||
setQuery(value);
|
setQuery(value);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
@ -89,7 +108,9 @@ export function useRegistrationRewardPage() {
|
|||||||
setConfigSaving(true);
|
setConfigSaving(true);
|
||||||
try {
|
try {
|
||||||
const saved = await updateRegistrationRewardConfig(payloadFromForm(form));
|
const saved = await updateRegistrationRewardConfig(payloadFromForm(form));
|
||||||
|
setConfig(saved);
|
||||||
setForm(formFromConfig(saved));
|
setForm(formFromConfig(saved));
|
||||||
|
setConfigDrawerOpen(false);
|
||||||
showToast("注册奖励配置已保存", "success");
|
showToast("注册奖励配置已保存", "success");
|
||||||
await reloadClaims();
|
await reloadClaims();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -106,9 +127,13 @@ export function useRegistrationRewardPage() {
|
|||||||
claims,
|
claims,
|
||||||
claimsError,
|
claimsError,
|
||||||
claimsLoading,
|
claimsLoading,
|
||||||
|
closeConfigDrawer,
|
||||||
|
config,
|
||||||
|
configDrawerOpen,
|
||||||
configLoading,
|
configLoading,
|
||||||
configSaving,
|
configSaving,
|
||||||
form,
|
form,
|
||||||
|
openConfigDrawer,
|
||||||
page,
|
page,
|
||||||
query,
|
query,
|
||||||
reloadClaims,
|
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 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 { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
import {
|
import { AdminListBody, AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
|
||||||
AdminActionIconButton,
|
|
||||||
AdminListBody,
|
|
||||||
AdminListPage,
|
|
||||||
AdminListToolbar,
|
|
||||||
} from "@/shared/ui/AdminListLayout.jsx";
|
|
||||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
import { formatMillis } from "@/shared/utils/time.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 { useRegistrationRewardPage } from "@/features/registration-reward/hooks/useRegistrationRewardPage.js";
|
||||||
import styles from "@/features/registration-reward/registration-reward.module.css";
|
import styles from "@/features/registration-reward/registration-reward.module.css";
|
||||||
|
|
||||||
const rewardTypeOptions = [
|
|
||||||
["coin", "金币"],
|
|
||||||
["resource_group", "资源组"],
|
|
||||||
];
|
|
||||||
|
|
||||||
const claimStatusOptions = [
|
const claimStatusOptions = [
|
||||||
["pending", "发放中"],
|
["pending", "发放中"],
|
||||||
["granted", "已发放"],
|
["granted", "已发放"],
|
||||||
@ -103,117 +89,13 @@ export function RegistrationRewardPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminListPage>
|
<AdminListPage>
|
||||||
<form className={styles.configPanel} onSubmit={page.submitConfig}>
|
<RegistrationRewardConfigSummary
|
||||||
<div className={styles.configHeader}>
|
canUpdate={page.abilities.canUpdate}
|
||||||
<div className={styles.stack}>
|
config={page.config}
|
||||||
<span className={styles.title}>注册奖励配置</span>
|
configLoading={page.configLoading}
|
||||||
<span className={styles.meta}>注册成功后由服务端自动发放</span>
|
resourceGroups={page.resourceGroups}
|
||||||
</div>
|
onEdit={page.openConfigDrawer}
|
||||||
<div className={styles.configActions}>
|
onRefresh={page.reloadConfig}
|
||||||
<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>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<DataState error={page.claimsError} loading={page.claimsLoading} onRetry={page.reloadClaims}>
|
<DataState error={page.claimsError} loading={page.claimsLoading} onRetry={page.reloadClaims}>
|
||||||
<AdminListBody>
|
<AdminListBody>
|
||||||
@ -235,6 +117,17 @@ export function RegistrationRewardPage() {
|
|||||||
/>
|
/>
|
||||||
</AdminListBody>
|
</AdminListBody>
|
||||||
</DataState>
|
</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>
|
</AdminListPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,55 +1,70 @@
|
|||||||
.configPanel {
|
.summaryPanel {
|
||||||
display: grid;
|
|
||||||
gap: var(--space-4);
|
|
||||||
padding: var(--space-5);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.configHeader {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
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;
|
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;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.configSections {
|
.summaryLabel {
|
||||||
display: grid;
|
flex: 0 0 auto;
|
||||||
gap: var(--space-4);
|
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 {
|
.summaryValue {
|
||||||
display: grid;
|
display: inline-flex;
|
||||||
gap: var(--space-3);
|
min-width: 0;
|
||||||
}
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
.configSection + .configSection {
|
color: var(--text-primary);
|
||||||
padding-top: var(--space-4);
|
|
||||||
border-top: 1px solid var(--border-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.configSectionTitle {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 18px;
|
line-height: var(--admin-line-height);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.configGrid {
|
.summaryValue > :global(.status-badge) {
|
||||||
display: grid;
|
min-width: 0;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.switchField {
|
|
||||||
min-height: var(--control-height);
|
|
||||||
margin: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.identity {
|
.identity {
|
||||||
@ -83,12 +98,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.configHeader {
|
.summaryPanel {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.configGrid {
|
.summaryHeader {
|
||||||
grid-template-columns: 1fr;
|
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 { afterEach, expect, test, vi } from "vitest";
|
||||||
import { setAccessToken } from "@/shared/api/request";
|
import { setAccessToken } from "@/shared/api/request";
|
||||||
import {
|
import {
|
||||||
|
createEmojiPack,
|
||||||
createGift,
|
createGift,
|
||||||
createResource,
|
createResource,
|
||||||
createResourceGroup,
|
createResourceGroup,
|
||||||
@ -12,6 +13,8 @@ import {
|
|||||||
enableResourceGroup,
|
enableResourceGroup,
|
||||||
grantResource,
|
grantResource,
|
||||||
grantResourceGroup,
|
grantResourceGroup,
|
||||||
|
listEmojiPackCategories,
|
||||||
|
listEmojiPacks,
|
||||||
listResourceGrants,
|
listResourceGrants,
|
||||||
listGifts,
|
listGifts,
|
||||||
listResources,
|
listResources,
|
||||||
@ -212,3 +215,39 @@ test("resource APIs use generated admin paths", async () => {
|
|||||||
expect(grantGroupInit?.method).toBe("POST");
|
expect(grantGroupInit?.method).toBe("POST");
|
||||||
expect(JSON.parse(String(grantGroupInit?.body))).toMatchObject({ groupId: 22, targetUserId: 1001 });
|
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 {
|
export interface ResourcePayload {
|
||||||
amount: number;
|
amount: number;
|
||||||
|
animationUrl?: string;
|
||||||
assetUrl: string;
|
assetUrl: string;
|
||||||
name: string;
|
name: string;
|
||||||
previewUrl: string;
|
previewUrl: string;
|
||||||
@ -34,6 +35,32 @@ export interface ResourcePayload {
|
|||||||
status: string;
|
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 {
|
export interface ResourceGroupItemDto {
|
||||||
groupItemId: number;
|
groupItemId: number;
|
||||||
itemType?: string;
|
itemType?: string;
|
||||||
@ -124,6 +151,13 @@ export interface ResourceGrantOperatorDto {
|
|||||||
username?: string;
|
username?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ResourceGrantUserDto {
|
||||||
|
avatar?: string;
|
||||||
|
displayUserId?: string;
|
||||||
|
userId?: number | string;
|
||||||
|
username?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ResourceGrantDto {
|
export interface ResourceGrantDto {
|
||||||
appCode?: string;
|
appCode?: string;
|
||||||
commandId?: string;
|
commandId?: string;
|
||||||
@ -137,7 +171,8 @@ export interface ResourceGrantDto {
|
|||||||
operatorUserId?: number;
|
operatorUserId?: number;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
targetUserId?: number;
|
targetUser?: ResourceGrantUserDto;
|
||||||
|
targetUserId?: number | string;
|
||||||
updatedAtMs?: number;
|
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>> {
|
export function listResourceGrants(query: PageQuery = {}): Promise<ApiPage<ResourceGrantDto>> {
|
||||||
const endpoint = API_ENDPOINTS.listResourceGrants;
|
const endpoint = API_ENDPOINTS.listResourceGrants;
|
||||||
return apiRequest<ApiPage<ResourceGrantDto>>(apiEndpointPath(API_OPERATIONS.listResourceGrants), {
|
return apiRequest<ApiPage<ResourceGrantDto>>(apiEndpointPath(API_OPERATIONS.listResourceGrants), {
|
||||||
|
|||||||
@ -23,6 +23,11 @@ export const resourceGrantSubjectLabels = {
|
|||||||
resource_group: "资源组",
|
resource_group: "资源组",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const emojiPackPricingLabels = {
|
||||||
|
free: "免费",
|
||||||
|
paid: "付费",
|
||||||
|
};
|
||||||
|
|
||||||
export const resourceTypeFilters = [
|
export const resourceTypeFilters = [
|
||||||
["", "全部类型"],
|
["", "全部类型"],
|
||||||
["avatar_frame", "头像框"],
|
["avatar_frame", "头像框"],
|
||||||
@ -33,6 +38,9 @@ export const resourceTypeFilters = [
|
|||||||
["badge", "徽章"],
|
["badge", "徽章"],
|
||||||
["floating_screen", "飘屏"],
|
["floating_screen", "飘屏"],
|
||||||
["gift", "礼物"],
|
["gift", "礼物"],
|
||||||
|
["mic_seat_icon", "麦位图标"],
|
||||||
|
["mic_seat_animation", "麦位动效"],
|
||||||
|
["emoji_pack", "表情包"],
|
||||||
];
|
];
|
||||||
|
|
||||||
export const resourceTypeLabels = Object.fromEntries(resourceTypeFilters.filter(([value]) => value));
|
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 { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
import {
|
import {
|
||||||
|
createEmojiPack,
|
||||||
createGift,
|
createGift,
|
||||||
createResource,
|
createResource,
|
||||||
createResourceGroup,
|
createResourceGroup,
|
||||||
@ -15,6 +16,8 @@ import {
|
|||||||
enableResourceGroup,
|
enableResourceGroup,
|
||||||
grantResource,
|
grantResource,
|
||||||
grantResourceGroup,
|
grantResourceGroup,
|
||||||
|
listEmojiPackCategories,
|
||||||
|
listEmojiPacks,
|
||||||
listGifts,
|
listGifts,
|
||||||
listResourceGrants,
|
listResourceGrants,
|
||||||
listResourceGroups,
|
listResourceGroups,
|
||||||
@ -25,6 +28,7 @@ import {
|
|||||||
} from "@/features/resources/api";
|
} from "@/features/resources/api";
|
||||||
import { useResourceAbilities } from "@/features/resources/permissions.js";
|
import { useResourceAbilities } from "@/features/resources/permissions.js";
|
||||||
import {
|
import {
|
||||||
|
emojiPackFormSchema,
|
||||||
giftFormSchema,
|
giftFormSchema,
|
||||||
resourceFormSchema,
|
resourceFormSchema,
|
||||||
resourceGrantFormSchema,
|
resourceGrantFormSchema,
|
||||||
@ -35,7 +39,7 @@ const pageSize = 10;
|
|||||||
const dayMillis = 24 * 60 * 60 * 1000;
|
const dayMillis = 24 * 60 * 60 * 1000;
|
||||||
const emptyData = { items: [], page: 1, pageSize, total: 0 };
|
const emptyData = { items: [], page: 1, pageSize, total: 0 };
|
||||||
const emptyResourceForm = (resource = {}) => ({
|
const emptyResourceForm = (resource = {}) => ({
|
||||||
assetUrl: resource.assetUrl || "",
|
animationUrl: resource.animationUrl || resource.assetUrl || "",
|
||||||
enabled: resource.status ? resource.status === "active" : true,
|
enabled: resource.status ? resource.status === "active" : true,
|
||||||
name: resource.name || "",
|
name: resource.name || "",
|
||||||
previewUrl: resource.previewUrl || "",
|
previewUrl: resource.previewUrl || "",
|
||||||
@ -43,6 +47,15 @@ const emptyResourceForm = (resource = {}) => ({
|
|||||||
resourceType: resource.resourceType || "avatar_frame",
|
resourceType: resource.resourceType || "avatar_frame",
|
||||||
walletAssetAmount: resource.walletAssetAmount ? String(resource.walletAssetAmount) : "",
|
walletAssetAmount: resource.walletAssetAmount ? String(resource.walletAssetAmount) : "",
|
||||||
});
|
});
|
||||||
|
const emptyEmojiPackForm = () => ({
|
||||||
|
animationUrl: "",
|
||||||
|
category: "默认",
|
||||||
|
coverUrl: "",
|
||||||
|
name: "",
|
||||||
|
pricingType: "free",
|
||||||
|
regionIds: ["0"],
|
||||||
|
sortOrder: "0",
|
||||||
|
});
|
||||||
const emptyResourceGroupForm = (group = {}) => ({
|
const emptyResourceGroupForm = (group = {}) => ({
|
||||||
description: group.description || "",
|
description: group.description || "",
|
||||||
enabled: group.status ? group.status === "active" : true,
|
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() {
|
export function useResourceGrantListPage() {
|
||||||
const abilities = useResourceAbilities();
|
const abilities = useResourceAbilities();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
@ -759,9 +904,11 @@ function resetSetter(setValue, setPage) {
|
|||||||
function buildResourcePayload(form) {
|
function buildResourcePayload(form) {
|
||||||
const resourceType = form.resourceType;
|
const resourceType = form.resourceType;
|
||||||
const isCoin = resourceType === "coin";
|
const isCoin = resourceType === "coin";
|
||||||
|
const animationUrl = form.animationUrl.trim();
|
||||||
return {
|
return {
|
||||||
amount: isCoin ? Number(form.walletAssetAmount) : 0,
|
amount: isCoin ? Number(form.walletAssetAmount) : 0,
|
||||||
assetUrl: form.assetUrl.trim(),
|
animationUrl,
|
||||||
|
assetUrl: animationUrl,
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
previewUrl: form.previewUrl.trim(),
|
previewUrl: form.previewUrl.trim(),
|
||||||
resourceCode: form.resourceCode.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) {
|
function buildResourceGroupPayload(form) {
|
||||||
return {
|
return {
|
||||||
description: (form.description || "").trim(),
|
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",
|
key: "target",
|
||||||
label: "用户 ID",
|
label: "用户信息",
|
||||||
width: "minmax(120px, 0.7fr)",
|
width: "minmax(240px, 1.1fr)",
|
||||||
render: (grant) => grant.targetUserId || "-",
|
render: (grant) => <GrantTargetUser grant={grant} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "operator",
|
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 }) {
|
function GrantOperator({ grant }) {
|
||||||
const operator = grant.operator || {};
|
const operator = grant.operator || {};
|
||||||
const source = operator.source || grant.grantSource || "";
|
const source = operator.source || grant.grantSource || "";
|
||||||
|
|||||||
@ -23,7 +23,6 @@ import {
|
|||||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
import {
|
import {
|
||||||
grantStrategyLabels,
|
|
||||||
resourceStatusFilters,
|
resourceStatusFilters,
|
||||||
resourceTypeFilters,
|
resourceTypeFilters,
|
||||||
resourceTypeLabels,
|
resourceTypeLabels,
|
||||||
@ -31,7 +30,7 @@ import {
|
|||||||
import { useResourceListPage } from "@/features/resources/hooks/useResourcePages.js";
|
import { useResourceListPage } from "@/features/resources/hooks/useResourcePages.js";
|
||||||
import styles from "@/features/resources/resources.module.css";
|
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 = [
|
const baseColumns = [
|
||||||
{
|
{
|
||||||
@ -46,18 +45,6 @@ const baseColumns = [
|
|||||||
width: "minmax(120px, 0.7fr)",
|
width: "minmax(120px, 0.7fr)",
|
||||||
render: (resource) => resourceTypeLabels[resource.resourceType] || resource.resourceType || "-",
|
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",
|
key: "status",
|
||||||
label: "状态",
|
label: "状态",
|
||||||
@ -102,22 +89,22 @@ export function ResourceListPage() {
|
|||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
: column.key === "status"
|
: column.key === "status"
|
||||||
? {
|
? {
|
||||||
...column,
|
...column,
|
||||||
filter: createOptionsColumnFilter({
|
filter: createOptionsColumnFilter({
|
||||||
options: resourceStatusFilters,
|
options: resourceStatusFilters,
|
||||||
placeholder: "搜索状态",
|
placeholder: "搜索状态",
|
||||||
value: page.status,
|
value: page.status,
|
||||||
onChange: page.changeStatus,
|
onChange: page.changeStatus,
|
||||||
}),
|
}),
|
||||||
render: (resource) => <ResourceStatusSwitch page={page} resource={resource} />,
|
render: (resource) => <ResourceStatusSwitch page={page} resource={resource} />,
|
||||||
}
|
}
|
||||||
: column.key === "actions"
|
: column.key === "actions"
|
||||||
? {
|
? {
|
||||||
...column,
|
...column,
|
||||||
render: (resource) => <ResourceRowActions page={page} resource={resource} />,
|
render: (resource) => <ResourceRowActions page={page} resource={resource} />,
|
||||||
}
|
}
|
||||||
: column,
|
: column,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -136,7 +123,7 @@ export function ResourceListPage() {
|
|||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
items={items}
|
items={items}
|
||||||
minWidth="1120px"
|
minWidth="820px"
|
||||||
pagination={
|
pagination={
|
||||||
total > 0
|
total > 0
|
||||||
? {
|
? {
|
||||||
@ -217,7 +204,9 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
|
|||||||
required
|
required
|
||||||
select
|
select
|
||||||
value={form.resourceType}
|
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]) => (
|
{resourceTypeOptions.map(([value, label]) => (
|
||||||
<MenuItem key={value} value={value}>
|
<MenuItem key={value} value={value}>
|
||||||
@ -241,15 +230,17 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
|
|||||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||||
<UploadField
|
<UploadField
|
||||||
disabled={disabled || uploadDisabled}
|
disabled={disabled || uploadDisabled}
|
||||||
|
kind="file"
|
||||||
label="资源封面"
|
label="资源封面"
|
||||||
value={form.previewUrl}
|
value={form.previewUrl}
|
||||||
onChange={(previewUrl) => setForm({ ...form, previewUrl })}
|
onChange={(previewUrl) => setForm({ ...form, previewUrl })}
|
||||||
/>
|
/>
|
||||||
<UploadField
|
<UploadField
|
||||||
disabled={disabled || uploadDisabled}
|
disabled={disabled || uploadDisabled}
|
||||||
label="资源素材"
|
kind="file"
|
||||||
value={form.assetUrl}
|
label="动效素材"
|
||||||
onChange={(assetUrl) => setForm({ ...form, assetUrl })}
|
value={form.animationUrl}
|
||||||
|
onChange={(animationUrl) => setForm({ ...form, animationUrl })}
|
||||||
/>
|
/>
|
||||||
</AdminFormFieldGrid>
|
</AdminFormFieldGrid>
|
||||||
</AdminFormSection>
|
</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 }) {
|
function ResourceStatusSwitch({ page, resource }) {
|
||||||
const checked = resource.status === "active";
|
const checked = resource.status === "active";
|
||||||
return (
|
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) {
|
function imageURL(value) {
|
||||||
const url = String(value || "").trim();
|
const url = String(value || "").trim();
|
||||||
if (!url) {
|
if (!url) {
|
||||||
@ -332,7 +292,3 @@ function imageURL(value) {
|
|||||||
}
|
}
|
||||||
return /\.(avif|gif|jpe?g|png|webp)(\?|#|$)/i.test(url) ? url : "";
|
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";
|
import { PERMISSIONS } from "@/app/permissions";
|
||||||
|
|
||||||
export function useResourceAbilities() {
|
export function useResourceAbilities() {
|
||||||
const { can } = useAuth();
|
const { can } = useAuth();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
canCreate: can(PERMISSIONS.resourceCreate),
|
canCreate: can(PERMISSIONS.resourceCreate),
|
||||||
canCreateGift: can(PERMISSIONS.giftCreate),
|
canCreateGift: can(PERMISSIONS.giftCreate),
|
||||||
canCreateGrant: can(PERMISSIONS.resourceGrantCreate),
|
canCreateGrant: can(PERMISSIONS.resourceGrantCreate),
|
||||||
canCreateGroup: can(PERMISSIONS.resourceGroupCreate),
|
canCreateGroup: can(PERMISSIONS.resourceGroupCreate),
|
||||||
canStatusGift: can(PERMISSIONS.giftStatus),
|
canCreateEmojiPack: can(PERMISSIONS.emojiPackCreate),
|
||||||
canUpdateGroup: can(PERMISSIONS.resourceGroupUpdate),
|
canStatusGift: can(PERMISSIONS.giftStatus),
|
||||||
canUpdateGift: can(PERMISSIONS.giftUpdate),
|
canUpdateGroup: can(PERMISSIONS.resourceGroupUpdate),
|
||||||
canUpdate: can(PERMISSIONS.resourceUpdate),
|
canUpdateGift: can(PERMISSIONS.giftUpdate),
|
||||||
canUpload: can(PERMISSIONS.uploadCreate),
|
canUpdate: can(PERMISSIONS.resourceUpdate),
|
||||||
canView: can(PERMISSIONS.resourceView),
|
canUpload: can(PERMISSIONS.uploadCreate),
|
||||||
canViewGrant: can(PERMISSIONS.resourceGrantView)
|
canView: can(PERMISSIONS.resourceView),
|
||||||
};
|
canViewEmojiPack: can(PERMISSIONS.emojiPackView),
|
||||||
|
canViewGrant: can(PERMISSIONS.resourceGrantView),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,36 +1,44 @@
|
|||||||
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
|
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
|
||||||
|
|
||||||
export const resourceRoutes = [
|
export const resourceRoutes = [
|
||||||
{
|
{
|
||||||
label: "资源列表",
|
label: "资源列表",
|
||||||
loader: () => import("./pages/ResourceListPage.jsx").then((module) => module.ResourceListPage),
|
loader: () => import("./pages/ResourceListPage.jsx").then((module) => module.ResourceListPage),
|
||||||
menuCode: MENU_CODES.resourceList,
|
menuCode: MENU_CODES.resourceList,
|
||||||
pageKey: "resource-list",
|
pageKey: "resource-list",
|
||||||
path: "/resources",
|
path: "/resources",
|
||||||
permission: PERMISSIONS.resourceView
|
permission: PERMISSIONS.resourceView,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "资源组列表",
|
label: "资源组列表",
|
||||||
loader: () => import("./pages/ResourceGroupListPage.jsx").then((module) => module.ResourceGroupListPage),
|
loader: () => import("./pages/ResourceGroupListPage.jsx").then((module) => module.ResourceGroupListPage),
|
||||||
menuCode: MENU_CODES.resourceGroupList,
|
menuCode: MENU_CODES.resourceGroupList,
|
||||||
pageKey: "resource-group-list",
|
pageKey: "resource-group-list",
|
||||||
path: "/resource-groups",
|
path: "/resource-groups",
|
||||||
permission: PERMISSIONS.resourceGroupView
|
permission: PERMISSIONS.resourceGroupView,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "礼物列表",
|
label: "礼物列表",
|
||||||
loader: () => import("./pages/GiftListPage.jsx").then((module) => module.GiftListPage),
|
loader: () => import("./pages/GiftListPage.jsx").then((module) => module.GiftListPage),
|
||||||
menuCode: MENU_CODES.giftList,
|
menuCode: MENU_CODES.giftList,
|
||||||
pageKey: "gift-list",
|
pageKey: "gift-list",
|
||||||
path: "/gifts",
|
path: "/gifts",
|
||||||
permission: PERMISSIONS.giftView
|
permission: PERMISSIONS.giftView,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "资源赠送",
|
label: "表情包列表",
|
||||||
loader: () => import("./pages/ResourceGrantListPage.jsx").then((module) => module.ResourceGrantListPage),
|
loader: () => import("./pages/EmojiPackListPage.jsx").then((module) => module.EmojiPackListPage),
|
||||||
menuCode: MENU_CODES.resourceGrantList,
|
menuCode: MENU_CODES.emojiPackList,
|
||||||
pageKey: "resource-grant-list",
|
pageKey: "emoji-pack-list",
|
||||||
path: "/resource-grants",
|
path: "/emoji-packs",
|
||||||
permission: PERMISSIONS.resourceGrantView
|
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";
|
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 walletAssetTypes = ["COIN", "DIAMOND"];
|
||||||
const optionalWalletAssetTypeSchema = z
|
const optionalWalletAssetTypeSchema = z
|
||||||
.preprocess((value) => {
|
.preprocess((value) => {
|
||||||
@ -14,7 +26,7 @@ const optionalWalletAssetTypeSchema = z
|
|||||||
|
|
||||||
export const resourceCreateFormSchema = z
|
export const resourceCreateFormSchema = z
|
||||||
.object({
|
.object({
|
||||||
assetUrl: z.string().trim().min(1, "请上传资源素材").max(512, "资源素材不能超过 512 个字符"),
|
animationUrl: z.string().trim().min(1, "请上传动效素材").max(512, "动效素材不能超过 512 个字符"),
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
name: z.string().trim().min(1, "请输入资源名称").max(128, "资源名称不能超过 128 个字符"),
|
name: z.string().trim().min(1, "请输入资源名称").max(128, "资源名称不能超过 128 个字符"),
|
||||||
previewUrl: z.string().trim().min(1, "请上传资源封面").max(512, "资源封面不能超过 512 个字符"),
|
previewUrl: z.string().trim().min(1, "请上传资源封面").max(512, "资源封面不能超过 512 个字符"),
|
||||||
@ -38,6 +50,16 @@ export const resourceCreateFormSchema = z
|
|||||||
|
|
||||||
export const resourceFormSchema = resourceCreateFormSchema;
|
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({
|
const groupItemSchema = z.object({
|
||||||
durationDays: z.union([z.string(), z.number()]).optional(),
|
durationDays: z.union([z.string(), z.number()]).optional(),
|
||||||
itemType: z.enum(["resource", "wallet_asset"]),
|
itemType: z.enum(["resource", "wallet_asset"]),
|
||||||
@ -119,7 +141,18 @@ export const giftFormSchema = z
|
|||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
giftId: z.string().trim().min(1, "请输入礼物 ID").max(96, "礼物 ID 不能超过 96 个字符"),
|
giftId: z.string().trim().min(1, "请输入礼物 ID").max(96, "礼物 ID 不能超过 96 个字符"),
|
||||||
giftPointAmount: z.union([z.string(), z.number()]).optional(),
|
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(),
|
heatValue: z.union([z.string(), z.number()]).optional(),
|
||||||
name: z.string().trim().min(1, "请输入礼物名称").max(128, "礼物名称不能超过 128 个字符"),
|
name: z.string().trim().min(1, "请输入礼物名称").max(128, "礼物名称不能超过 128 个字符"),
|
||||||
presentationJson: z.string().trim().optional(),
|
presentationJson: z.string().trim().optional(),
|
||||||
|
|||||||
@ -1,11 +1,17 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { FormValidationError, parseForm } from "@/shared/forms/validation";
|
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", () => {
|
describe("resource form schema", () => {
|
||||||
test("allows profile card resources", () => {
|
test("allows profile card resources", () => {
|
||||||
const payload = parseForm(resourceFormSchema, {
|
const payload = parseForm(resourceFormSchema, {
|
||||||
assetUrl: "https://media.haiyihy.com/resource/profile-card.png",
|
animationUrl: "https://media.haiyihy.com/resource/profile-card.pag",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
name: "Profile Card",
|
name: "Profile Card",
|
||||||
previewUrl: "https://media.haiyihy.com/resource/profile-card-cover.png",
|
previewUrl: "https://media.haiyihy.com/resource/profile-card-cover.png",
|
||||||
@ -84,6 +90,21 @@ describe("resource form schema", () => {
|
|||||||
expect(payload.regionIds).toEqual(["0", "1001"]);
|
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", () => {
|
test("validates resource grant form fields", () => {
|
||||||
const payload = parseForm(resourceGrantFormSchema, {
|
const payload = parseForm(resourceGrantFormSchema, {
|
||||||
durationDays: "7",
|
durationDays: "7",
|
||||||
|
|||||||
@ -11,6 +11,7 @@ export function RoomDetailDrawer({ onClose, open, room }) {
|
|||||||
const owner = room?.owner || {};
|
const owner = room?.owner || {};
|
||||||
const normalizedStatus = room?.status === "active" ? "active" : "closed";
|
const normalizedStatus = room?.status === "active" ? "active" : "closed";
|
||||||
const ownerDisplayId = ownerLongShortId(owner, room);
|
const ownerDisplayId = ownerLongShortId(owner, room);
|
||||||
|
const closeOperator = room?.closedByAdminName || (room?.closedByAdminId ? String(room.closedByAdminId) : "");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SideDrawer open={open} title="房间详情" width="wide" onClose={onClose}>
|
<SideDrawer open={open} title="房间详情" width="wide" onClose={onClose}>
|
||||||
@ -42,6 +43,17 @@ export function RoomDetailDrawer({ onClose, open, room }) {
|
|||||||
title="基础信息"
|
title="基础信息"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{normalizedStatus === "closed" ? (
|
||||||
|
<DetailSection
|
||||||
|
items={[
|
||||||
|
["操作人", closeOperator],
|
||||||
|
["操作时间", formatMillis(room.closedAtMs)],
|
||||||
|
["关闭原因", room.closeReason]
|
||||||
|
]}
|
||||||
|
title="关闭信息"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<section className={styles.detailSection}>
|
<section className={styles.detailSection}>
|
||||||
<h3 className={styles.detailSectionTitle}>房主信息</h3>
|
<h3 className={styles.detailSectionTitle}>房主信息</h3>
|
||||||
<div className={styles.detailOwner}>
|
<div className={styles.detailOwner}>
|
||||||
|
|||||||
@ -16,7 +16,7 @@ const emptyForm = () => ({
|
|||||||
title: "",
|
title: "",
|
||||||
visibleRegionId: 0,
|
visibleRegionId: 0,
|
||||||
});
|
});
|
||||||
const emptyStatusForm = () => ({ status: "active" });
|
const emptyStatusForm = () => ({ closeReason: "", status: "active" });
|
||||||
|
|
||||||
export function useRoomsPage() {
|
export function useRoomsPage() {
|
||||||
const abilities = useRoomAbilities();
|
const abilities = useRoomAbilities();
|
||||||
@ -100,9 +100,9 @@ export function useRoomsPage() {
|
|||||||
setActiveAction("detail");
|
setActiveAction("detail");
|
||||||
};
|
};
|
||||||
|
|
||||||
const openStatus = (room) => {
|
const openStatus = (room, statusValue = "closed") => {
|
||||||
setActiveRoom(room);
|
setActiveRoom(room);
|
||||||
setStatusForm({ status: room.status === "closed" ? "closed" : "active" });
|
setStatusForm({ closeReason: "", status: statusValue });
|
||||||
setActiveAction("status");
|
setActiveAction("status");
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -132,7 +132,7 @@ export function useRoomsPage() {
|
|||||||
if (!activeRoom) {
|
if (!activeRoom) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await runAction("status", "房间状态已更新", async () => {
|
await runAction(`status-${activeRoom.roomId}`, "房间状态已更新", async () => {
|
||||||
const payload = parseForm(roomStatusSchema, statusForm);
|
const payload = parseForm(roomStatusSchema, statusForm);
|
||||||
await updateRoom(activeRoom.roomId, payload);
|
await updateRoom(activeRoom.roomId, payload);
|
||||||
closeAction();
|
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 removeRoom = async (room) => {
|
||||||
const ok = await confirm({
|
const ok = await confirm({
|
||||||
confirmText: "删除",
|
confirmText: "删除",
|
||||||
@ -200,6 +211,7 @@ export function useRoomsPage() {
|
|||||||
statusForm,
|
statusForm,
|
||||||
submitStatus,
|
submitStatus,
|
||||||
submitEdit,
|
submitEdit,
|
||||||
|
toggleRoomStatus,
|
||||||
toggleContributionSort,
|
toggleContributionSort,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
|
|||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import Tooltip from "@mui/material/Tooltip";
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
import { AdminFormDialog, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
|
import { AdminFormDialog, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
|
||||||
|
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
||||||
@ -53,7 +54,7 @@ const columns = [
|
|||||||
key: "status",
|
key: "status",
|
||||||
label: "状态",
|
label: "状态",
|
||||||
width: "minmax(100px, 0.7fr)",
|
width: "minmax(100px, 0.7fr)",
|
||||||
render: (room) => <RoomStatus status={room.status} />,
|
render: (room) => roomStatusLabels[room.status === "active" ? "active" : "closed"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "time",
|
key: "time",
|
||||||
@ -98,7 +99,7 @@ export function RoomListPage() {
|
|||||||
value: page.status,
|
value: page.status,
|
||||||
onChange: page.changeStatus,
|
onChange: page.changeStatus,
|
||||||
}),
|
}),
|
||||||
render: (room) => <RoomStatus status={room.status} />,
|
render: (room) => <RoomStatusSwitch page={page} room={room} />,
|
||||||
}
|
}
|
||||||
: column.key === "roomContribution"
|
: column.key === "roomContribution"
|
||||||
? {
|
? {
|
||||||
@ -188,6 +189,26 @@ export function RoomListPage() {
|
|||||||
/>
|
/>
|
||||||
</ActionModal>
|
</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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -268,14 +289,19 @@ function RoomActions({ page, room }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RoomStatus({ status }) {
|
function RoomStatusSwitch({ page, room }) {
|
||||||
const normalizedStatus = status === "active" ? "active" : "closed";
|
const checked = room.status === "active";
|
||||||
const tone = normalizedStatus === "active" ? "running" : "stopped";
|
|
||||||
return (
|
return (
|
||||||
<span className={`status-badge status-badge--${tone}`}>
|
<div className={styles.statusCell}>
|
||||||
<span className="status-point" />
|
<AdminSwitch
|
||||||
{roomStatusLabels[normalizedStatus]}
|
checked={checked}
|
||||||
</span>
|
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);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.statusCell {
|
||||||
|
display: inline-flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
.statusButton {
|
.statusButton {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@ -10,12 +10,18 @@ export const roomUpdateSchema = z.object({
|
|||||||
visibleRegionId: z.coerce.number().int().min(0, "区域 ID 不正确").default(0),
|
visibleRegionId: z.coerce.number().int().min(0, "区域 ID 不正确").default(0),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const roomStatusSchema = z.object({
|
export const roomStatusSchema = z
|
||||||
status: z
|
.object({
|
||||||
.string()
|
closeReason: z.string().trim().max(512, "关闭原因不能超过 512 个字符").optional().default(""),
|
||||||
.trim()
|
status: z
|
||||||
.refine((value) => value === "active" || value === "closed", "房间状态不正确"),
|
.string()
|
||||||
});
|
.trim()
|
||||||
|
.refine((value) => value === "active" || value === "closed", "房间状态不正确"),
|
||||||
|
})
|
||||||
|
.refine((value) => value.status !== "closed" || value.closeReason.length > 0, {
|
||||||
|
message: "请输入关闭原因",
|
||||||
|
path: ["closeReason"],
|
||||||
|
});
|
||||||
|
|
||||||
export const roomConfigSchema = z
|
export const roomConfigSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
@ -27,14 +27,17 @@ export const API_OPERATIONS = {
|
|||||||
cancelRoomPin: "cancelRoomPin",
|
cancelRoomPin: "cancelRoomPin",
|
||||||
changePassword: "changePassword",
|
changePassword: "changePassword",
|
||||||
closeAgency: "closeAgency",
|
closeAgency: "closeAgency",
|
||||||
|
createAchievementDefinition: "createAchievementDefinition",
|
||||||
createAgency: "createAgency",
|
createAgency: "createAgency",
|
||||||
createAppVersion: "createAppVersion",
|
createAppVersion: "createAppVersion",
|
||||||
createBanner: "createBanner",
|
createBanner: "createBanner",
|
||||||
createBD: "createBD",
|
createBD: "createBD",
|
||||||
createBDLeader: "createBDLeader",
|
createBDLeader: "createBDLeader",
|
||||||
createCatalog: "createCatalog",
|
createCatalog: "createCatalog",
|
||||||
|
createCoinAdjustment: "createCoinAdjustment",
|
||||||
createCoinSeller: "createCoinSeller",
|
createCoinSeller: "createCoinSeller",
|
||||||
createCountry: "createCountry",
|
createCountry: "createCountry",
|
||||||
|
createEmojiPack: "createEmojiPack",
|
||||||
createGift: "createGift",
|
createGift: "createGift",
|
||||||
createMenu: "createMenu",
|
createMenu: "createMenu",
|
||||||
createPermission: "createPermission",
|
createPermission: "createPermission",
|
||||||
@ -46,6 +49,7 @@ export const API_OPERATIONS = {
|
|||||||
createRole: "createRole",
|
createRole: "createRole",
|
||||||
createRoomPin: "createRoomPin",
|
createRoomPin: "createRoomPin",
|
||||||
createTaskDefinition: "createTaskDefinition",
|
createTaskDefinition: "createTaskDefinition",
|
||||||
|
createTier: "createTier",
|
||||||
createUser: "createUser",
|
createUser: "createUser",
|
||||||
createUserExportJob: "createUserExportJob",
|
createUserExportJob: "createUserExportJob",
|
||||||
creditCoinSellerStock: "creditCoinSellerStock",
|
creditCoinSellerStock: "creditCoinSellerStock",
|
||||||
@ -83,6 +87,7 @@ export const API_OPERATIONS = {
|
|||||||
getUser: "getUser",
|
getUser: "getUser",
|
||||||
grantResource: "grantResource",
|
grantResource: "grantResource",
|
||||||
grantResourceGroup: "grantResourceGroup",
|
grantResourceGroup: "grantResourceGroup",
|
||||||
|
listAchievementDefinitions: "listAchievementDefinitions",
|
||||||
listAgencies: "listAgencies",
|
listAgencies: "listAgencies",
|
||||||
listApps: "listApps",
|
listApps: "listApps",
|
||||||
listAppVersions: "listAppVersions",
|
listAppVersions: "listAppVersions",
|
||||||
@ -90,19 +95,24 @@ export const API_OPERATIONS = {
|
|||||||
listBDLeaders: "listBDLeaders",
|
listBDLeaders: "listBDLeaders",
|
||||||
listBDs: "listBDs",
|
listBDs: "listBDs",
|
||||||
listCatalog: "listCatalog",
|
listCatalog: "listCatalog",
|
||||||
|
listCoinAdjustments: "listCoinAdjustments",
|
||||||
listCoinLedger: "listCoinLedger",
|
listCoinLedger: "listCoinLedger",
|
||||||
listCoinSellers: "listCoinSellers",
|
listCoinSellers: "listCoinSellers",
|
||||||
listCountries: "listCountries",
|
listCountries: "listCountries",
|
||||||
|
listEmojiPackCategories: "listEmojiPackCategories",
|
||||||
|
listEmojiPacks: "listEmojiPacks",
|
||||||
listGifts: "listGifts",
|
listGifts: "listGifts",
|
||||||
listH5Links: "listH5Links",
|
listH5Links: "listH5Links",
|
||||||
listHosts: "listHosts",
|
listHosts: "listHosts",
|
||||||
listJobs: "listJobs",
|
listJobs: "listJobs",
|
||||||
|
listLevelConfig: "listLevelConfig",
|
||||||
listLoginLogs: "listLoginLogs",
|
listLoginLogs: "listLoginLogs",
|
||||||
listOperationLogs: "listOperationLogs",
|
listOperationLogs: "listOperationLogs",
|
||||||
listPermissions: "listPermissions",
|
listPermissions: "listPermissions",
|
||||||
listPlatforms: "listPlatforms",
|
listPlatforms: "listPlatforms",
|
||||||
listRechargeBills: "listRechargeBills",
|
listRechargeBills: "listRechargeBills",
|
||||||
listRechargeProducts: "listRechargeProducts",
|
listRechargeProducts: "listRechargeProducts",
|
||||||
|
listRegionBlocks: "listRegionBlocks",
|
||||||
listRegions: "listRegions",
|
listRegions: "listRegions",
|
||||||
listRegistrationRewardClaims: "listRegistrationRewardClaims",
|
listRegistrationRewardClaims: "listRegistrationRewardClaims",
|
||||||
listResourceGrants: "listResourceGrants",
|
listResourceGrants: "listResourceGrants",
|
||||||
@ -116,9 +126,11 @@ export const API_OPERATIONS = {
|
|||||||
listUsers: "listUsers",
|
listUsers: "listUsers",
|
||||||
login: "login",
|
login: "login",
|
||||||
logout: "logout",
|
logout: "logout",
|
||||||
|
lookupCoinAdjustmentTarget: "lookupCoinAdjustmentTarget",
|
||||||
me: "me",
|
me: "me",
|
||||||
navigationMenus: "navigationMenus",
|
navigationMenus: "navigationMenus",
|
||||||
refresh: "refresh",
|
refresh: "refresh",
|
||||||
|
replaceRegionBlocks: "replaceRegionBlocks",
|
||||||
replaceRegionCountries: "replaceRegionCountries",
|
replaceRegionCountries: "replaceRegionCountries",
|
||||||
replaceRoleDataScopes: "replaceRoleDataScopes",
|
replaceRoleDataScopes: "replaceRoleDataScopes",
|
||||||
replaceRolePermissions: "replaceRolePermissions",
|
replaceRolePermissions: "replaceRolePermissions",
|
||||||
@ -132,6 +144,7 @@ export const API_OPERATIONS = {
|
|||||||
setTaskDefinitionStatus: "setTaskDefinitionStatus",
|
setTaskDefinitionStatus: "setTaskDefinitionStatus",
|
||||||
sortMenus: "sortMenus",
|
sortMenus: "sortMenus",
|
||||||
syncPermissions: "syncPermissions",
|
syncPermissions: "syncPermissions",
|
||||||
|
updateAchievementDefinition: "updateAchievementDefinition",
|
||||||
updateAppVersion: "updateAppVersion",
|
updateAppVersion: "updateAppVersion",
|
||||||
updateBanner: "updateBanner",
|
updateBanner: "updateBanner",
|
||||||
updateCatalog: "updateCatalog",
|
updateCatalog: "updateCatalog",
|
||||||
@ -152,10 +165,13 @@ export const API_OPERATIONS = {
|
|||||||
updateRoom: "updateRoom",
|
updateRoom: "updateRoom",
|
||||||
updateRoomConfig: "updateRoomConfig",
|
updateRoomConfig: "updateRoomConfig",
|
||||||
updateTaskDefinition: "updateTaskDefinition",
|
updateTaskDefinition: "updateTaskDefinition",
|
||||||
|
updateTier: "updateTier",
|
||||||
|
updateTrack: "updateTrack",
|
||||||
updateUser: "updateUser",
|
updateUser: "updateUser",
|
||||||
updateUserStatus: "updateUserStatus",
|
updateUserStatus: "updateUserStatus",
|
||||||
uploadFile: "uploadFile",
|
uploadFile: "uploadFile",
|
||||||
uploadImage: "uploadImage"
|
uploadImage: "uploadImage",
|
||||||
|
upsertRule: "upsertRule"
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type ApiOperationId = (typeof API_OPERATIONS)[keyof typeof API_OPERATIONS];
|
export type ApiOperationId = (typeof API_OPERATIONS)[keyof typeof API_OPERATIONS];
|
||||||
@ -257,6 +273,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "agency:status",
|
permission: "agency:status",
|
||||||
permissions: ["agency:status"]
|
permissions: ["agency:status"]
|
||||||
},
|
},
|
||||||
|
createAchievementDefinition: {
|
||||||
|
method: "POST",
|
||||||
|
operationId: API_OPERATIONS.createAchievementDefinition,
|
||||||
|
path: "/v1/admin/activity/achievements",
|
||||||
|
permission: "achievement:create",
|
||||||
|
permissions: ["achievement:create"]
|
||||||
|
},
|
||||||
createAgency: {
|
createAgency: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
operationId: API_OPERATIONS.createAgency,
|
operationId: API_OPERATIONS.createAgency,
|
||||||
@ -299,6 +322,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "game:create",
|
permission: "game:create",
|
||||||
permissions: ["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: {
|
createCoinSeller: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
operationId: API_OPERATIONS.createCoinSeller,
|
operationId: API_OPERATIONS.createCoinSeller,
|
||||||
@ -313,6 +343,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "country:create",
|
permission: "country:create",
|
||||||
permissions: ["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: {
|
createGift: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
operationId: API_OPERATIONS.createGift,
|
operationId: API_OPERATIONS.createGift,
|
||||||
@ -390,6 +427,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "daily-task:create",
|
permission: "daily-task:create",
|
||||||
permissions: ["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: {
|
createUser: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
operationId: API_OPERATIONS.createUser,
|
operationId: API_OPERATIONS.createUser,
|
||||||
@ -649,6 +693,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "resource-grant:create",
|
permission: "resource-grant:create",
|
||||||
permissions: ["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: {
|
listAgencies: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.listAgencies,
|
operationId: API_OPERATIONS.listAgencies,
|
||||||
@ -696,6 +747,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "game:view",
|
permission: "game:view",
|
||||||
permissions: ["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: {
|
listCoinLedger: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.listCoinLedger,
|
operationId: API_OPERATIONS.listCoinLedger,
|
||||||
@ -717,6 +775,20 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "country:view",
|
permission: "country:view",
|
||||||
permissions: ["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: {
|
listGifts: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.listGifts,
|
operationId: API_OPERATIONS.listGifts,
|
||||||
@ -745,6 +817,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "job:view",
|
permission: "job:view",
|
||||||
permissions: ["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: {
|
listLoginLogs: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.listLoginLogs,
|
operationId: API_OPERATIONS.listLoginLogs,
|
||||||
@ -787,6 +866,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "payment-product:view",
|
permission: "payment-product:view",
|
||||||
permissions: ["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: {
|
listRegions: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.listRegions,
|
operationId: API_OPERATIONS.listRegions,
|
||||||
@ -874,6 +960,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
operationId: API_OPERATIONS.logout,
|
operationId: API_OPERATIONS.logout,
|
||||||
path: "/v1/auth/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: {
|
me: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.me,
|
operationId: API_OPERATIONS.me,
|
||||||
@ -889,6 +982,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
operationId: API_OPERATIONS.refresh,
|
operationId: API_OPERATIONS.refresh,
|
||||||
path: "/v1/auth/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: {
|
replaceRegionCountries: {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
operationId: API_OPERATIONS.replaceRegionCountries,
|
operationId: API_OPERATIONS.replaceRegionCountries,
|
||||||
@ -978,6 +1078,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "permission:sync",
|
permission: "permission:sync",
|
||||||
permissions: ["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: {
|
updateAppVersion: {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
operationId: API_OPERATIONS.updateAppVersion,
|
operationId: API_OPERATIONS.updateAppVersion,
|
||||||
@ -1118,6 +1225,20 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "daily-task:update",
|
permission: "daily-task:update",
|
||||||
permissions: ["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: {
|
updateUser: {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
operationId: API_OPERATIONS.updateUser,
|
operationId: API_OPERATIONS.updateUser,
|
||||||
@ -1145,6 +1266,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
path: "/v1/admin/files/image/upload",
|
path: "/v1/admin/files/image/upload",
|
||||||
permission: "upload:create",
|
permission: "upload:create",
|
||||||
permissions: ["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 {
|
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": {
|
"/admin/activity/daily-tasks": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -420,6 +452,38 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/admin/files/image/upload": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -612,6 +676,38 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/admin/operations/coin-ledger": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -1012,6 +1108,102 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/app/users": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -2102,6 +2294,44 @@ export interface components {
|
|||||||
}
|
}
|
||||||
export type $defs = Record<string, never>;
|
export type $defs = Record<string, never>;
|
||||||
export interface operations {
|
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: {
|
listTaskDefinitions: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -2638,6 +2868,42 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
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: {
|
uploadImage: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -2837,6 +3103,42 @@ export interface operations {
|
|||||||
200: components["responses"]["HostProfilePageResponse"];
|
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: {
|
listCoinLedger: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -3319,6 +3621,97 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
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: {
|
appListUsers: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@ -206,6 +206,44 @@ export interface CoinLedgerEntryDto {
|
|||||||
userId: string;
|
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 {
|
export interface RechargeBillDto {
|
||||||
appCode?: string;
|
appCode?: string;
|
||||||
coinAmount: number;
|
coinAmount: number;
|
||||||
@ -464,6 +502,10 @@ export interface AppUserPasswordPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface RoomDto {
|
export interface RoomDto {
|
||||||
|
closeReason?: string;
|
||||||
|
closedAtMs?: number;
|
||||||
|
closedByAdminId?: number;
|
||||||
|
closedByAdminName?: string;
|
||||||
coverUrl?: string;
|
coverUrl?: string;
|
||||||
createdAtMs?: number;
|
createdAtMs?: number;
|
||||||
heat?: number;
|
heat?: number;
|
||||||
@ -524,6 +566,7 @@ export interface RoomOwnerDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface RoomUpdatePayload {
|
export interface RoomUpdatePayload {
|
||||||
|
closeReason?: string;
|
||||||
coverUrl?: string;
|
coverUrl?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
mode?: string;
|
mode?: string;
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import Refresh from "@mui/icons-material/Refresh";
|
import Refresh from "@mui/icons-material/Refresh";
|
||||||
|
import Skeleton from "@mui/material/Skeleton";
|
||||||
import { Button } from "./Button.jsx";
|
import { Button } from "./Button.jsx";
|
||||||
import { usePageLoadingStatus } from "./PageLoadBoundary.jsx";
|
import { usePageLoadingStatus } from "./PageLoadBoundary.jsx";
|
||||||
|
|
||||||
@ -8,7 +9,7 @@ export function DataState({ error, loading, children, onRetry, reportPageLoading
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="data-state data-state--loading">
|
<div className="data-state data-state--loading">
|
||||||
<div className="page-skeleton__block" />
|
<ContentSkeleton />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -29,3 +30,33 @@ export function DataState({ error, loading, children, onRetry, reportPageLoading
|
|||||||
|
|
||||||
return children;
|
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",
|
kind = "image",
|
||||||
label,
|
label,
|
||||||
onChange,
|
onChange,
|
||||||
|
uploadKind,
|
||||||
value = ""
|
value = ""
|
||||||
}) {
|
}) {
|
||||||
const inputId = useId();
|
const inputId = useId();
|
||||||
@ -27,10 +28,12 @@ export function UploadField({
|
|||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [localPreview, setLocalPreview] = useState(null);
|
const [localPreview, setLocalPreview] = useState(null);
|
||||||
const isImage = kind === "image";
|
const isImage = kind === "image";
|
||||||
|
const uploadMode = uploadKind || kind;
|
||||||
|
const useImageUpload = uploadMode === "image";
|
||||||
const source = localPreview?.url || value;
|
const source = localPreview?.url || value;
|
||||||
const assetKind = localPreview?.assetKind || getAssetKind(value, kind);
|
const assetKind = localPreview?.assetKind || getAssetKind(value, kind);
|
||||||
const displayName = localPreview?.name || getDisplayValue(value);
|
const displayName = localPreview?.name || getDisplayValue(value);
|
||||||
const inputAccept = accept || (isImage ? imageAccept : undefined);
|
const inputAccept = accept !== undefined ? accept : useImageUpload && isImage ? imageAccept : undefined;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@ -65,7 +68,7 @@ export function UploadField({
|
|||||||
});
|
});
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
try {
|
try {
|
||||||
const result = isImage ? await uploadImage(file) : await uploadFile(file);
|
const result = useImageUpload ? await uploadImage(file) : await uploadFile(file);
|
||||||
onChange(result.url);
|
onChange(result.url);
|
||||||
setLocalPreview(null);
|
setLocalPreview(null);
|
||||||
showToast("上传成功", "success");
|
showToast("上传成功", "success");
|
||||||
@ -146,7 +149,7 @@ function AssetPreview({ assetKind, isImage, src }) {
|
|||||||
if (assetKind === "pag") {
|
if (assetKind === "pag") {
|
||||||
return <PAGAssetPreview src={src} />;
|
return <PAGAssetPreview src={src} />;
|
||||||
}
|
}
|
||||||
if (isImage) {
|
if (assetKind === "image" || isImage) {
|
||||||
return <RasterAssetPreview src={src} />;
|
return <RasterAssetPreview src={src} />;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
@ -328,6 +331,9 @@ function getAssetKind(value, kind, contentType = "") {
|
|||||||
if (extension === "pag") {
|
if (extension === "pag") {
|
||||||
return "pag";
|
return "pag";
|
||||||
}
|
}
|
||||||
|
if (isRasterImageExtension(extension)) {
|
||||||
|
return "image";
|
||||||
|
}
|
||||||
const normalizedType = String(contentType || "").toLowerCase();
|
const normalizedType = String(contentType || "").toLowerCase();
|
||||||
if (normalizedType.includes("svga")) {
|
if (normalizedType.includes("svga")) {
|
||||||
return "svga";
|
return "svga";
|
||||||
@ -335,9 +341,20 @@ function getAssetKind(value, kind, contentType = "") {
|
|||||||
if (normalizedType.includes("pag")) {
|
if (normalizedType.includes("pag")) {
|
||||||
return "pag";
|
return "pag";
|
||||||
}
|
}
|
||||||
|
if (isRasterImageContentType(normalizedType)) {
|
||||||
|
return "image";
|
||||||
|
}
|
||||||
return kind === "image" ? "image" : "file";
|
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) {
|
function getExtension(value) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return "";
|
return "";
|
||||||
@ -359,6 +376,9 @@ function safePathname(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function assetKindLabel(assetKind, isImage) {
|
function assetKindLabel(assetKind, isImage) {
|
||||||
|
if (assetKind === "image") {
|
||||||
|
return "图片";
|
||||||
|
}
|
||||||
if (assetKind === "svga") {
|
if (assetKind === "svga") {
|
||||||
return "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 {
|
.data-state--loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: stretch;
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.data-state--loading .page-skeleton__block {
|
.data-state__skeleton {
|
||||||
min-height: 220px;
|
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 {
|
.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