diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index 7ecd58b..69d4e2a 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -10,6 +10,56 @@ } ], "paths": { + "/admin/activity/achievements": { + "get": { + "operationId": "listAchievementDefinitions", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "achievement:view", + "x-permissions": [ + "achievement:view" + ] + }, + "post": { + "operationId": "createAchievementDefinition", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "achievement:create", + "x-permissions": [ + "achievement:create" + ] + } + }, + "/admin/activity/achievements/{achievement_id}": { + "put": { + "operationId": "updateAchievementDefinition", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "achievement_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "achievement:update", + "x-permissions": [ + "achievement:update" + ] + } + }, "/admin/activity/daily-tasks": { "get": { "operationId": "listTaskDefinitions", @@ -775,6 +825,46 @@ ] } }, + "/admin/emoji-packs": { + "get": { + "operationId": "listEmojiPacks", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "emoji-pack:view", + "x-permissions": [ + "emoji-pack:view" + ] + }, + "post": { + "operationId": "createEmojiPack", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "emoji-pack:create", + "x-permissions": [ + "emoji-pack:create" + ] + } + }, + "/admin/emoji-packs/categories": { + "get": { + "operationId": "listEmojiPackCategories", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "emoji-pack:view", + "x-permissions": [ + "emoji-pack:view" + ] + } + }, "/admin/files/image/upload": { "post": { "operationId": "uploadImage", @@ -1059,6 +1149,46 @@ ] } }, + "/admin/operations/coin-adjustments": { + "get": { + "operationId": "listCoinAdjustments", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "coin-adjustment:view", + "x-permissions": [ + "coin-adjustment:view" + ] + }, + "post": { + "operationId": "createCoinAdjustment", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "coin-adjustment:create", + "x-permissions": [ + "coin-adjustment:create" + ] + } + }, + "/admin/operations/coin-adjustments/target": { + "get": { + "operationId": "lookupCoinAdjustmentTarget", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "coin-adjustment:create", + "x-permissions": [ + "coin-adjustment:create" + ] + } + }, "/admin/operations/coin-ledger": { "get": { "operationId": "listCoinLedger", @@ -1743,6 +1873,140 @@ ] } }, + "/admin/users/level-config": { + "get": { + "operationId": "listLevelConfig", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "level-config:view", + "x-permissions": [ + "level-config:view" + ] + } + }, + "/admin/users/level-config/rules/{track}/{level}": { + "put": { + "operationId": "upsertRule", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "track", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "in": "path", + "name": "level", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "level-config:update", + "x-permissions": [ + "level-config:update" + ] + } + }, + "/admin/users/level-config/tiers": { + "post": { + "operationId": "createTier", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "level-config:update", + "x-permissions": [ + "level-config:update" + ] + } + }, + "/admin/users/level-config/tiers/{tier_id}": { + "put": { + "operationId": "updateTier", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "tier_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "level-config:update", + "x-permissions": [ + "level-config:update" + ] + } + }, + "/admin/users/level-config/tracks/{track}": { + "put": { + "operationId": "updateTrack", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "track", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "level-config:update", + "x-permissions": [ + "level-config:update" + ] + } + }, + "/admin/users/region-blocks": { + "get": { + "operationId": "listRegionBlocks", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "region-block:view", + "x-permissions": [ + "region-block:view" + ] + }, + "put": { + "operationId": "replaceRegionBlocks", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "region-block:update", + "x-permissions": [ + "region-block:update" + ] + } + }, "/app/users": { "get": { "operationId": "appListUsers", diff --git a/src/app/navigation/menu.js b/src/app/navigation/menu.js index e9f64a5..291b75e 100644 --- a/src/app/navigation/menu.js +++ b/src/app/navigation/menu.js @@ -84,6 +84,7 @@ export const fallbackNavigation = [ routeNavItem("app-user-list", { icon: ManageAccountsOutlined }), routeNavItem("app-user-bans", { icon: BlockOutlined }), routeNavItem("app-user-login-logs", { icon: LoginOutlined }), + routeNavItem("app-user-region-blocks", { icon: PublicOutlined }), ], }, { @@ -119,6 +120,7 @@ export const fallbackNavigation = [ routeNavItem("resource-group-list", { icon: CategoryOutlined }), routeNavItem("gift-list", { icon: CardGiftcardOutlined }), routeNavItem("resource-grant-list", { icon: SendOutlined }), + routeNavItem("emoji-pack-list", { icon: ImageOutlined }), ], }, { @@ -126,7 +128,10 @@ export const fallbackNavigation = [ icon: ReceiptLongOutlined, id: "operations", label: "运营管理", - children: [routeNavItem("operation-coin-ledger", { icon: ReceiptLongOutlined })], + children: [ + routeNavItem("operation-coin-ledger", { icon: ReceiptLongOutlined }), + routeNavItem("operation-coin-adjustment", { icon: WalletOutlined }), + ], }, { code: "activities", diff --git a/src/app/permissions.ts b/src/app/permissions.ts index a7c9732..b56a5c9 100644 --- a/src/app/permissions.ts +++ b/src/app/permissions.ts @@ -26,6 +26,8 @@ export const PERMISSIONS = { coinSellerUpdate: "coin-seller:update", coinSellerStockCredit: "coin-seller:stock-credit", coinLedgerView: "coin-ledger:view", + coinAdjustmentView: "coin-adjustment:view", + coinAdjustmentCreate: "coin-adjustment:create", paymentBillView: "payment-bill:view", paymentProductView: "payment-product:view", paymentProductCreate: "payment-product:create", @@ -59,6 +61,10 @@ export const PERMISSIONS = { appUserUpdate: "app-user:update", appUserStatus: "app-user:status", appUserPassword: "app-user:password", + levelConfigView: "level-config:view", + levelConfigUpdate: "level-config:update", + regionBlockView: "region-block:view", + regionBlockUpdate: "region-block:update", roomView: "room:view", roomUpdate: "room:update", roomDelete: "room:delete", @@ -85,10 +91,15 @@ export const PERMISSIONS = { giftCreate: "gift:create", giftUpdate: "gift:update", giftStatus: "gift:status", + emojiPackView: "emoji-pack:view", + emojiPackCreate: "emoji-pack:create", dailyTaskView: "daily-task:view", dailyTaskCreate: "daily-task:create", dailyTaskUpdate: "daily-task:update", dailyTaskStatus: "daily-task:status", + achievementView: "achievement:view", + achievementCreate: "achievement:create", + achievementUpdate: "achievement:update", registrationRewardView: "registration-reward:view", registrationRewardUpdate: "registration-reward:update", uploadCreate: "upload:create", @@ -114,6 +125,8 @@ export const MENU_CODES = { appUserList: "app-user-list", appUserBans: "app-user-bans", appUserLoginLogs: "app-user-login-logs", + appUserLevelConfig: "app-user-level-config", + appUserRegionBlocks: "app-user-region-blocks", rooms: "rooms", roomList: "room-list", roomPins: "room-pins", @@ -127,11 +140,14 @@ export const MENU_CODES = { resourceGroupList: "resource-group-list", resourceGrantList: "resource-grant-list", giftList: "gift-list", + emojiPackList: "emoji-pack-list", operations: "operations", operationCoinLedger: "operation-coin-ledger", + operationCoinAdjustment: "operation-coin-adjustment", activities: "activities", dailyTaskList: "daily-task-list", registrationReward: "registration-reward", + achievementConfig: "achievement-config", geo: "geo", hostOrg: "host-org", hostOrgCountries: "host-org-countries", diff --git a/src/app/router/routeConfig.ts b/src/app/router/routeConfig.ts index 2adfdcb..bcf26be 100644 --- a/src/app/router/routeConfig.ts +++ b/src/app/router/routeConfig.ts @@ -1,15 +1,18 @@ import { authRoutes } from "@/features/auth/routes.js"; +import { achievementRoutes } from "@/features/achievements/routes.js"; import { appConfigRoutes } from "@/features/app-config/routes.js"; import { appUserRoutes } from "@/features/app-users/routes.js"; import { dashboardRoutes } from "@/features/dashboard/routes.js"; import { dailyTaskRoutes } from "@/features/daily-tasks/routes.js"; import { gameRoutes } from "@/features/games/routes.js"; import { hostOrgRoutes } from "@/features/host-org/routes.js"; +import { levelConfigRoutes } from "@/features/level-config/routes.js"; import { logsRoutes } from "@/features/logs/routes.js"; import { menusRoutes } from "@/features/menus/routes.js"; import { operationsRoutes } from "@/features/operations/routes.js"; import { paymentRoutes } from "@/features/payment/routes.js"; import { registrationRewardRoutes } from "@/features/registration-reward/routes.js"; +import { regionBlockRoutes } from "@/features/region-blocks/routes.js"; import { resourceRoutes } from "@/features/resources/routes.js"; import { rolesRoutes } from "@/features/roles/routes.js"; import { roomRoutes } from "@/features/rooms/routes.js"; @@ -22,9 +25,12 @@ export const publicRoutes: PublicRoute[] = authRoutes; export const adminRoutes: AdminRoute[] = [ ...dashboardRoutes, ...appUserRoutes, + ...levelConfigRoutes, + ...regionBlockRoutes, ...roomRoutes, ...appConfigRoutes, ...dailyTaskRoutes, + ...achievementRoutes, ...registrationRewardRoutes, ...resourceRoutes, ...operationsRoutes, diff --git a/src/features/achievements/achievements.module.css b/src/features/achievements/achievements.module.css new file mode 100644 index 0000000..b51fe5e --- /dev/null +++ b/src/features/achievements/achievements.module.css @@ -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; +} diff --git a/src/features/achievements/api.ts b/src/features/achievements/api.ts new file mode 100644 index 0000000..9cd5ff9 --- /dev/null +++ b/src/features/achievements/api.ts @@ -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; + +export function listAchievementDefinitions(query: PageQuery = {}): Promise> { + const endpoint = API_ENDPOINTS.listAchievementDefinitions; + return apiRequest>(apiEndpointPath(API_OPERATIONS.listAchievementDefinitions), { + method: endpoint.method, + query, + }).then(normalizeAchievementPage); +} + +export function createAchievementDefinition(payload: AchievementDefinitionPayload): Promise { + const endpoint = API_ENDPOINTS.createAchievementDefinition; + return apiRequest( + apiEndpointPath(API_OPERATIONS.createAchievementDefinition), + { + body: payload, + method: endpoint.method, + }, + ).then(normalizeAchievement); +} + +export function updateAchievementDefinition( + achievementId: EntityId, + payload: AchievementDefinitionPayload, +): Promise { + const endpoint = API_ENDPOINTS.updateAchievementDefinition; + return apiRequest( + apiEndpointPath(API_OPERATIONS.updateAchievementDefinition, { achievement_id: achievementId }), + { + body: payload, + method: endpoint.method, + }, + ).then(normalizeAchievement); +} + +function normalizeAchievementPage(page: ApiPage): ApiPage { + 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); +} diff --git a/src/features/achievements/constants.js b/src/features/achievements/constants.js new file mode 100644 index 0000000..d0e4738 --- /dev/null +++ b/src/features/achievements/constants.js @@ -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); diff --git a/src/features/achievements/hooks/useAchievementsPage.js b/src/features/achievements/hooks/useAchievementsPage.js new file mode 100644 index 0000000..37288de --- /dev/null +++ b/src/features/achievements/hooks/useAchievementsPage.js @@ -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); +} diff --git a/src/features/achievements/pages/AchievementConfigPage.jsx b/src/features/achievements/pages/AchievementConfigPage.jsx new file mode 100644 index 0000000..3034725 --- /dev/null +++ b/src/features/achievements/pages/AchievementConfigPage.jsx @@ -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) => , + }, + { + key: "condition", + label: "达成条件", + width: "minmax(240px, 1.2fr)", + render: (achievement) => , + }, + { + 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) => ( +
+ {autoPinPolicyLabel(achievement.autoPinPolicy)} + {achievement.honorVisibility ? "荣誉墙可见" : "荣誉墙隐藏"} +
+ ), + }, + { + key: "effective", + label: "有效期", + width: "minmax(220px, 1.05fr)", + render: (achievement) => ( +
+ {achievement.effectiveFromMs ? formatMillis(achievement.effectiveFromMs) : "立即生效"} + + {achievement.effectiveToMs ? formatMillis(achievement.effectiveToMs) : "长期有效"} + +
+ ), + }, + { + key: "version", + label: "版本 / 排序", + width: "minmax(120px, 0.6fr)", + render: (achievement) => ( +
+ v{achievement.version || 1} + {achievement.sortOrder || 0} +
+ ), + }, + { + 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) => ( + + ), + }; + } + if (column.key === "reward") { + return { + ...column, + render: (achievement) => ( + + ), + }; + } + if (column.key === "status") { + return { + ...column, + render: (achievement) => , + }; + } + if (column.key === "actions") { + return { + ...column, + render: (achievement) => , + }; + } + return column; + }); + + return ( + + + + + + + } + actions={ + page.abilities.canCreate ? ( + + + + ) : null + } + /> + + + 0 + ? { + page: page.page, + pageSize: page.data.pageSize || 10, + total, + onPageChange: page.setPage, + } + : undefined + } + rowKey={(achievement) => achievement.achievementId} + /> + + + + + ); +} + +function AchievementIdentity({ achievement }) { + return ( +
+ {achievementTypeLabel(achievement.achievementType)} +
+ {achievement.title || achievement.achievementId} + {achievement.achievementId} + + {collectionTypeLabel(achievement.collectionType)} · {achievement.collectionId || "ordinary"} + +
+
+ ); +} + +function ConditionSummary({ conditions = [] }) { + const first = conditions[0] || {}; + if (!first.metricType) { + return 未配置; + } + return ( +
+ {first.metricType} + + {formatNumber(first.targetValue)} {targetUnitLabel(first.targetUnit)} + {conditions.length > 1 ? ` · 共 ${conditions.length} 条` : ""} + +
+ ); +} + +function AchievementStatusSwitch({ achievement, page }) { + const checked = achievement.status === "active"; + const disabled = + !page.abilities.canUpdate || + achievement.status === "archived" || + page.loadingAction === `status-${achievement.achievementId}`; + return ( +
+ page.toggleAchievement(achievement, event.target.checked)} + /> + {achievementStatusLabel(achievement.status)} +
+ ); +} + +function AchievementRowActions({ achievement, page }) { + return ( + + page.openEdit(achievement)} + > + + + + ); +} + +function AchievementFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open, page }) { + return ( + + + + + {collectionTypeOptions.map(([value, label]) => ( + + {label} + + ))} + + + {achievementTypeOptions.map(([value, label]) => ( + + {label} + + ))} + + + + {achievementStatusOptions.map(([value, label]) => ( + + {label} + + ))} + + + + + + + + + } + title="达成条件" + > + + {form.conditions.map((condition, index) => ( + + page.updateCondition(index, "metricType", event.target.value)} + /> + page.updateCondition(index, "targetValue", event.target.value)} + /> + page.updateCondition(index, "targetUnit", event.target.value)} + > + {targetUnitOptions.map(([value, label]) => ( + + {label} + + ))} + + + page.updateCondition(index, "dimensionFilterJson", event.target.value) + } + /> + page.removeCondition(index)} + > + + + + ))} + + + + + + + + {autoPinPolicyOptions.map(([value, label]) => ( + + {label} + + ))} + + page.setForm((current) => ({ ...current, honorVisibility: checked }))} + /> + + + + + + + + + + + + ); +} + +function ResourceSelect({ disabled, label, onChange, resources, value }) { + const options = withSelectedResource(resources, value); + return ( + + 不配置 + {options.map((resource) => ( + + {resource.name || resource.resourceCode || resource.resourceId} + + ))} + + ); +} + +function ResourceGroupSelect({ disabled, groups, label, onChange, value }) { + const options = withSelectedGroup(groups, value); + return ( + + 不配置 + {options.map((group) => ( + + {group.name || group.groupCode || group.groupId} + + ))} + + ); +} + +function ResourceLabel({ id, resources }) { + if (!id) { + return 未配置; + } + const resource = resources.find((item) => Number(item.resourceId) === Number(id)); + return ( +
+ {resource?.name || resource?.resourceCode || `资源 #${id}`} + ID {id} +
+ ); +} + +function ResourceGroupLabel({ groups, id }) { + if (!id) { + return 未配置; + } + const group = groups.find((item) => Number(item.groupId) === Number(id)); + return ( +
+ {group?.name || group?.groupCode || `资源组 #${id}`} + ID {id} +
+ ); +} + +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"); +} diff --git a/src/features/achievements/permissions.js b/src/features/achievements/permissions.js new file mode 100644 index 0000000..a187840 --- /dev/null +++ b/src/features/achievements/permissions.js @@ -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), + }; +} diff --git a/src/features/achievements/routes.js b/src/features/achievements/routes.js new file mode 100644 index 0000000..3e360dd --- /dev/null +++ b/src/features/achievements/routes.js @@ -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, + }, +]; diff --git a/src/features/achievements/schema.ts b/src/features/achievements/schema.ts new file mode 100644 index 0000000..8112753 --- /dev/null +++ b/src/features/achievements/schema.ts @@ -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; + +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; +} diff --git a/src/features/level-config/api.ts b/src/features/level-config/api.ts new file mode 100644 index 0000000..61e8e8c --- /dev/null +++ b/src/features/level-config/api.ts @@ -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; + +export function listLevelConfig(query: PageQuery = {}): Promise { + const endpoint = API_ENDPOINTS.listLevelConfig; + return apiRequest(apiEndpointPath(API_OPERATIONS.listLevelConfig), { + method: endpoint.method, + query, + }).then(normalizeLevelConfig); +} + +export function updateTrack(track: LevelTrackCode, payload: LevelTrackPayload): Promise { + const endpoint = API_ENDPOINTS.updateTrack; + return apiRequest(apiEndpointPath(API_OPERATIONS.updateTrack, { track }), { + body: payload, + method: endpoint.method, + }).then(normalizeTrack); +} + +export function upsertRule( + track: LevelTrackCode, + level: EntityId, + payload: LevelRulePayload, +): Promise { + const endpoint = API_ENDPOINTS.upsertRule; + return apiRequest( + apiEndpointPath(API_OPERATIONS.upsertRule, { level, track }), + { + body: payload, + method: endpoint.method, + }, + ).then(normalizeRule); +} + +export function createTier(payload: LevelTierPayload): Promise { + const endpoint = API_ENDPOINTS.createTier; + return apiRequest(apiEndpointPath(API_OPERATIONS.createTier), { + body: payload, + method: endpoint.method, + }).then(normalizeTier); +} + +export function updateTier(tierId: EntityId, payload: LevelTierPayload): Promise { + const endpoint = API_ENDPOINTS.updateTier; + return apiRequest(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 {}; + } +} diff --git a/src/features/level-config/constants.js b/src/features/level-config/constants.js new file mode 100644 index 0000000..37b3dc7 --- /dev/null +++ b/src/features/level-config/constants.js @@ -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]; diff --git a/src/features/level-config/hooks/useLevelConfigPage.js b/src/features/level-config/hooks/useLevelConfigPage.js new file mode 100644 index 0000000..4c50f05 --- /dev/null +++ b/src/features/level-config/hooks/useLevelConfigPage.js @@ -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 || "{}"; +} diff --git a/src/features/level-config/level-config.module.css b/src/features/level-config/level-config.module.css new file mode 100644 index 0000000..820af27 --- /dev/null +++ b/src/features/level-config/level-config.module.css @@ -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); + } +} diff --git a/src/features/level-config/pages/LevelConfigPage.jsx b/src/features/level-config/pages/LevelConfigPage.jsx new file mode 100644 index 0000000..ef4a83a --- /dev/null +++ b/src/features/level-config/pages/LevelConfigPage.jsx @@ -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 ( + + + +
+
+ page.changeTrack(nextTrack)} + > + {trackTabs.map((item) => ( + + {item.label} + + {item.ruleCount} 条规则 · {item.tierCount} 个等级层 + + + } + value={item.value} + /> + ))} + +
+ + + + + +
+
+ {page.tracks.length ? ( +
+ {page.tracks.map((track) => ( + + ))} +
+ ) : ( +
当前无数据
+ )} +
+
+
+ + + +
+ ); +} + +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 ( +
+
+
+
+ {track.name || levelTrackLabels[track.track] || track.track} +
+
+ {track.track} + {totalRules} 条规则 + {totalTiers} 个等级层 + 排序 {track.sortOrder || 0} +
+
+ + page.openTrackEdit(track)} + > + + + page.openRuleCreate(track.track)} + > + + + page.openTierCreate(track.track)} + > + + + +
+
+
+ + `${rule.track}-${rule.level}`} + /> +
+
+ + tier.tierId || `${tier.track}-${tier.minLevel}-${tier.maxLevel}`} + /> +
+
+
+ ); +} + +function TableBlockHeader({ countLabel, title }) { + return ( +
+
{title}
+ {countLabel} +
+ ); +} + +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) => , + }, + { + key: "requiredValue", + label: "升级数值", + width: "minmax(140px, 0.65fr)", + render: (rule) => formatNumber(rule.requiredValue), + }, + { + key: "longBadge", + label: "长徽章素材", + width: "minmax(200px, 1fr)", + render: (rule) => , + }, + { + key: "reward", + label: "升级礼物", + width: "minmax(200px, 1fr)", + render: (rule) => , + }, + { + key: "status", + label: "状态", + width: "minmax(116px, 0.55fr)", + render: (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) => , + }, + ]; +} + +function buildTierColumns(page) { + return [ + { + key: "range", + label: "等级层", + width: "minmax(220px, 1fr)", + render: (tier) => , + }, + { + key: "frame", + label: "头像框", + width: "minmax(200px, 0.9fr)", + render: (tier) => , + }, + { + key: "badge", + label: "徽章", + width: "minmax(200px, 0.9fr)", + render: (tier) => , + }, + { + key: "reward", + label: "升级礼物", + width: "minmax(200px, 1fr)", + render: (tier) => , + }, + { + key: "status", + label: "状态", + width: "minmax(116px, 0.55fr)", + render: (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) => , + }, + ]; +} + +function RuleIdentity({ rule }) { + return ( +
+ Lv.{rule.level} +
+ {rule.name || `${levelTrackLabels[rule.track]} ${rule.level}级`} + {levelTrackLabels[rule.track] || rule.track} +
+
+ ); +} + +function TierIdentity({ tier }) { + return ( +
+ + {tier.minLevel}-{tier.maxLevel} + +
+ {tier.name || `${tier.minLevel}-${tier.maxLevel}级`} + {levelTrackLabels[tier.track] || tier.track} +
+
+ ); +} + +function RuleStatusSwitch({ page, rule }) { + const checked = rule.status === "active"; + return ( +
+ page.toggleRule(rule, event.target.checked)} + /> + {levelStatusLabel(rule.status)} +
+ ); +} + +function TierStatusSwitch({ page, tier }) { + const checked = tier.status === "active"; + return ( +
+ page.toggleTier(tier, event.target.checked)} + /> + {levelStatusLabel(tier.status)} +
+ ); +} + +function RuleRowActions({ page, rule }) { + return ( + + page.openRuleEdit(rule)} + > + + + + ); +} + +function TierRowActions({ page, tier }) { + return ( + + page.openTierEdit(tier)} + > + + + + ); +} + +function RuleFormDialog({ page }) { + const open = page.activeAction === "rule-create" || page.activeAction === "rule-edit"; + const editing = page.activeAction === "rule-edit"; + const form = page.ruleForm; + + return ( + + + + + {levelTrackOptions.map(([value, label]) => ( + + {label} + + ))} + + + + + + + + + + + + + page.setRuleForm((current) => ({ ...current, status: checked ? "active" : "disabled" })) + } + /> + + + + ); +} + +function TierFormDialog({ page }) { + const open = page.activeAction === "tier-create" || page.activeAction === "tier-edit"; + const editing = page.activeAction === "tier-edit"; + const form = page.tierForm; + + return ( + + + + + {levelTrackOptions.map(([value, label]) => ( + + {label} + + ))} + + + + + + + + + + + + + page.setTierForm((current) => ({ ...current, status: checked ? "active" : "disabled" })) + } + /> + + + + ); +} + +function TrackFormDialog({ page }) { + const form = page.trackForm; + + return ( + + + + + + + page.setTrackForm((current) => ({ ...current, status: checked ? "active" : "disabled" })) + } + /> + + + + ); +} + +function ResourceSelect({ disabled, label, onChange, resources, value }) { + const options = withSelectedResource(resources, value); + return ( + + 不配置 + {options.map((resource) => ( + + {resource.name || resource.resourceCode || resource.resourceId} + + ))} + + ); +} + +function ResourceGroupSelect({ disabled, groups, label, onChange, value }) { + const options = withSelectedGroup(groups, value); + return ( + + 不配置 + {options.map((group) => ( + + {group.name || group.groupCode || group.groupId} + + ))} + + ); +} + +function ResourceLabel({ id, resources }) { + if (!id) { + return 未配置; + } + const resource = resources.find((item) => Number(item.resourceId) === Number(id)); + return ( +
+ {resource?.name || resource?.resourceCode || `资源 #${id}`} + ID {id} +
+ ); +} + +function ResourceGroupLabel({ groups, id }) { + if (!id) { + return 未配置; + } + const group = groups.find((item) => Number(item.groupId) === Number(id)); + return ( +
+ {group?.name || group?.groupCode || `资源组 #${id}`} + ID {id} +
+ ); +} + +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"); +} diff --git a/src/features/level-config/permissions.js b/src/features/level-config/permissions.js new file mode 100644 index 0000000..b899c97 --- /dev/null +++ b/src/features/level-config/permissions.js @@ -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), + }; +} diff --git a/src/features/level-config/routes.js b/src/features/level-config/routes.js new file mode 100644 index 0000000..76cbaed --- /dev/null +++ b/src/features/level-config/routes.js @@ -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, + }, +]; diff --git a/src/features/level-config/schema.ts b/src/features/level-config/schema.ts new file mode 100644 index 0000000..52c93b2 --- /dev/null +++ b/src/features/level-config/schema.ts @@ -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; +export type LevelTierForm = z.infer; +export type LevelTrackForm = z.infer; + +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); +} diff --git a/src/features/operations/api.test.ts b/src/features/operations/api.test.ts new file mode 100644 index 0000000..8347194 --- /dev/null +++ b/src/features/operations/api.test.ts @@ -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"); +}); diff --git a/src/features/operations/api.ts b/src/features/operations/api.ts index bfd4efd..8494d9e 100644 --- a/src/features/operations/api.ts +++ b/src/features/operations/api.ts @@ -1,6 +1,14 @@ import { apiRequest } from "@/shared/api/request"; import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints"; -import type { ApiPage, CoinLedgerEntryDto, PageQuery } from "@/shared/api/types"; +import type { + ApiPage, + CoinAdjustmentCreateDto, + CoinAdjustmentDto, + CoinAdjustmentPayload, + CoinLedgerEntryDto, + CoinLedgerUserDto, + PageQuery, +} from "@/shared/api/types"; export function listCoinLedger(query: PageQuery = {}): Promise> { const endpoint = API_ENDPOINTS.listCoinLedger; @@ -9,3 +17,30 @@ export function listCoinLedger(query: PageQuery = {}): Promise> { + const endpoint = API_ENDPOINTS.listCoinAdjustments; + return apiRequest>(apiEndpointPath(API_OPERATIONS.listCoinAdjustments), { + method: endpoint.method, + query, + }); +} + +export function lookupCoinAdjustmentTarget(userId: string): Promise { + const endpoint = API_ENDPOINTS.lookupCoinAdjustmentTarget; + return apiRequest(apiEndpointPath(API_OPERATIONS.lookupCoinAdjustmentTarget), { + method: endpoint.method, + query: { user_id: userId }, + }); +} + +export function createCoinAdjustment(payload: CoinAdjustmentPayload): Promise { + const endpoint = API_ENDPOINTS.createCoinAdjustment; + return apiRequest( + apiEndpointPath(API_OPERATIONS.createCoinAdjustment), + { + body: payload, + method: endpoint.method, + }, + ); +} diff --git a/src/features/operations/operations.module.css b/src/features/operations/operations.module.css index 2d1782d..ea8d9c4 100644 --- a/src/features/operations/operations.module.css +++ b/src/features/operations/operations.module.css @@ -16,6 +16,10 @@ color: var(--danger); } +.adjustmentAmount { + font-size: calc(var(--admin-font-size) + 4px); +} + .clickableRow:hover { background: var(--primary-hover); } @@ -163,8 +167,30 @@ word-break: break-word; } +.lookupRow { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: var(--space-3); +} + +.targetPreview { + display: flex; + min-width: 0; + align-items: center; + gap: var(--space-3); + padding: var(--space-3); + border: 1px solid var(--border-soft); + border-radius: var(--radius-control); + background: var(--bg-card-strong); +} + @media (max-width: 640px) { .detailGrid { grid-template-columns: 1fr; } + + .lookupRow { + grid-template-columns: 1fr; + } } diff --git a/src/features/operations/pages/CoinAdjustmentPage.jsx b/src/features/operations/pages/CoinAdjustmentPage.jsx new file mode 100644 index 0000000..3edae6a --- /dev/null +++ b/src/features/operations/pages/CoinAdjustmentPage.jsx @@ -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) => , + width: "minmax(260px, 1.35fr)", + }, + { + key: "amount", + label: "增减数量", + render: (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 ( + + + + + ) : null + } + filters={ + <> + + + + } + /> + + + 0 + ? { + page, + pageSize: data.pageSize || pageSize, + total, + onPageChange: setPage, + } + : undefined + } + rowKey={(item) => item.entryId} + /> + + + + +
+ { + changeForm({ userKeyword: event.target.value }); + setTargetUser(null); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + searchTargetUser(); + } + }} + /> + +
+ {targetUser ? : null} +
+ + + changeForm({ mode: event.target.value })} + > + 增加 + 扣减 + + changeForm({ amount: event.target.value })} + /> + changeForm({ reason: event.target.value })} + > + {adjustmentReasons.map((reason) => ( + + {reason} + + ))} + + + +
+
+ ); +} + +function AdjustmentUserCell({ item }) { + const user = item.user || {}; + const name = user.username || "-"; + + return ( +
+ + {user.avatar ? : } + + + {name} + {userIdText(user, item.userId)} + +
+ ); +} + +function TargetUserPreview({ user }) { + return ( +
+ + {user.avatar ? : } + + + {user.username || "-"} + {userIdText(user, user.userId)} + +
+ ); +} + +function AdjustmentAmount({ item }) { + const delta = Number(item.availableDelta ?? item.amount ?? 0); + const expense = delta < 0; + const sign = expense ? "-" : "+"; + + return ( + + {sign} + {formatNumber(Math.abs(delta))} + + ); +} + +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"); +} diff --git a/src/features/operations/permissions.js b/src/features/operations/permissions.js new file mode 100644 index 0000000..569ee27 --- /dev/null +++ b/src/features/operations/permissions.js @@ -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), + }; +} diff --git a/src/features/operations/routes.js b/src/features/operations/routes.js index 75e910f..5130c67 100644 --- a/src/features/operations/routes.js +++ b/src/features/operations/routes.js @@ -9,4 +9,12 @@ export const operationsRoutes = [ path: "/operations/coin-ledger", permission: PERMISSIONS.coinLedgerView, }, + { + label: "金币增减", + loader: () => import("./pages/CoinAdjustmentPage.jsx").then((module) => module.CoinAdjustmentPage), + menuCode: MENU_CODES.operationCoinAdjustment, + pageKey: "operation-coin-adjustment", + path: "/operations/coin-adjustments", + permission: PERMISSIONS.coinAdjustmentView, + }, ]; diff --git a/src/features/region-blocks/api.ts b/src/features/region-blocks/api.ts new file mode 100644 index 0000000..088c6f2 --- /dev/null +++ b/src/features/region-blocks/api.ts @@ -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; + +export function listRegionBlocks(): Promise> { + const endpoint = API_ENDPOINTS.listRegionBlocks; + return apiRequest>(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> { + const endpoint = API_ENDPOINTS.replaceRegionBlocks; + return apiRequest, 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; +} diff --git a/src/features/region-blocks/hooks/useRegionBlockPage.js b/src/features/region-blocks/hooks/useRegionBlockPage.js new file mode 100644 index 0000000..4e1bd24 --- /dev/null +++ b/src/features/region-blocks/hooks/useRegionBlockPage.js @@ -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"); +} diff --git a/src/features/region-blocks/pages/RegionBlockPage.jsx b/src/features/region-blocks/pages/RegionBlockPage.jsx new file mode 100644 index 0000000..df9b76b --- /dev/null +++ b/src/features/region-blocks/pages/RegionBlockPage.jsx @@ -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 ( + + + + + + + } + filters={ +
+ page.setKeywordInput(event.target.value)} + /> + + + } + /> + + + item.blockId || item.keyword} + /> + + +
+ ); +} + +function buildColumns(page) { + return [ + { + key: "keyword", + label: "屏蔽词", + width: "minmax(220px, 1.2fr)", + render: (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) => ( + + + page.removeKeyword(item.keyword)} + > + + + + + ), + }, + ]; +} + +function KeywordCell({ item }) { + return ( +
+ {item.keyword} + {item.enabled ? "启用" : "停用"} +
+ ); +} diff --git a/src/features/region-blocks/permissions.js b/src/features/region-blocks/permissions.js new file mode 100644 index 0000000..f48a0a9 --- /dev/null +++ b/src/features/region-blocks/permissions.js @@ -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), + }; +} diff --git a/src/features/region-blocks/region-blocks.module.css b/src/features/region-blocks/region-blocks.module.css new file mode 100644 index 0000000..682712e --- /dev/null +++ b/src/features/region-blocks/region-blocks.module.css @@ -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%; + } +} diff --git a/src/features/region-blocks/routes.js b/src/features/region-blocks/routes.js new file mode 100644 index 0000000..7fb580f --- /dev/null +++ b/src/features/region-blocks/routes.js @@ -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, + }, +]; diff --git a/src/features/region-blocks/schema.ts b/src/features/region-blocks/schema.ts new file mode 100644 index 0000000..888326d --- /dev/null +++ b/src/features/region-blocks/schema.ts @@ -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; diff --git a/src/features/registration-reward/components/RegistrationRewardConfigDrawer.jsx b/src/features/registration-reward/components/RegistrationRewardConfigDrawer.jsx new file mode 100644 index 0000000..f971f83 --- /dev/null +++ b/src/features/registration-reward/components/RegistrationRewardConfigDrawer.jsx @@ -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 ( + +
+

注册奖励配置

+
+
发放状态
+
+ + setForm((current) => ({ ...current, enabled: event.target.checked })) + } + /> + + setForm((current) => ({ ...current, dailyLimit: event.target.value })) + } + /> +
+
+
+
奖励内容
+
+ + setForm((current) => ({ ...current, rewardType: event.target.value })) + } + > + {rewardTypeOptions.map(([value, label]) => ( + + {label} + + ))} + + {form.rewardType === "resource_group" ? ( + + setForm((current) => ({ + ...current, + resourceGroupId: event.target.value, + })) + } + > + 请选择资源组 + {resourceGroups.map((group) => ( + + {group.name || group.groupCode || group.groupId} + + ))} + + ) : ( + + setForm((current) => ({ ...current, coinAmount: event.target.value })) + } + /> + )} +
+
+
+ + +
+
+
+ ); +} diff --git a/src/features/registration-reward/components/RegistrationRewardConfigSummary.jsx b/src/features/registration-reward/components/RegistrationRewardConfigSummary.jsx new file mode 100644 index 0000000..21c4a70 --- /dev/null +++ b/src/features/registration-reward/components/RegistrationRewardConfigSummary.jsx @@ -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 ( +
+
+ 注册奖励配置 +
+
+ + + + {config ? formatNumber(config.dailyLimit) : configLoading ? "加载中" : "-"} + {config ? rewardLabel(config, resourceGroups) : configLoading ? "加载中" : "-"} +
+
+ + + + +
+
+ ); +} + +function SummaryItem({ children, label }) { + return ( +
+ {label} + {children} +
+ ); +} + +function ConfigStatus({ config, loading }) { + if (!config) { + return ( + + + {loading ? "加载中" : "-"} + + ); + } + + return ( + + + {config.enabled ? "已开启" : "已关闭"} + + ); +} + +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)); +} diff --git a/src/features/registration-reward/hooks/useRegistrationRewardPage.js b/src/features/registration-reward/hooks/useRegistrationRewardPage.js index 6ab0328..af31927 100644 --- a/src/features/registration-reward/hooks/useRegistrationRewardPage.js +++ b/src/features/registration-reward/hooks/useRegistrationRewardPage.js @@ -23,6 +23,8 @@ const emptyForm = { export function useRegistrationRewardPage() { const abilities = useRegistrationRewardAbilities(); const { showToast } = useToast(); + const [config, setConfig] = useState(null); + const [configDrawerOpen, setConfigDrawerOpen] = useState(false); const [form, setForm] = useState(emptyForm); const [configLoading, setConfigLoading] = useState(false); const [configSaving, setConfigSaving] = useState(false); @@ -52,6 +54,7 @@ export function useRegistrationRewardPage() { getRegistrationRewardConfig(), listResourceGroups({ page: 1, page_size: 100, status: "active" }), ]); + setConfig(config); setForm(formFromConfig(config)); setResourceGroups(groups.items || []); } catch (err) { @@ -65,6 +68,22 @@ export function useRegistrationRewardPage() { void reloadConfig(); }, [reloadConfig]); + const openConfigDrawer = useCallback(() => { + if (config) { + setForm(formFromConfig(config)); + } + setConfigDrawerOpen(true); + }, [config]); + + const closeConfigDrawer = useCallback(() => { + setConfigDrawerOpen(false); + if (config) { + setForm(formFromConfig(config)); + return; + } + setForm(emptyForm); + }, [config]); + const changeQuery = (value) => { setQuery(value); setPage(1); @@ -89,7 +108,9 @@ export function useRegistrationRewardPage() { setConfigSaving(true); try { const saved = await updateRegistrationRewardConfig(payloadFromForm(form)); + setConfig(saved); setForm(formFromConfig(saved)); + setConfigDrawerOpen(false); showToast("注册奖励配置已保存", "success"); await reloadClaims(); } catch (err) { @@ -106,9 +127,13 @@ export function useRegistrationRewardPage() { claims, claimsError, claimsLoading, + closeConfigDrawer, + config, + configDrawerOpen, configLoading, configSaving, form, + openConfigDrawer, page, query, reloadClaims, diff --git a/src/features/registration-reward/pages/RegistrationRewardPage.jsx b/src/features/registration-reward/pages/RegistrationRewardPage.jsx index 3285776..104c2c6 100644 --- a/src/features/registration-reward/pages/RegistrationRewardPage.jsx +++ b/src/features/registration-reward/pages/RegistrationRewardPage.jsx @@ -1,28 +1,14 @@ -import RefreshOutlined from "@mui/icons-material/RefreshOutlined"; -import SaveOutlined from "@mui/icons-material/SaveOutlined"; import Avatar from "@mui/material/Avatar"; -import MenuItem from "@mui/material/MenuItem"; -import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; -import TextField from "@mui/material/TextField"; -import { Button } from "@/shared/ui/Button.jsx"; import { DataState } from "@/shared/ui/DataState.jsx"; import { DataTable } from "@/shared/ui/DataTable.jsx"; -import { - AdminActionIconButton, - AdminListBody, - AdminListPage, - AdminListToolbar, -} from "@/shared/ui/AdminListLayout.jsx"; +import { AdminListBody, AdminListPage } from "@/shared/ui/AdminListLayout.jsx"; import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js"; import { formatMillis } from "@/shared/utils/time.js"; +import { RegistrationRewardConfigDrawer } from "@/features/registration-reward/components/RegistrationRewardConfigDrawer.jsx"; +import { RegistrationRewardConfigSummary } from "@/features/registration-reward/components/RegistrationRewardConfigSummary.jsx"; import { useRegistrationRewardPage } from "@/features/registration-reward/hooks/useRegistrationRewardPage.js"; import styles from "@/features/registration-reward/registration-reward.module.css"; -const rewardTypeOptions = [ - ["coin", "金币"], - ["resource_group", "资源组"], -]; - const claimStatusOptions = [ ["pending", "发放中"], ["granted", "已发放"], @@ -103,117 +89,13 @@ export function RegistrationRewardPage() { return ( -
-
-
- 注册奖励配置 - 注册成功后由服务端自动发放 -
-
- - - - -
-
-
-
-
发放状态
-
- - page.setForm((current) => ({ ...current, enabled: event.target.checked })) - } - /> - - page.setForm((current) => ({ ...current, dailyLimit: event.target.value })) - } - /> -
-
-
-
奖励内容
-
- - page.setForm((current) => ({ ...current, rewardType: event.target.value })) - } - > - {rewardTypeOptions.map(([value, label]) => ( - - {label} - - ))} - - {page.form.rewardType === "resource_group" ? ( - - page.setForm((current) => ({ - ...current, - resourceGroupId: event.target.value, - })) - } - > - 请选择资源组 - {page.resourceGroups.map((group) => ( - - {group.name || group.groupCode || group.groupId} - - ))} - - ) : ( - - page.setForm((current) => ({ ...current, coinAmount: event.target.value })) - } - /> - )} -
-
-
-
- - - - } + @@ -235,6 +117,17 @@ export function RegistrationRewardPage() { /> +
); } diff --git a/src/features/registration-reward/registration-reward.module.css b/src/features/registration-reward/registration-reward.module.css index 1d9d46b..8c76263 100644 --- a/src/features/registration-reward/registration-reward.module.css +++ b/src/features/registration-reward/registration-reward.module.css @@ -1,55 +1,70 @@ -.configPanel { - display: grid; - gap: var(--space-4); - padding: var(--space-5); - border-bottom: 1px solid var(--border); -} - -.configHeader { +.summaryPanel { display: flex; + flex: 0 0 auto; min-width: 0; align-items: center; justify-content: space-between; - gap: var(--space-3); + gap: var(--space-5); + padding: var(--space-4) var(--space-5); + border-bottom: 1px solid var(--border); } -.configActions { +.summaryHeader { + display: flex; + min-width: 0; + align-items: center; + flex: 0 0 auto; +} + +.summaryActions { display: inline-flex; + flex: 0 0 auto; + align-items: center; + flex-wrap: wrap; + justify-content: flex-end; + gap: var(--space-2); +} + +.summaryItems { + display: flex; + flex: 1; + min-width: 0; + align-items: center; + gap: var(--space-7); +} + +.summaryItem { + display: inline-flex; + min-width: 0; align-items: center; gap: var(--space-2); } -.configSections { - display: grid; - gap: var(--space-4); +.summaryLabel { + flex: 0 0 auto; + overflow: hidden; + color: var(--text-tertiary); + font-size: var(--admin-font-size); + font-weight: 650; + line-height: var(--admin-line-height); + text-overflow: ellipsis; + white-space: nowrap; } -.configSection { - display: grid; - gap: var(--space-3); -} - -.configSection + .configSection { - padding-top: var(--space-4); - border-top: 1px solid var(--border-soft); -} - -.configSectionTitle { - color: var(--text-secondary); - font-size: 13px; +.summaryValue { + display: inline-flex; + min-width: 0; + align-items: center; + overflow: hidden; + color: var(--text-primary); font-weight: 700; - line-height: 18px; + line-height: var(--admin-line-height); + text-overflow: ellipsis; + white-space: nowrap; } -.configGrid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: var(--space-3); -} - -.switchField { - min-height: var(--control-height); - margin: 0; +.summaryValue > :global(.status-badge) { + min-width: 0; } .identity { @@ -83,12 +98,27 @@ } @media (max-width: 900px) { - .configHeader { + .summaryPanel { align-items: flex-start; flex-direction: column; } - .configGrid { - grid-template-columns: 1fr; + .summaryHeader { + width: 100%; + } + + .summaryItems { + width: 100%; + align-items: flex-start; + flex-direction: column; + gap: var(--space-3); + } + + .summaryItem { + width: 100%; + } + + .summaryActions { + justify-content: flex-start; } } diff --git a/src/features/resources/api.test.ts b/src/features/resources/api.test.ts index 0684a1e..e1f3bee 100644 --- a/src/features/resources/api.test.ts +++ b/src/features/resources/api.test.ts @@ -1,6 +1,7 @@ import { afterEach, expect, test, vi } from "vitest"; import { setAccessToken } from "@/shared/api/request"; import { + createEmojiPack, createGift, createResource, createResourceGroup, @@ -12,6 +13,8 @@ import { enableResourceGroup, grantResource, grantResourceGroup, + listEmojiPackCategories, + listEmojiPacks, listResourceGrants, listGifts, listResources, @@ -212,3 +215,39 @@ test("resource APIs use generated admin paths", async () => { expect(grantGroupInit?.method).toBe("POST"); expect(JSON.parse(String(grantGroupInit?.body))).toMatchObject({ groupId: 22, targetUserId: 1001 }); }); + +test("emoji pack APIs use generated admin paths", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(JSON.stringify({ code: 0, data: [] }))), + ); + + await listEmojiPackCategories(); + await listEmojiPacks({ page: 1, page_size: 10, status: "active" }); + await createEmojiPack({ + animationUrl: "https://media.haiyihy.com/emoji/wave.svga", + category: "热门", + coverUrl: "https://media.haiyihy.com/emoji/wave.png", + name: "Wave", + pricingType: "paid", + regionIds: [0], + sortOrder: 0, + }); + + const [categoryUrl, categoryInit] = vi.mocked(fetch).mock.calls[0]; + const [listUrl, listInit] = vi.mocked(fetch).mock.calls[1]; + const [createUrl, createInit] = vi.mocked(fetch).mock.calls[2]; + + expect(String(categoryUrl)).toContain("/api/v1/admin/emoji-packs/categories"); + expect(categoryInit?.method).toBe("GET"); + expect(String(listUrl)).toContain("/api/v1/admin/emoji-packs?"); + expect(String(listUrl)).toContain("status=active"); + expect(listInit?.method).toBe("GET"); + expect(String(createUrl)).toContain("/api/v1/admin/emoji-packs"); + expect(createInit?.method).toBe("POST"); + expect(JSON.parse(String(createInit?.body))).toMatchObject({ + category: "热门", + name: "Wave", + pricingType: "paid", + }); +}); diff --git a/src/features/resources/api.ts b/src/features/resources/api.ts index a814760..c60702e 100644 --- a/src/features/resources/api.ts +++ b/src/features/resources/api.ts @@ -25,6 +25,7 @@ export interface ResourceDto { export interface ResourcePayload { amount: number; + animationUrl?: string; assetUrl: string; name: string; previewUrl: string; @@ -34,6 +35,32 @@ export interface ResourcePayload { status: string; } +export interface EmojiPackDto { + appCode?: string; + resourceId: number; + resourceCode?: string; + name?: string; + status?: string; + category?: string; + pricingType?: string; + coverUrl?: string; + animationUrl?: string; + regionIds?: number[]; + sortOrder?: number; + createdAtMs?: number; + updatedAtMs?: number; +} + +export interface EmojiPackPayload { + animationUrl: string; + category: string; + coverUrl: string; + name: string; + pricingType: string; + regionIds: number[]; + sortOrder: number; +} + export interface ResourceGroupItemDto { groupItemId: number; itemType?: string; @@ -124,6 +151,13 @@ export interface ResourceGrantOperatorDto { username?: string; } +export interface ResourceGrantUserDto { + avatar?: string; + displayUserId?: string; + userId?: number | string; + username?: string; +} + export interface ResourceGrantDto { appCode?: string; commandId?: string; @@ -137,7 +171,8 @@ export interface ResourceGrantDto { operatorUserId?: number; reason?: string; status?: string; - targetUserId?: number; + targetUser?: ResourceGrantUserDto; + targetUserId?: number | string; updatedAtMs?: number; } @@ -295,6 +330,29 @@ export function disableGift(giftId: string): Promise { }); } +export function listEmojiPacks(query: PageQuery = {}): Promise> { + const endpoint = API_ENDPOINTS.listEmojiPacks; + return apiRequest>(apiEndpointPath(API_OPERATIONS.listEmojiPacks), { + method: endpoint.method, + query, + }); +} + +export function listEmojiPackCategories(): Promise { + const endpoint = API_ENDPOINTS.listEmojiPackCategories; + return apiRequest(apiEndpointPath(API_OPERATIONS.listEmojiPackCategories), { + method: endpoint.method, + }); +} + +export function createEmojiPack(payload: EmojiPackPayload): Promise { + const endpoint = API_ENDPOINTS.createEmojiPack; + return apiRequest(apiEndpointPath(API_OPERATIONS.createEmojiPack), { + body: payload, + method: endpoint.method, + }); +} + export function listResourceGrants(query: PageQuery = {}): Promise> { const endpoint = API_ENDPOINTS.listResourceGrants; return apiRequest>(apiEndpointPath(API_OPERATIONS.listResourceGrants), { diff --git a/src/features/resources/constants.js b/src/features/resources/constants.js index 27ba9d4..1cdaa08 100644 --- a/src/features/resources/constants.js +++ b/src/features/resources/constants.js @@ -23,6 +23,11 @@ export const resourceGrantSubjectLabels = { resource_group: "资源组", }; +export const emojiPackPricingLabels = { + free: "免费", + paid: "付费", +}; + export const resourceTypeFilters = [ ["", "全部类型"], ["avatar_frame", "头像框"], @@ -33,6 +38,9 @@ export const resourceTypeFilters = [ ["badge", "徽章"], ["floating_screen", "飘屏"], ["gift", "礼物"], + ["mic_seat_icon", "麦位图标"], + ["mic_seat_animation", "麦位动效"], + ["emoji_pack", "表情包"], ]; export const resourceTypeLabels = Object.fromEntries(resourceTypeFilters.filter(([value]) => value)); diff --git a/src/features/resources/hooks/useResourcePages.js b/src/features/resources/hooks/useResourcePages.js index 6e68a41..71f7988 100644 --- a/src/features/resources/hooks/useResourcePages.js +++ b/src/features/resources/hooks/useResourcePages.js @@ -4,6 +4,7 @@ import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js"; import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js"; import { useToast } from "@/shared/ui/ToastProvider.jsx"; import { + createEmojiPack, createGift, createResource, createResourceGroup, @@ -15,6 +16,8 @@ import { enableResourceGroup, grantResource, grantResourceGroup, + listEmojiPackCategories, + listEmojiPacks, listGifts, listResourceGrants, listResourceGroups, @@ -25,6 +28,7 @@ import { } from "@/features/resources/api"; import { useResourceAbilities } from "@/features/resources/permissions.js"; import { + emojiPackFormSchema, giftFormSchema, resourceFormSchema, resourceGrantFormSchema, @@ -35,7 +39,7 @@ const pageSize = 10; const dayMillis = 24 * 60 * 60 * 1000; const emptyData = { items: [], page: 1, pageSize, total: 0 }; const emptyResourceForm = (resource = {}) => ({ - assetUrl: resource.assetUrl || "", + animationUrl: resource.animationUrl || resource.assetUrl || "", enabled: resource.status ? resource.status === "active" : true, name: resource.name || "", previewUrl: resource.previewUrl || "", @@ -43,6 +47,15 @@ const emptyResourceForm = (resource = {}) => ({ resourceType: resource.resourceType || "avatar_frame", walletAssetAmount: resource.walletAssetAmount ? String(resource.walletAssetAmount) : "", }); +const emptyEmojiPackForm = () => ({ + animationUrl: "", + category: "默认", + coverUrl: "", + name: "", + pricingType: "free", + regionIds: ["0"], + sortOrder: "0", +}); const emptyResourceGroupForm = (group = {}) => ({ description: group.description || "", enabled: group.status ? group.status === "active" : true, @@ -619,6 +632,138 @@ export function useGiftListPage() { }; } +export function useEmojiPackListPage() { + const abilities = useResourceAbilities(); + const { showToast } = useToast(); + const [query, setQuery] = useState(""); + const [status, setStatus] = useState(""); + const [page, setPage] = useState(1); + const [activeAction, setActiveAction] = useState(""); + const [form, setForm] = useState(emptyEmojiPackForm); + const [loadingAction, setLoadingAction] = useState(""); + const [categoryOptions, setCategoryOptions] = useState([]); + const [categoryOptionsLoading, setCategoryOptionsLoading] = useState(false); + const { loadingRegions, regionOptions } = useRegionOptions(); + const filters = useMemo( + () => ({ + keyword: query, + status, + }), + [query, status], + ); + const result = usePaginatedQuery({ + errorMessage: "加载表情包列表失败", + fetcher: listEmojiPacks, + filters, + page, + pageSize, + queryKey: ["emoji-packs", filters, page], + }); + + useEffect(() => { + let ignore = false; + setCategoryOptionsLoading(true); + listEmojiPackCategories() + .then((options) => { + if (!ignore) { + setCategoryOptions(mergeEmojiPackCategoryOptions(options)); + } + }) + .catch((err) => { + if (!ignore) { + showToast(err.message || "加载表情包分类失败", "error"); + } + }) + .finally(() => { + if (!ignore) { + setCategoryOptionsLoading(false); + } + }); + return () => { + ignore = true; + }; + }, [showToast]); + + const openCreateEmojiPack = () => { + setForm(emptyEmojiPackForm()); + setActiveAction("create"); + }; + + const closeAction = () => { + setActiveAction(""); + }; + + const submitEmojiPack = async (event) => { + event.preventDefault(); + if (!abilities.canCreateEmojiPack) { + return; + } + setLoadingAction("emoji-pack-create"); + try { + const payload = buildEmojiPackPayload(parseForm(emojiPackFormSchema, form)); + await createEmojiPack(payload); + setCategoryOptions((options) => mergeEmojiPackCategoryOptions(options, payload.category)); + showToast("表情包已创建", "success"); + closeAction(); + setForm(emptyEmojiPackForm()); + await result.reload(); + } catch (err) { + showToast(err.message || "操作失败", "error"); + } finally { + setLoadingAction(""); + } + }; + + const toggleEmojiPack = async (emojiPack, nextEnabled = emojiPack.status !== "active") => { + if (!abilities.canUpdate || !emojiPack?.resourceId) { + return; + } + if ((emojiPack.status === "active") === nextEnabled) { + return; + } + setLoadingAction(`emoji-pack-status-${emojiPack.resourceId}`); + try { + if (nextEnabled) { + await enableResource(emojiPack.resourceId); + } else { + await disableResource(emojiPack.resourceId); + } + showToast(nextEnabled ? "表情包已启用" : "表情包已禁用", "success"); + await result.reload(); + } catch (err) { + showToast(err.message || "操作失败", "error"); + } finally { + setLoadingAction(""); + } + }; + + const resetFilters = () => { + setQuery(""); + setStatus(""); + setPage(1); + }; + + return { + ...sharedPageState({ page, query, result, setPage, setQuery }), + abilities, + activeAction, + categoryOptions, + categoryOptionsLoading, + changeStatus: resetSetter(setStatus, setPage), + closeAction, + form, + loadingAction, + loadingRegions, + openCreateEmojiPack, + regionOptions, + resetFilters, + setForm, + status, + submitEmojiPack, + toggleEmojiPack, + }; +} + export function useResourceGrantListPage() { const abilities = useResourceAbilities(); const { showToast } = useToast(); @@ -759,9 +904,11 @@ function resetSetter(setValue, setPage) { function buildResourcePayload(form) { const resourceType = form.resourceType; const isCoin = resourceType === "coin"; + const animationUrl = form.animationUrl.trim(); return { amount: isCoin ? Number(form.walletAssetAmount) : 0, - assetUrl: form.assetUrl.trim(), + animationUrl, + assetUrl: animationUrl, name: form.name.trim(), previewUrl: form.previewUrl.trim(), resourceCode: form.resourceCode.trim(), @@ -771,6 +918,36 @@ function buildResourcePayload(form) { }; } +function buildEmojiPackPayload(form) { + const regionIds = (form.regionIds || []) + .map(Number) + .filter((regionId) => Number.isInteger(regionId) && regionId >= 0); + return { + animationUrl: form.animationUrl.trim(), + category: form.category.trim(), + coverUrl: form.coverUrl.trim(), + name: form.name.trim(), + pricingType: form.pricingType, + regionIds: regionIds.length ? regionIds : [0], + sortOrder: Number(form.sortOrder || 0), + }; +} + +function mergeEmojiPackCategoryOptions(options = [], value = "默认") { + const merged = new Set(["默认"]); + options.forEach((option) => { + const category = String(option || "").trim(); + if (category) { + merged.add(category); + } + }); + const category = String(value || "").trim(); + if (category) { + merged.add(category); + } + return Array.from(merged).sort((left, right) => left.localeCompare(right, "zh-Hans-CN")); +} + function buildResourceGroupPayload(form) { return { description: (form.description || "").trim(), diff --git a/src/features/resources/pages/EmojiPackListPage.jsx b/src/features/resources/pages/EmojiPackListPage.jsx new file mode 100644 index 0000000..b18f2d0 --- /dev/null +++ b/src/features/resources/pages/EmojiPackListPage.jsx @@ -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) => , + }, + { + key: "animation", + label: "动效图", + width: "minmax(220px, 1.2fr)", + render: (item) => item.animationUrl || "-", + }, + { + key: "category", + label: "分类 / 类型", + width: "minmax(150px, 0.75fr)", + render: (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) => , + } + : column.key === "regions" + ? { + ...column, + render: (item) => , + } + : column, + ); + + return ( + + + + + ) : null + } + /> + + + 0 + ? { + page: page.page, + pageSize: page.data.pageSize || 10, + total, + onPageChange: page.setPage, + } + : undefined + } + rowKey={(item) => item.resourceId} + /> + + + option.value)} + setForm={page.setForm} + uploadDisabled={!page.abilities.canUpload} + onClose={page.closeAction} + onSubmit={page.submitEmojiPack} + /> + + ); +} + +function EmojiPackFormDialog({ + categoryOptions, + disabled, + form, + loadingCategories, + loading, + onClose, + onSubmit, + open, + regionLabelById, + regionOptions, + setForm, + uploadDisabled, +}) { + return ( + + + + setForm({ ...form, name: event.target.value })} + /> + setForm({ ...form, category: normalizeCategoryInput(value) })} + onInputChange={(_, value) => setForm({ ...form, category: normalizeCategoryInput(value) })} + renderInput={(params) => } + /> + setForm({ ...form, sortOrder: event.target.value })} + /> + setForm({ ...form, regionIds: normalizeSelectedRegions(regionIds) })} + /> + setForm({ ...form, pricingType: checked ? "free" : "paid" })} + /> + + + + + setForm({ ...form, coverUrl })} + /> + setForm({ ...form, animationUrl })} + /> + + + + ); +} + +function EmojiPackTags({ item }) { + const pricingType = item.pricingType || "free"; + return ( +
+ {item.category || "默认"} + {emojiPackPricingLabels[pricingType] || pricingType} +
+ ); +} + +function EmojiPackStatusSwitch({ item, page }) { + const checked = item.status === "active"; + return ( + page.toggleEmojiPack(item, event.target.checked)} + /> + ); +} + +function EmojiPackIdentity({ item }) { + const preview = imageURL(item.coverUrl); + return ( +
+ + {preview ? : } + +
+ {item.name || "-"} + {item.resourceCode || item.resourceId} +
+
+ ); +} + +function RegionTags({ regionIds = [], regionLabelById }) { + const values = regionIds.length ? regionIds.map(String) : ["0"]; + return ( +
+ {values.map((value) => ( + + {regionLabelById[value] || value} + + ))} +
+ ); +} + +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 : ""; +} diff --git a/src/features/resources/pages/ResourceGrantListPage.jsx b/src/features/resources/pages/ResourceGrantListPage.jsx index 76033b7..7648708 100644 --- a/src/features/resources/pages/ResourceGrantListPage.jsx +++ b/src/features/resources/pages/ResourceGrantListPage.jsx @@ -35,9 +35,9 @@ const grantColumns = [ }, { key: "target", - label: "用户 ID", - width: "minmax(120px, 0.7fr)", - render: (grant) => grant.targetUserId || "-", + label: "用户信息", + width: "minmax(240px, 1.1fr)", + render: (grant) => , }, { 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 ( +
+ + {user.avatar ? : } + + + {name} + 长ID {userId || "-"} + {displayUserId ? 短ID {displayUserId} : null} + +
+ ); +} + function GrantOperator({ grant }) { const operator = grant.operator || {}; const source = operator.source || grant.grantSource || ""; diff --git a/src/features/resources/pages/ResourceListPage.jsx b/src/features/resources/pages/ResourceListPage.jsx index ae06a7b..2baee42 100644 --- a/src/features/resources/pages/ResourceListPage.jsx +++ b/src/features/resources/pages/ResourceListPage.jsx @@ -23,7 +23,6 @@ import { import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js"; import { formatMillis } from "@/shared/utils/time.js"; import { - grantStrategyLabels, resourceStatusFilters, resourceTypeFilters, resourceTypeLabels, @@ -31,7 +30,7 @@ import { import { useResourceListPage } from "@/features/resources/hooks/useResourcePages.js"; import styles from "@/features/resources/resources.module.css"; -const resourceTypeOptions = resourceTypeFilters.filter(([value]) => value); +const resourceTypeOptions = resourceTypeFilters.filter(([value]) => value && value !== "emoji_pack"); const baseColumns = [ { @@ -46,18 +45,6 @@ const baseColumns = [ width: "minmax(120px, 0.7fr)", render: (resource) => resourceTypeLabels[resource.resourceType] || resource.resourceType || "-", }, - { - key: "grant", - label: "发放规则", - width: "minmax(170px, 1fr)", - render: (resource) => , - }, - { - key: "scopes", - label: "使用范围", - width: "minmax(180px, 1fr)", - render: (resource) => , - }, { key: "status", label: "状态", @@ -102,22 +89,22 @@ export function ResourceListPage() { }), } : column.key === "status" - ? { - ...column, - filter: createOptionsColumnFilter({ - options: resourceStatusFilters, - placeholder: "搜索状态", - value: page.status, - onChange: page.changeStatus, - }), - render: (resource) => , - } - : column.key === "actions" - ? { - ...column, - render: (resource) => , - } - : column, + ? { + ...column, + filter: createOptionsColumnFilter({ + options: resourceStatusFilters, + placeholder: "搜索状态", + value: page.status, + onChange: page.changeStatus, + }), + render: (resource) => , + } + : column.key === "actions" + ? { + ...column, + render: (resource) => , + } + : column, ); return ( @@ -136,7 +123,7 @@ export function ResourceListPage() { 0 ? { @@ -217,7 +204,9 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit, required select value={form.resourceType} - onChange={(event) => setForm({ ...form, resourceType: event.target.value, walletAssetAmount: "" })} + onChange={(event) => + setForm({ ...form, resourceType: event.target.value, walletAssetAmount: "" }) + } > {resourceTypeOptions.map(([value, label]) => ( @@ -241,15 +230,17 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit, setForm({ ...form, previewUrl })} /> setForm({ ...form, assetUrl })} + kind="file" + label="动效素材" + value={form.animationUrl} + onChange={(animationUrl) => setForm({ ...form, animationUrl })} /> @@ -282,21 +273,6 @@ function ResourceIdentity({ resource }) { ); } -function GrantInfo({ resource }) { - const strategy = grantStrategyLabels[resource.grantStrategy] || resource.grantStrategy || "-"; - if (resource.walletAssetType || resource.walletAssetAmount) { - return ( -
- {strategy} - - {resource.walletAssetType || "-"} {formatNumber(resource.walletAssetAmount)} - -
- ); - } - return strategy; -} - function ResourceStatusSwitch({ page, resource }) { const checked = resource.status === "active"; return ( @@ -309,22 +285,6 @@ function ResourceStatusSwitch({ page, resource }) { ); } -function TagList({ values = [] }) { - const items = values.filter(Boolean); - if (!items.length) { - return "-"; - } - return ( -
- {items.map((value) => ( - - {value} - - ))} -
- ); -} - function imageURL(value) { const url = String(value || "").trim(); if (!url) { @@ -332,7 +292,3 @@ function imageURL(value) { } return /\.(avif|gif|jpe?g|png|webp)(\?|#|$)/i.test(url) ? url : ""; } - -function formatNumber(value) { - return Number(value || 0).toLocaleString("zh-CN"); -} diff --git a/src/features/resources/permissions.js b/src/features/resources/permissions.js index 8c925da..4d94fa0 100644 --- a/src/features/resources/permissions.js +++ b/src/features/resources/permissions.js @@ -2,19 +2,21 @@ import { useAuth } from "@/app/auth/AuthProvider.jsx"; import { PERMISSIONS } from "@/app/permissions"; export function useResourceAbilities() { - const { can } = useAuth(); + const { can } = useAuth(); - return { - canCreate: can(PERMISSIONS.resourceCreate), - canCreateGift: can(PERMISSIONS.giftCreate), - canCreateGrant: can(PERMISSIONS.resourceGrantCreate), - canCreateGroup: can(PERMISSIONS.resourceGroupCreate), - canStatusGift: can(PERMISSIONS.giftStatus), - canUpdateGroup: can(PERMISSIONS.resourceGroupUpdate), - canUpdateGift: can(PERMISSIONS.giftUpdate), - canUpdate: can(PERMISSIONS.resourceUpdate), - canUpload: can(PERMISSIONS.uploadCreate), - canView: can(PERMISSIONS.resourceView), - canViewGrant: can(PERMISSIONS.resourceGrantView) - }; + return { + canCreate: can(PERMISSIONS.resourceCreate), + canCreateGift: can(PERMISSIONS.giftCreate), + canCreateGrant: can(PERMISSIONS.resourceGrantCreate), + canCreateGroup: can(PERMISSIONS.resourceGroupCreate), + canCreateEmojiPack: can(PERMISSIONS.emojiPackCreate), + canStatusGift: can(PERMISSIONS.giftStatus), + canUpdateGroup: can(PERMISSIONS.resourceGroupUpdate), + canUpdateGift: can(PERMISSIONS.giftUpdate), + canUpdate: can(PERMISSIONS.resourceUpdate), + canUpload: can(PERMISSIONS.uploadCreate), + canView: can(PERMISSIONS.resourceView), + canViewEmojiPack: can(PERMISSIONS.emojiPackView), + canViewGrant: can(PERMISSIONS.resourceGrantView), + }; } diff --git a/src/features/resources/routes.js b/src/features/resources/routes.js index eb6f077..285824f 100644 --- a/src/features/resources/routes.js +++ b/src/features/resources/routes.js @@ -1,36 +1,44 @@ import { MENU_CODES, PERMISSIONS } from "@/app/permissions"; export const resourceRoutes = [ - { - label: "资源列表", - loader: () => import("./pages/ResourceListPage.jsx").then((module) => module.ResourceListPage), - menuCode: MENU_CODES.resourceList, - pageKey: "resource-list", - path: "/resources", - permission: PERMISSIONS.resourceView - }, - { - label: "资源组列表", - loader: () => import("./pages/ResourceGroupListPage.jsx").then((module) => module.ResourceGroupListPage), - menuCode: MENU_CODES.resourceGroupList, - pageKey: "resource-group-list", - path: "/resource-groups", - permission: PERMISSIONS.resourceGroupView - }, - { - label: "礼物列表", - loader: () => import("./pages/GiftListPage.jsx").then((module) => module.GiftListPage), - menuCode: MENU_CODES.giftList, - pageKey: "gift-list", - path: "/gifts", - permission: PERMISSIONS.giftView - }, - { - label: "资源赠送", - loader: () => import("./pages/ResourceGrantListPage.jsx").then((module) => module.ResourceGrantListPage), - menuCode: MENU_CODES.resourceGrantList, - pageKey: "resource-grant-list", - path: "/resource-grants", - permission: PERMISSIONS.resourceGrantView - } + { + label: "资源列表", + loader: () => import("./pages/ResourceListPage.jsx").then((module) => module.ResourceListPage), + menuCode: MENU_CODES.resourceList, + pageKey: "resource-list", + path: "/resources", + permission: PERMISSIONS.resourceView, + }, + { + label: "资源组列表", + loader: () => import("./pages/ResourceGroupListPage.jsx").then((module) => module.ResourceGroupListPage), + menuCode: MENU_CODES.resourceGroupList, + pageKey: "resource-group-list", + path: "/resource-groups", + permission: PERMISSIONS.resourceGroupView, + }, + { + label: "礼物列表", + loader: () => import("./pages/GiftListPage.jsx").then((module) => module.GiftListPage), + menuCode: MENU_CODES.giftList, + pageKey: "gift-list", + path: "/gifts", + permission: PERMISSIONS.giftView, + }, + { + label: "表情包列表", + loader: () => import("./pages/EmojiPackListPage.jsx").then((module) => module.EmojiPackListPage), + menuCode: MENU_CODES.emojiPackList, + pageKey: "emoji-pack-list", + path: "/emoji-packs", + permission: PERMISSIONS.emojiPackView, + }, + { + label: "资源赠送", + loader: () => import("./pages/ResourceGrantListPage.jsx").then((module) => module.ResourceGrantListPage), + menuCode: MENU_CODES.resourceGrantList, + pageKey: "resource-grant-list", + path: "/resource-grants", + permission: PERMISSIONS.resourceGrantView, + }, ]; diff --git a/src/features/resources/schema.js b/src/features/resources/schema.js index 8c09863..e02341b 100644 --- a/src/features/resources/schema.js +++ b/src/features/resources/schema.js @@ -1,6 +1,18 @@ import { z } from "zod"; -const resourceTypes = ["avatar_frame", "profile_card", "coin", "vehicle", "chat_bubble", "badge", "floating_screen", "gift"]; +const resourceTypes = [ + "avatar_frame", + "profile_card", + "coin", + "vehicle", + "chat_bubble", + "badge", + "floating_screen", + "gift", + "mic_seat_icon", + "mic_seat_animation", + "emoji_pack", +]; const walletAssetTypes = ["COIN", "DIAMOND"]; const optionalWalletAssetTypeSchema = z .preprocess((value) => { @@ -14,7 +26,7 @@ const optionalWalletAssetTypeSchema = z export const resourceCreateFormSchema = z .object({ - assetUrl: z.string().trim().min(1, "请上传资源素材").max(512, "资源素材不能超过 512 个字符"), + animationUrl: z.string().trim().min(1, "请上传动效素材").max(512, "动效素材不能超过 512 个字符"), enabled: z.boolean(), name: z.string().trim().min(1, "请输入资源名称").max(128, "资源名称不能超过 128 个字符"), previewUrl: z.string().trim().min(1, "请上传资源封面").max(512, "资源封面不能超过 512 个字符"), @@ -38,6 +50,16 @@ export const resourceCreateFormSchema = z export const resourceFormSchema = resourceCreateFormSchema; +export const emojiPackFormSchema = z.object({ + animationUrl: z.string().trim().min(1, "请上传动效图").max(512, "动效图不能超过 512 个字符"), + category: z.string().trim().min(1, "请输入分类").max(64, "分类不能超过 64 个字符"), + coverUrl: z.string().trim().min(1, "请上传封面图").max(512, "封面图不能超过 512 个字符"), + name: z.string().trim().min(1, "请输入名称").max(128, "名称不能超过 128 个字符"), + pricingType: z.enum(["free", "paid"]), + regionIds: z.array(z.union([z.string(), z.number()])).optional(), + sortOrder: z.union([z.string(), z.number()]).optional(), +}); + const groupItemSchema = z.object({ durationDays: z.union([z.string(), z.number()]).optional(), itemType: z.enum(["resource", "wallet_asset"]), @@ -119,7 +141,18 @@ export const giftFormSchema = z enabled: z.boolean(), giftId: z.string().trim().min(1, "请输入礼物 ID").max(96, "礼物 ID 不能超过 96 个字符"), giftPointAmount: z.union([z.string(), z.number()]).optional(), - giftTypeCode: z.enum(["normal", "cp", "lucky", "super_lucky", "exclusive", "noble", "flag", "activity", "magic", "custom"]), + giftTypeCode: z.enum([ + "normal", + "cp", + "lucky", + "super_lucky", + "exclusive", + "noble", + "flag", + "activity", + "magic", + "custom", + ]), heatValue: z.union([z.string(), z.number()]).optional(), name: z.string().trim().min(1, "请输入礼物名称").max(128, "礼物名称不能超过 128 个字符"), presentationJson: z.string().trim().optional(), diff --git a/src/features/resources/schema.test.ts b/src/features/resources/schema.test.ts index e6265f8..e7fb6ff 100644 --- a/src/features/resources/schema.test.ts +++ b/src/features/resources/schema.test.ts @@ -1,11 +1,17 @@ import { describe, expect, test } from "vitest"; import { FormValidationError, parseForm } from "@/shared/forms/validation"; -import { giftFormSchema, resourceFormSchema, resourceGrantFormSchema, resourceGroupCreateFormSchema } from "./schema.js"; +import { + emojiPackFormSchema, + giftFormSchema, + resourceFormSchema, + resourceGrantFormSchema, + resourceGroupCreateFormSchema +} from "./schema.js"; describe("resource form schema", () => { test("allows profile card resources", () => { const payload = parseForm(resourceFormSchema, { - assetUrl: "https://media.haiyihy.com/resource/profile-card.png", + animationUrl: "https://media.haiyihy.com/resource/profile-card.pag", enabled: true, name: "Profile Card", previewUrl: "https://media.haiyihy.com/resource/profile-card-cover.png", @@ -84,6 +90,21 @@ describe("resource form schema", () => { expect(payload.regionIds).toEqual(["0", "1001"]); }); + test("validates emoji pack category and pricing type", () => { + const payload = parseForm(emojiPackFormSchema, { + animationUrl: "https://media.haiyihy.com/emoji/wave.svga", + category: "热门", + coverUrl: "https://media.haiyihy.com/emoji/wave.png", + name: "Wave", + pricingType: "paid", + regionIds: ["0"], + sortOrder: "0" + }); + + expect(payload.category).toBe("热门"); + expect(payload.pricingType).toBe("paid"); + }); + test("validates resource grant form fields", () => { const payload = parseForm(resourceGrantFormSchema, { durationDays: "7", diff --git a/src/features/rooms/components/RoomDetailDrawer.jsx b/src/features/rooms/components/RoomDetailDrawer.jsx index bbd7f93..dea1c8a 100644 --- a/src/features/rooms/components/RoomDetailDrawer.jsx +++ b/src/features/rooms/components/RoomDetailDrawer.jsx @@ -11,6 +11,7 @@ export function RoomDetailDrawer({ onClose, open, room }) { const owner = room?.owner || {}; const normalizedStatus = room?.status === "active" ? "active" : "closed"; const ownerDisplayId = ownerLongShortId(owner, room); + const closeOperator = room?.closedByAdminName || (room?.closedByAdminId ? String(room.closedByAdminId) : ""); return ( @@ -42,6 +43,17 @@ export function RoomDetailDrawer({ onClose, open, room }) { title="基础信息" /> + {normalizedStatus === "closed" ? ( + + ) : null} +

房主信息

diff --git a/src/features/rooms/hooks/useRoomsPage.js b/src/features/rooms/hooks/useRoomsPage.js index 6926a9c..986f361 100644 --- a/src/features/rooms/hooks/useRoomsPage.js +++ b/src/features/rooms/hooks/useRoomsPage.js @@ -16,7 +16,7 @@ const emptyForm = () => ({ title: "", visibleRegionId: 0, }); -const emptyStatusForm = () => ({ status: "active" }); +const emptyStatusForm = () => ({ closeReason: "", status: "active" }); export function useRoomsPage() { const abilities = useRoomAbilities(); @@ -100,9 +100,9 @@ export function useRoomsPage() { setActiveAction("detail"); }; - const openStatus = (room) => { + const openStatus = (room, statusValue = "closed") => { setActiveRoom(room); - setStatusForm({ status: room.status === "closed" ? "closed" : "active" }); + setStatusForm({ closeReason: "", status: statusValue }); setActiveAction("status"); }; @@ -132,7 +132,7 @@ export function useRoomsPage() { if (!activeRoom) { return; } - await runAction("status", "房间状态已更新", async () => { + await runAction(`status-${activeRoom.roomId}`, "房间状态已更新", async () => { const payload = parseForm(roomStatusSchema, statusForm); await updateRoom(activeRoom.roomId, payload); closeAction(); @@ -140,6 +140,17 @@ export function useRoomsPage() { }); }; + const toggleRoomStatus = async (room, checked) => { + if (!checked) { + openStatus(room, "closed"); + return; + } + await runAction(`status-${room.roomId}`, "房间状态已更新", async () => { + await updateRoom(room.roomId, { status: "active" }); + await reload(); + }); + }; + const removeRoom = async (room) => { const ok = await confirm({ confirmText: "删除", @@ -200,6 +211,7 @@ export function useRoomsPage() { statusForm, submitStatus, submitEdit, + toggleRoomStatus, toggleContributionSort, }; } diff --git a/src/features/rooms/pages/RoomListPage.jsx b/src/features/rooms/pages/RoomListPage.jsx index a6f879f..4db6704 100644 --- a/src/features/rooms/pages/RoomListPage.jsx +++ b/src/features/rooms/pages/RoomListPage.jsx @@ -5,6 +5,7 @@ import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined"; import TextField from "@mui/material/TextField"; import Tooltip from "@mui/material/Tooltip"; import { AdminFormDialog, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx"; +import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; import { DataState } from "@/shared/ui/DataState.jsx"; import { DataTable } from "@/shared/ui/DataTable.jsx"; import { IconButton } from "@/shared/ui/IconButton.jsx"; @@ -53,7 +54,7 @@ const columns = [ key: "status", label: "状态", width: "minmax(100px, 0.7fr)", - render: (room) => , + render: (room) => roomStatusLabels[room.status === "active" ? "active" : "closed"], }, { key: "time", @@ -98,7 +99,7 @@ export function RoomListPage() { value: page.status, onChange: page.changeStatus, }), - render: (room) => , + render: (room) => , } : column.key === "roomContribution" ? { @@ -188,6 +189,26 @@ export function RoomListPage() { /> + + page.setStatusForm({ ...page.statusForm, closeReason: event.target.value })} + /> + +
); } @@ -268,14 +289,19 @@ function RoomActions({ page, room }) { ); } -function RoomStatus({ status }) { - const normalizedStatus = status === "active" ? "active" : "closed"; - const tone = normalizedStatus === "active" ? "running" : "stopped"; +function RoomStatusSwitch({ page, room }) { + const checked = room.status === "active"; return ( - - - {roomStatusLabels[normalizedStatus]} - +
+ page.toggleRoomStatus(room, event.target.checked)} + /> +
); } diff --git a/src/features/rooms/rooms.module.css b/src/features/rooms/rooms.module.css index 6fe7a5f..9e76cc9 100644 --- a/src/features/rooms/rooms.module.css +++ b/src/features/rooms/rooms.module.css @@ -217,6 +217,13 @@ gap: var(--space-2); } +.statusCell { + display: inline-flex; + min-width: 0; + align-items: center; + gap: var(--space-2); +} + .statusButton { display: inline-flex; align-items: center; diff --git a/src/features/rooms/schema.ts b/src/features/rooms/schema.ts index 40daa25..ffaf78b 100644 --- a/src/features/rooms/schema.ts +++ b/src/features/rooms/schema.ts @@ -10,12 +10,18 @@ export const roomUpdateSchema = z.object({ visibleRegionId: z.coerce.number().int().min(0, "区域 ID 不正确").default(0), }); -export const roomStatusSchema = z.object({ - status: z - .string() - .trim() - .refine((value) => value === "active" || value === "closed", "房间状态不正确"), -}); +export const roomStatusSchema = z + .object({ + closeReason: z.string().trim().max(512, "关闭原因不能超过 512 个字符").optional().default(""), + status: z + .string() + .trim() + .refine((value) => value === "active" || value === "closed", "房间状态不正确"), + }) + .refine((value) => value.status !== "closed" || value.closeReason.length > 0, { + message: "请输入关闭原因", + path: ["closeReason"], + }); export const roomConfigSchema = z .object({ diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index 7b56cd8..e27b109 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -27,14 +27,17 @@ export const API_OPERATIONS = { cancelRoomPin: "cancelRoomPin", changePassword: "changePassword", closeAgency: "closeAgency", + createAchievementDefinition: "createAchievementDefinition", createAgency: "createAgency", createAppVersion: "createAppVersion", createBanner: "createBanner", createBD: "createBD", createBDLeader: "createBDLeader", createCatalog: "createCatalog", + createCoinAdjustment: "createCoinAdjustment", createCoinSeller: "createCoinSeller", createCountry: "createCountry", + createEmojiPack: "createEmojiPack", createGift: "createGift", createMenu: "createMenu", createPermission: "createPermission", @@ -46,6 +49,7 @@ export const API_OPERATIONS = { createRole: "createRole", createRoomPin: "createRoomPin", createTaskDefinition: "createTaskDefinition", + createTier: "createTier", createUser: "createUser", createUserExportJob: "createUserExportJob", creditCoinSellerStock: "creditCoinSellerStock", @@ -83,6 +87,7 @@ export const API_OPERATIONS = { getUser: "getUser", grantResource: "grantResource", grantResourceGroup: "grantResourceGroup", + listAchievementDefinitions: "listAchievementDefinitions", listAgencies: "listAgencies", listApps: "listApps", listAppVersions: "listAppVersions", @@ -90,19 +95,24 @@ export const API_OPERATIONS = { listBDLeaders: "listBDLeaders", listBDs: "listBDs", listCatalog: "listCatalog", + listCoinAdjustments: "listCoinAdjustments", listCoinLedger: "listCoinLedger", listCoinSellers: "listCoinSellers", listCountries: "listCountries", + listEmojiPackCategories: "listEmojiPackCategories", + listEmojiPacks: "listEmojiPacks", listGifts: "listGifts", listH5Links: "listH5Links", listHosts: "listHosts", listJobs: "listJobs", + listLevelConfig: "listLevelConfig", listLoginLogs: "listLoginLogs", listOperationLogs: "listOperationLogs", listPermissions: "listPermissions", listPlatforms: "listPlatforms", listRechargeBills: "listRechargeBills", listRechargeProducts: "listRechargeProducts", + listRegionBlocks: "listRegionBlocks", listRegions: "listRegions", listRegistrationRewardClaims: "listRegistrationRewardClaims", listResourceGrants: "listResourceGrants", @@ -116,9 +126,11 @@ export const API_OPERATIONS = { listUsers: "listUsers", login: "login", logout: "logout", + lookupCoinAdjustmentTarget: "lookupCoinAdjustmentTarget", me: "me", navigationMenus: "navigationMenus", refresh: "refresh", + replaceRegionBlocks: "replaceRegionBlocks", replaceRegionCountries: "replaceRegionCountries", replaceRoleDataScopes: "replaceRoleDataScopes", replaceRolePermissions: "replaceRolePermissions", @@ -132,6 +144,7 @@ export const API_OPERATIONS = { setTaskDefinitionStatus: "setTaskDefinitionStatus", sortMenus: "sortMenus", syncPermissions: "syncPermissions", + updateAchievementDefinition: "updateAchievementDefinition", updateAppVersion: "updateAppVersion", updateBanner: "updateBanner", updateCatalog: "updateCatalog", @@ -152,10 +165,13 @@ export const API_OPERATIONS = { updateRoom: "updateRoom", updateRoomConfig: "updateRoomConfig", updateTaskDefinition: "updateTaskDefinition", + updateTier: "updateTier", + updateTrack: "updateTrack", updateUser: "updateUser", updateUserStatus: "updateUserStatus", uploadFile: "uploadFile", - uploadImage: "uploadImage" + uploadImage: "uploadImage", + upsertRule: "upsertRule" } as const; export type ApiOperationId = (typeof API_OPERATIONS)[keyof typeof API_OPERATIONS]; @@ -257,6 +273,13 @@ export const API_ENDPOINTS: Record = { permission: "agency:status", permissions: ["agency:status"] }, + createAchievementDefinition: { + method: "POST", + operationId: API_OPERATIONS.createAchievementDefinition, + path: "/v1/admin/activity/achievements", + permission: "achievement:create", + permissions: ["achievement:create"] + }, createAgency: { method: "POST", operationId: API_OPERATIONS.createAgency, @@ -299,6 +322,13 @@ export const API_ENDPOINTS: Record = { permission: "game:create", permissions: ["game:create"] }, + createCoinAdjustment: { + method: "POST", + operationId: API_OPERATIONS.createCoinAdjustment, + path: "/v1/admin/operations/coin-adjustments", + permission: "coin-adjustment:create", + permissions: ["coin-adjustment:create"] + }, createCoinSeller: { method: "POST", operationId: API_OPERATIONS.createCoinSeller, @@ -313,6 +343,13 @@ export const API_ENDPOINTS: Record = { permission: "country:create", permissions: ["country:create"] }, + createEmojiPack: { + method: "POST", + operationId: API_OPERATIONS.createEmojiPack, + path: "/v1/admin/emoji-packs", + permission: "emoji-pack:create", + permissions: ["emoji-pack:create"] + }, createGift: { method: "POST", operationId: API_OPERATIONS.createGift, @@ -390,6 +427,13 @@ export const API_ENDPOINTS: Record = { permission: "daily-task:create", permissions: ["daily-task:create"] }, + createTier: { + method: "POST", + operationId: API_OPERATIONS.createTier, + path: "/v1/admin/users/level-config/tiers", + permission: "level-config:update", + permissions: ["level-config:update"] + }, createUser: { method: "POST", operationId: API_OPERATIONS.createUser, @@ -649,6 +693,13 @@ export const API_ENDPOINTS: Record = { permission: "resource-grant:create", permissions: ["resource-grant:create"] }, + listAchievementDefinitions: { + method: "GET", + operationId: API_OPERATIONS.listAchievementDefinitions, + path: "/v1/admin/activity/achievements", + permission: "achievement:view", + permissions: ["achievement:view"] + }, listAgencies: { method: "GET", operationId: API_OPERATIONS.listAgencies, @@ -696,6 +747,13 @@ export const API_ENDPOINTS: Record = { permission: "game:view", permissions: ["game:view"] }, + listCoinAdjustments: { + method: "GET", + operationId: API_OPERATIONS.listCoinAdjustments, + path: "/v1/admin/operations/coin-adjustments", + permission: "coin-adjustment:view", + permissions: ["coin-adjustment:view"] + }, listCoinLedger: { method: "GET", operationId: API_OPERATIONS.listCoinLedger, @@ -717,6 +775,20 @@ export const API_ENDPOINTS: Record = { permission: "country:view", permissions: ["country:view"] }, + listEmojiPackCategories: { + method: "GET", + operationId: API_OPERATIONS.listEmojiPackCategories, + path: "/v1/admin/emoji-packs/categories", + permission: "emoji-pack:view", + permissions: ["emoji-pack:view"] + }, + listEmojiPacks: { + method: "GET", + operationId: API_OPERATIONS.listEmojiPacks, + path: "/v1/admin/emoji-packs", + permission: "emoji-pack:view", + permissions: ["emoji-pack:view"] + }, listGifts: { method: "GET", operationId: API_OPERATIONS.listGifts, @@ -745,6 +817,13 @@ export const API_ENDPOINTS: Record = { permission: "job:view", permissions: ["job:view"] }, + listLevelConfig: { + method: "GET", + operationId: API_OPERATIONS.listLevelConfig, + path: "/v1/admin/users/level-config", + permission: "level-config:view", + permissions: ["level-config:view"] + }, listLoginLogs: { method: "GET", operationId: API_OPERATIONS.listLoginLogs, @@ -787,6 +866,13 @@ export const API_ENDPOINTS: Record = { permission: "payment-product:view", permissions: ["payment-product:view"] }, + listRegionBlocks: { + method: "GET", + operationId: API_OPERATIONS.listRegionBlocks, + path: "/v1/admin/users/region-blocks", + permission: "region-block:view", + permissions: ["region-block:view"] + }, listRegions: { method: "GET", operationId: API_OPERATIONS.listRegions, @@ -874,6 +960,13 @@ export const API_ENDPOINTS: Record = { operationId: API_OPERATIONS.logout, path: "/v1/auth/logout" }, + lookupCoinAdjustmentTarget: { + method: "GET", + operationId: API_OPERATIONS.lookupCoinAdjustmentTarget, + path: "/v1/admin/operations/coin-adjustments/target", + permission: "coin-adjustment:create", + permissions: ["coin-adjustment:create"] + }, me: { method: "GET", operationId: API_OPERATIONS.me, @@ -889,6 +982,13 @@ export const API_ENDPOINTS: Record = { operationId: API_OPERATIONS.refresh, path: "/v1/auth/refresh" }, + replaceRegionBlocks: { + method: "PUT", + operationId: API_OPERATIONS.replaceRegionBlocks, + path: "/v1/admin/users/region-blocks", + permission: "region-block:update", + permissions: ["region-block:update"] + }, replaceRegionCountries: { method: "PUT", operationId: API_OPERATIONS.replaceRegionCountries, @@ -978,6 +1078,13 @@ export const API_ENDPOINTS: Record = { permission: "permission:sync", permissions: ["permission:sync"] }, + updateAchievementDefinition: { + method: "PUT", + operationId: API_OPERATIONS.updateAchievementDefinition, + path: "/v1/admin/activity/achievements/{achievement_id}", + permission: "achievement:update", + permissions: ["achievement:update"] + }, updateAppVersion: { method: "PUT", operationId: API_OPERATIONS.updateAppVersion, @@ -1118,6 +1225,20 @@ export const API_ENDPOINTS: Record = { permission: "daily-task:update", permissions: ["daily-task:update"] }, + updateTier: { + method: "PUT", + operationId: API_OPERATIONS.updateTier, + path: "/v1/admin/users/level-config/tiers/{tier_id}", + permission: "level-config:update", + permissions: ["level-config:update"] + }, + updateTrack: { + method: "PUT", + operationId: API_OPERATIONS.updateTrack, + path: "/v1/admin/users/level-config/tracks/{track}", + permission: "level-config:update", + permissions: ["level-config:update"] + }, updateUser: { method: "PATCH", operationId: API_OPERATIONS.updateUser, @@ -1145,6 +1266,13 @@ export const API_ENDPOINTS: Record = { path: "/v1/admin/files/image/upload", permission: "upload:create", permissions: ["upload:create"] + }, + upsertRule: { + method: "PUT", + operationId: API_OPERATIONS.upsertRule, + path: "/v1/admin/users/level-config/rules/{track}/{level}", + permission: "level-config:update", + permissions: ["level-config:update"] } }; diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 21bf0b1..6c4399f 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -4,6 +4,38 @@ */ export interface paths { + "/admin/activity/achievements": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listAchievementDefinitions"]; + put?: never; + post: operations["createAchievementDefinition"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/activity/achievements/{achievement_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["updateAchievementDefinition"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/admin/activity/daily-tasks": { parameters: { query?: never; @@ -420,6 +452,38 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/emoji-packs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listEmojiPacks"]; + put?: never; + post: operations["createEmojiPack"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/emoji-packs/categories": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listEmojiPackCategories"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/admin/files/image/upload": { parameters: { query?: never; @@ -612,6 +676,38 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/operations/coin-adjustments": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listCoinAdjustments"]; + put?: never; + post: operations["createCoinAdjustment"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/operations/coin-adjustments/target": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["lookupCoinAdjustmentTarget"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/admin/operations/coin-ledger": { parameters: { query?: never; @@ -1012,6 +1108,102 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/users/level-config": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listLevelConfig"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/users/level-config/rules/{track}/{level}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["upsertRule"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/users/level-config/tiers": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["createTier"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/users/level-config/tiers/{tier_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["updateTier"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/users/level-config/tracks/{track}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["updateTrack"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/users/region-blocks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listRegionBlocks"]; + put: operations["replaceRegionBlocks"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/app/users": { parameters: { query?: never; @@ -2102,6 +2294,44 @@ export interface components { } export type $defs = Record; export interface operations { + listAchievementDefinitions: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + createAchievementDefinition: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + updateAchievementDefinition: { + parameters: { + query?: never; + header?: never; + path: { + achievement_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; listTaskDefinitions: { parameters: { query?: never; @@ -2638,6 +2868,42 @@ export interface operations { 200: components["responses"]["EmptyResponse"]; }; }; + listEmojiPacks: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + createEmojiPack: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + listEmojiPackCategories: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; uploadImage: { parameters: { query?: never; @@ -2837,6 +3103,42 @@ export interface operations { 200: components["responses"]["HostProfilePageResponse"]; }; }; + listCoinAdjustments: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + createCoinAdjustment: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + lookupCoinAdjustmentTarget: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; listCoinLedger: { parameters: { query?: never; @@ -3319,6 +3621,97 @@ export interface operations { 200: components["responses"]["EmptyResponse"]; }; }; + listLevelConfig: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + upsertRule: { + parameters: { + query?: never; + header?: never; + path: { + track: number; + level: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + createTier: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + updateTier: { + parameters: { + query?: never; + header?: never; + path: { + tier_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + updateTrack: { + parameters: { + query?: never; + header?: never; + path: { + track: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + listRegionBlocks: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + replaceRegionBlocks: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; appListUsers: { parameters: { query?: never; diff --git a/src/shared/api/types.ts b/src/shared/api/types.ts index eefdcea..790e60a 100644 --- a/src/shared/api/types.ts +++ b/src/shared/api/types.ts @@ -206,6 +206,44 @@ export interface CoinLedgerEntryDto { userId: string; } +export interface CoinAdjustmentOperatorDto { + adminId?: string; + name?: string; + username?: string; +} + +export interface CoinAdjustmentDto { + amount: number; + availableAfter: number; + availableDelta: number; + commandId?: string; + createdAtMs?: number; + direction: "income" | "expense" | string; + entryId: number; + operator?: CoinAdjustmentOperatorDto; + operatorUserId?: string; + reason?: string; + transactionId: string; + user: CoinLedgerUserDto; + userId: string; +} + +export interface CoinAdjustmentPayload { + amount: number; + commandId?: string; + reason: string; + targetUserId: string; +} + +export interface CoinAdjustmentCreateDto { + amount: number; + availableDelta: number; + balanceAfter: number; + reason?: string; + transactionId: string; + user: CoinLedgerUserDto; +} + export interface RechargeBillDto { appCode?: string; coinAmount: number; @@ -464,6 +502,10 @@ export interface AppUserPasswordPayload { } export interface RoomDto { + closeReason?: string; + closedAtMs?: number; + closedByAdminId?: number; + closedByAdminName?: string; coverUrl?: string; createdAtMs?: number; heat?: number; @@ -524,6 +566,7 @@ export interface RoomOwnerDto { } export interface RoomUpdatePayload { + closeReason?: string; coverUrl?: string; description?: string; mode?: string; diff --git a/src/shared/ui/DataState.jsx b/src/shared/ui/DataState.jsx index cce93cd..cef0fdb 100644 --- a/src/shared/ui/DataState.jsx +++ b/src/shared/ui/DataState.jsx @@ -1,4 +1,5 @@ import Refresh from "@mui/icons-material/Refresh"; +import Skeleton from "@mui/material/Skeleton"; import { Button } from "./Button.jsx"; import { usePageLoadingStatus } from "./PageLoadBoundary.jsx"; @@ -8,7 +9,7 @@ export function DataState({ error, loading, children, onRetry, reportPageLoading if (loading) { return (
-
+
); } @@ -29,3 +30,33 @@ export function DataState({ error, loading, children, onRetry, reportPageLoading return children; } + +function ContentSkeleton() { + return ( + + ); +} diff --git a/src/shared/ui/UploadField.jsx b/src/shared/ui/UploadField.jsx index e653d95..20d4d33 100644 --- a/src/shared/ui/UploadField.jsx +++ b/src/shared/ui/UploadField.jsx @@ -19,6 +19,7 @@ export function UploadField({ kind = "image", label, onChange, + uploadKind, value = "" }) { const inputId = useId(); @@ -27,10 +28,12 @@ export function UploadField({ const [uploading, setUploading] = useState(false); const [localPreview, setLocalPreview] = useState(null); const isImage = kind === "image"; + const uploadMode = uploadKind || kind; + const useImageUpload = uploadMode === "image"; const source = localPreview?.url || value; const assetKind = localPreview?.assetKind || getAssetKind(value, kind); const displayName = localPreview?.name || getDisplayValue(value); - const inputAccept = accept || (isImage ? imageAccept : undefined); + const inputAccept = accept !== undefined ? accept : useImageUpload && isImage ? imageAccept : undefined; useEffect(() => { return () => { @@ -65,7 +68,7 @@ export function UploadField({ }); setUploading(true); try { - const result = isImage ? await uploadImage(file) : await uploadFile(file); + const result = useImageUpload ? await uploadImage(file) : await uploadFile(file); onChange(result.url); setLocalPreview(null); showToast("上传成功", "success"); @@ -146,7 +149,7 @@ function AssetPreview({ assetKind, isImage, src }) { if (assetKind === "pag") { return ; } - if (isImage) { + if (assetKind === "image" || isImage) { return ; } return ( @@ -328,6 +331,9 @@ function getAssetKind(value, kind, contentType = "") { if (extension === "pag") { return "pag"; } + if (isRasterImageExtension(extension)) { + return "image"; + } const normalizedType = String(contentType || "").toLowerCase(); if (normalizedType.includes("svga")) { return "svga"; @@ -335,9 +341,20 @@ function getAssetKind(value, kind, contentType = "") { if (normalizedType.includes("pag")) { return "pag"; } + if (isRasterImageContentType(normalizedType)) { + return "image"; + } return kind === "image" ? "image" : "file"; } +function isRasterImageExtension(extension) { + return ["avif", "gif", "jpg", "jpeg", "png", "webp"].includes(extension); +} + +function isRasterImageContentType(contentType) { + return ["image/avif", "image/gif", "image/jpeg", "image/png", "image/webp"].includes(contentType); +} + function getExtension(value) { if (!value) { return ""; @@ -359,6 +376,9 @@ function safePathname(value) { } function assetKindLabel(assetKind, isImage) { + if (assetKind === "image") { + return "图片"; + } if (assetKind === "svga") { return "SVGA"; } diff --git a/src/shared/ui/UploadField.test.jsx b/src/shared/ui/UploadField.test.jsx new file mode 100644 index 0000000..0ddfb59 --- /dev/null +++ b/src/shared/ui/UploadField.test.jsx @@ -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( + + + + ); + 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"); +}); diff --git a/src/styles/shared-ui.css b/src/styles/shared-ui.css index 38b69df..4e41f03 100644 --- a/src/styles/shared-ui.css +++ b/src/styles/shared-ui.css @@ -1029,11 +1029,93 @@ } .data-state--loading { + display: flex; + align-items: stretch; + justify-content: stretch; padding: var(--space-4); } -.data-state--loading .page-skeleton__block { - min-height: 220px; +.data-state__skeleton { + display: grid; + width: 100%; + min-height: 360px; + grid-template-rows: auto minmax(0, 1fr); + gap: var(--space-4); +} + +.data-state__skeleton-toolbar { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: var(--space-4); +} + +.data-state__skeleton-filters, +.data-state__skeleton-actions { + display: flex; + min-width: 0; + align-items: center; + flex-wrap: wrap; + gap: var(--space-3); +} + +.data-state__skeleton-actions { + flex: 0 0 auto; +} + +.data-state__skeleton-filter { + width: 150px; + height: var(--control-height); +} + +.data-state__skeleton-reset, +.data-state__skeleton-action { + width: 84px; + height: var(--control-height); +} + +.data-state__skeleton-table { + display: grid; + min-height: 0; + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius-card); + background: var(--bg-card); +} + +.data-state__skeleton-head { + height: var(--table-head-height); + border-radius: 0; + background-color: var(--bg-card-strong); +} + +.data-state__skeleton-row { + display: grid; + min-height: var(--table-row-height); + align-items: center; + grid-template-columns: minmax(180px, 1.35fr) minmax(100px, 0.7fr) minmax(140px, 0.95fr) minmax(90px, 0.55fr) minmax(54px, 0.35fr); + gap: var(--space-4); + padding: 0 var(--space-4); + border-top: 1px solid var(--row-border); +} + +.data-state__skeleton-cell { + width: min(100%, 150px); + height: 18px; +} + +.data-state__skeleton-cell--main { + width: min(100%, 220px); +} + +.data-state__skeleton-cell--short { + width: 72px; +} + +.data-state__skeleton-cell--action { + width: 34px; + height: 28px; } .data-state__title { diff --git a/var/admin-dev.pid b/var/admin-dev.pid new file mode 100644 index 0000000..0a67ee7 --- /dev/null +++ b/var/admin-dev.pid @@ -0,0 +1 @@ +99664