优化UI完善功能

This commit is contained in:
zhx 2026-05-16 16:26:22 +08:00
parent f5a6c6685c
commit bdeb3cd15a
62 changed files with 6214 additions and 322 deletions

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

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

View File

@ -0,0 +1,51 @@
.identity {
display: flex;
align-items: flex-start;
gap: 10px;
min-width: 0;
}
.identityText,
.stack {
display: flex;
min-width: 0;
flex-direction: column;
gap: 4px;
}
.name,
.resourceText {
overflow: hidden;
color: var(--text-primary);
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta,
.emptyValue {
overflow: hidden;
color: var(--text-tertiary);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.typeBadge {
flex: 0 0 auto;
min-width: 62px;
border: 1px solid var(--border-subtle);
border-radius: 999px;
padding: 3px 8px;
color: var(--text-secondary);
font-size: 12px;
line-height: 18px;
text-align: center;
}
.statusCell {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
}

View File

@ -0,0 +1,178 @@
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
import { apiRequest } from "@/shared/api/request";
import type { ApiPage, EntityId, PageQuery } from "@/shared/api/types";
export interface AchievementConditionDto {
achievementId?: string;
conditionId?: string;
dimensionFilterJson?: string;
metricType: string;
targetUnit?: string;
targetValue: number;
version?: number;
}
export interface AchievementDefinitionDto {
achievementId: string;
achievementType: string;
autoPinPolicy?: string;
collectionId?: string;
collectionType: string;
conditions: AchievementConditionDto[];
createdAtMs?: number;
createdByAdminId?: number;
description?: string;
displayConfigJson?: string;
effectiveFromMs?: number;
effectiveToMs?: number;
honorVisibility?: boolean;
primaryBadgeResourceId?: number;
rewardResourceGroupId?: number;
sortOrder?: number;
status: string;
title: string;
updatedAtMs?: number;
updatedByAdminId?: number;
version?: number;
}
export interface AchievementConditionPayload {
dimension_filter_json: string;
metric_type: string;
target_unit: string;
target_value: number;
}
export interface AchievementDefinitionPayload {
achievement_type: string;
auto_pin_policy: string;
collection_id: string;
collection_type: string;
conditions: AchievementConditionPayload[];
description: string;
display_config_json: string;
effective_from_ms: number;
effective_to_ms: number;
honor_visibility: boolean;
primary_badge_resource_id: number;
reward_resource_group_id: number;
sort_order: number;
status: string;
title: string;
}
type AnyRecord = Record<string, unknown>;
export function listAchievementDefinitions(query: PageQuery = {}): Promise<ApiPage<AchievementDefinitionDto>> {
const endpoint = API_ENDPOINTS.listAchievementDefinitions;
return apiRequest<ApiPage<unknown>>(apiEndpointPath(API_OPERATIONS.listAchievementDefinitions), {
method: endpoint.method,
query,
}).then(normalizeAchievementPage);
}
export function createAchievementDefinition(payload: AchievementDefinitionPayload): Promise<AchievementDefinitionDto> {
const endpoint = API_ENDPOINTS.createAchievementDefinition;
return apiRequest<unknown, AchievementDefinitionPayload>(
apiEndpointPath(API_OPERATIONS.createAchievementDefinition),
{
body: payload,
method: endpoint.method,
},
).then(normalizeAchievement);
}
export function updateAchievementDefinition(
achievementId: EntityId,
payload: AchievementDefinitionPayload,
): Promise<AchievementDefinitionDto> {
const endpoint = API_ENDPOINTS.updateAchievementDefinition;
return apiRequest<unknown, AchievementDefinitionPayload>(
apiEndpointPath(API_OPERATIONS.updateAchievementDefinition, { achievement_id: achievementId }),
{
body: payload,
method: endpoint.method,
},
).then(normalizeAchievement);
}
function normalizeAchievementPage(page: ApiPage<unknown>): ApiPage<AchievementDefinitionDto> {
return {
...page,
items: (page.items || []).map(normalizeAchievement),
};
}
function normalizeAchievement(raw: unknown): AchievementDefinitionDto {
const source = asRecord(raw);
return {
achievementId: stringValue(source.achievementId ?? source.achievement_id),
achievementType: stringValue(source.achievementType ?? source.achievement_type),
autoPinPolicy: stringValue(source.autoPinPolicy ?? source.auto_pin_policy),
collectionId: stringValue(source.collectionId ?? source.collection_id),
collectionType: stringValue(source.collectionType ?? source.collection_type),
conditions: arrayValue(source.conditions).map(normalizeCondition),
createdAtMs: numberValue(source.createdAtMs ?? source.created_at_ms),
createdByAdminId: numberValue(source.createdByAdminId ?? source.created_by_admin_id),
description: stringValue(source.description),
displayConfigJson: jsonString(source.displayConfigJson ?? source.display_config_json),
effectiveFromMs: numberValue(source.effectiveFromMs ?? source.effective_from_ms),
effectiveToMs: numberValue(source.effectiveToMs ?? source.effective_to_ms),
honorVisibility: booleanValue(source.honorVisibility ?? source.honor_visibility),
primaryBadgeResourceId: numberValue(source.primaryBadgeResourceId ?? source.primary_badge_resource_id),
rewardResourceGroupId: numberValue(source.rewardResourceGroupId ?? source.reward_resource_group_id),
sortOrder: numberValue(source.sortOrder ?? source.sort_order),
status: stringValue(source.status),
title: stringValue(source.title),
updatedAtMs: numberValue(source.updatedAtMs ?? source.updated_at_ms),
updatedByAdminId: numberValue(source.updatedByAdminId ?? source.updated_by_admin_id),
version: numberValue(source.version),
};
}
function normalizeCondition(raw: unknown): AchievementConditionDto {
const source = asRecord(raw);
return {
achievementId: stringValue(source.achievementId ?? source.achievement_id),
conditionId: stringValue(source.conditionId ?? source.condition_id),
dimensionFilterJson: jsonString(source.dimensionFilterJson ?? source.dimension_filter_json),
metricType: stringValue(source.metricType ?? source.metric_type),
targetUnit: stringValue(source.targetUnit ?? source.target_unit),
targetValue: numberValue(source.targetValue ?? source.target_value),
version: numberValue(source.version),
};
}
function asRecord(value: unknown): AnyRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as AnyRecord) : {};
}
function arrayValue(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function booleanValue(value: unknown): boolean {
return value === true || value === "true" || value === 1 || value === "1";
}
function numberValue(value: unknown): number {
if (value === undefined || value === null || value === "") {
return 0;
}
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : 0;
}
function stringValue(value: unknown): string {
return value === undefined || value === null ? "" : String(value);
}
function jsonString(value: unknown): string {
if (value === undefined || value === null || value === "") {
return "";
}
if (typeof value === "string") {
return value;
}
return JSON.stringify(value);
}

View File

@ -0,0 +1,41 @@
export const achievementStatusOptions = [
["draft", "草稿"],
["active", "启用"],
["paused", "暂停"],
["archived", "归档"],
];
export const achievementStatusLabels = Object.fromEntries(achievementStatusOptions);
export const collectionTypeOptions = [
["ordinary", "普通成就"],
["activity_limited", "活动成就"],
];
export const collectionTypeFilterOptions = [["", "全部合集"], ...collectionTypeOptions];
export const collectionTypeLabels = Object.fromEntries(collectionTypeOptions);
export const achievementTypeOptions = [
["ordinary", "普通成就"],
["activity_limited", "限时活动"],
];
export const achievementTypeLabels = Object.fromEntries(achievementTypeOptions);
export const autoPinPolicyOptions = [
["none", "不自动展示"],
["if_empty", "空位展示"],
["always", "优先展示"],
];
export const autoPinPolicyLabels = Object.fromEntries(autoPinPolicyOptions);
export const targetUnitOptions = [
["count", "次数"],
["coin", "金币"],
["day", "天"],
["ms", "毫秒"],
];
export const targetUnitLabels = Object.fromEntries(targetUnitOptions);

View File

@ -0,0 +1,294 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { parseForm } from "@/shared/forms/validation";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { listResourceGroups, listResources } from "@/features/resources/api";
import {
createAchievementDefinition,
listAchievementDefinitions,
updateAchievementDefinition,
} from "@/features/achievements/api";
import { useAchievementAbilities } from "@/features/achievements/permissions.js";
import { achievementFormSchema } from "@/features/achievements/schema";
const pageSize = 10;
const emptyData = { items: [], page: 1, pageSize, total: 0 };
const resourcePageQuery = { page: 1, page_size: 500, resource_type: "badge", status: "active" };
const groupPageQuery = { page: 1, page_size: 500, status: "active" };
const emptyCondition = (condition = {}) => ({
dimensionFilterJson: condition.dimensionFilterJson || "{}",
metricType: condition.metricType || "",
targetUnit: condition.targetUnit || "count",
targetValue: condition.targetValue === 0 || condition.targetValue ? String(condition.targetValue) : "",
});
const emptyForm = (achievement = {}) => ({
achievementType: achievement.achievementType || "ordinary",
autoPinPolicy: achievement.autoPinPolicy || "if_empty",
collectionId: achievement.collectionId || "",
collectionType: achievement.collectionType || "ordinary",
conditions: achievement.conditions?.length ? achievement.conditions.map(emptyCondition) : [emptyCondition()],
description: achievement.description || "",
displayConfigJson: achievement.displayConfigJson || "{}",
effectiveFrom: msToDatetimeLocal(achievement.effectiveFromMs),
effectiveTo: msToDatetimeLocal(achievement.effectiveToMs),
honorVisibility: achievement.honorVisibility ?? true,
primaryBadgeResourceId: optionalId(achievement.primaryBadgeResourceId),
rewardResourceGroupId: optionalId(achievement.rewardResourceGroupId),
sortOrder: valueOrDefault(achievement.sortOrder, 0),
status: achievement.status || "draft",
title: achievement.title || "",
});
export function useAchievementsPage() {
const abilities = useAchievementAbilities();
const { showToast } = useToast();
const [collectionType, setCollectionType] = useState("");
const [collectionId, setCollectionId] = useState("");
const [page, setPage] = useState(1);
const [activeAction, setActiveAction] = useState("");
const [selectedAchievement, setSelectedAchievement] = useState(null);
const [form, setForm] = useState(() => emptyForm());
const [loadingAction, setLoadingAction] = useState("");
const [badgeResources, setBadgeResources] = useState([]);
const [resourceGroups, setResourceGroups] = useState([]);
const filters = useMemo(
() => ({
collection_id: collectionId,
collection_type: collectionType,
}),
[collectionId, collectionType],
);
const {
data = emptyData,
error,
loading,
reload,
} = usePaginatedQuery({
errorMessage: "加载成就配置失败",
fetcher: listAchievementDefinitions,
filters,
page,
pageSize,
queryKey: ["achievements", filters, page],
});
const loadOptions = useCallback(async () => {
try {
const [resourcePage, groupPage] = await Promise.all([
listResources(resourcePageQuery),
listResourceGroups(groupPageQuery),
]);
setBadgeResources(resourcePage.items || []);
setResourceGroups(groupPage.items || []);
} catch (err) {
showToast(err.message || "加载资源选项失败", "error");
}
}, [showToast]);
useEffect(() => {
void loadOptions();
}, [loadOptions]);
const changeCollectionType = resetSetter(setCollectionType, setPage);
const changeCollectionId = resetSetter(setCollectionId, setPage);
const resetFilters = () => {
setCollectionType("");
setCollectionId("");
setPage(1);
};
const openCreate = () => {
setSelectedAchievement(null);
setForm(emptyForm());
setActiveAction("create");
};
const openEdit = (achievement) => {
if (!achievement?.achievementId) {
return;
}
setSelectedAchievement(achievement);
setForm(emptyForm(achievement));
setActiveAction("edit");
};
const closeAction = () => {
setActiveAction("");
setSelectedAchievement(null);
};
const reloadAll = async () => {
await Promise.all([reload(), loadOptions()]);
};
const submitAchievement = async (event) => {
event.preventDefault();
const editing = activeAction === "edit";
if (editing && !selectedAchievement?.achievementId) {
return;
}
await runAction(editing ? "edit" : "create", editing ? "成就已更新" : "成就已创建", async () => {
const payload = buildAchievementPayload(parseForm(achievementFormSchema, form));
if (editing) {
await updateAchievementDefinition(selectedAchievement.achievementId, payload);
} else {
await createAchievementDefinition(payload);
}
closeAction();
setForm(emptyForm());
await reload();
});
};
const toggleAchievement = async (achievement, nextEnabled = achievement.status !== "active") => {
if (!abilities.canUpdate || !achievement?.achievementId || achievement.status === "archived") {
return;
}
const nextStatus = nextEnabled ? "active" : "paused";
if (achievement.status === nextStatus) {
return;
}
await runAction(`status-${achievement.achievementId}`, nextEnabled ? "成就已启用" : "成就已暂停", async () => {
await updateAchievementDefinition(
achievement.achievementId,
buildAchievementPayload(emptyForm({ ...achievement, status: nextStatus })),
);
await reload();
});
};
const addCondition = () => {
setForm((current) => ({ ...current, conditions: [...current.conditions, emptyCondition()] }));
};
const removeCondition = (index) => {
setForm((current) => ({
...current,
conditions:
current.conditions.length > 1
? current.conditions.filter((_, itemIndex) => itemIndex !== index)
: current.conditions,
}));
};
const updateCondition = (index, key, value) => {
setForm((current) => ({
...current,
conditions: current.conditions.map((condition, itemIndex) =>
itemIndex === index ? { ...condition, [key]: value } : condition,
),
}));
};
const runAction = async (action, successMessage, submitter) => {
setLoadingAction(action);
try {
await submitter();
showToast(successMessage, "success");
} catch (err) {
showToast(err.message || "操作失败", "error");
} finally {
setLoadingAction("");
}
};
return {
abilities,
activeAction,
addCondition,
badgeResources,
changeCollectionId,
changeCollectionType,
closeAction,
collectionId,
collectionType,
data,
error,
form,
loading,
loadingAction,
openCreate,
openEdit,
page,
reload: reloadAll,
removeCondition,
resetFilters,
resourceGroups,
selectedAchievement,
setForm,
setPage,
submitAchievement,
toggleAchievement,
updateCondition,
};
}
function buildAchievementPayload(form) {
return {
achievement_type: form.achievementType,
auto_pin_policy: form.autoPinPolicy,
collection_id: String(form.collectionId || "").trim(),
collection_type: form.collectionType,
conditions: form.conditions.map((condition) => ({
dimension_filter_json: normalizedJson(condition.dimensionFilterJson),
metric_type: String(condition.metricType || "").trim(),
target_unit: String(condition.targetUnit || "").trim(),
target_value: Number(condition.targetValue || 0),
})),
description: String(form.description || "").trim(),
display_config_json: normalizedJson(form.displayConfigJson),
effective_from_ms: datetimeLocalToMs(form.effectiveFrom),
effective_to_ms: datetimeLocalToMs(form.effectiveTo),
honor_visibility: Boolean(form.honorVisibility),
primary_badge_resource_id: Number(form.primaryBadgeResourceId || 0),
reward_resource_group_id: Number(form.rewardResourceGroupId || 0),
sort_order: Number(form.sortOrder || 0),
status: form.status,
title: String(form.title || "").trim(),
};
}
function resetSetter(setter, setPage) {
return (value) => {
setter(value);
setPage(1);
};
}
function msToDatetimeLocal(value) {
if (!value) {
return "";
}
const date = new Date(Number(value));
if (Number.isNaN(date.getTime())) {
return "";
}
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
return local.toISOString().slice(0, 16);
}
function datetimeLocalToMs(value) {
const trimmed = String(value || "").trim();
if (!trimmed) {
return 0;
}
const parsed = new Date(trimmed).getTime();
return Number.isFinite(parsed) ? parsed : 0;
}
function normalizedJson(value) {
const raw = String(value || "").trim();
return raw || "{}";
}
function optionalId(value) {
return value ? String(value) : "";
}
function valueOrDefault(value, fallback) {
return value === 0 || value ? String(value) : String(fallback);
}

View File

@ -0,0 +1,606 @@
import Add from "@mui/icons-material/Add";
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import EditOutlined from "@mui/icons-material/EditOutlined";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import {
AdminFormDialog,
AdminFormFieldGrid,
AdminFormList,
AdminFormListRow,
AdminFormSection,
AdminFormSwitchField,
} from "@/shared/ui/AdminFormDialog.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import {
AdminActionIconButton,
AdminFilterResetButton,
AdminFilterSelect,
AdminListBody,
AdminListPage,
AdminListToolbar,
AdminRowActions,
AdminSearchBox,
} from "@/shared/ui/AdminListLayout.jsx";
import { formatMillis } from "@/shared/utils/time.js";
import {
achievementStatusLabels,
achievementStatusOptions,
achievementTypeLabels,
achievementTypeOptions,
autoPinPolicyLabels,
autoPinPolicyOptions,
collectionTypeFilterOptions,
collectionTypeLabels,
collectionTypeOptions,
targetUnitLabels,
targetUnitOptions,
} from "@/features/achievements/constants.js";
import { useAchievementsPage } from "@/features/achievements/hooks/useAchievementsPage.js";
import styles from "@/features/achievements/achievements.module.css";
const baseColumns = [
{
key: "achievement",
label: "成就",
width: "minmax(260px, 1.4fr)",
render: (achievement) => <AchievementIdentity achievement={achievement} />,
},
{
key: "condition",
label: "达成条件",
width: "minmax(240px, 1.2fr)",
render: (achievement) => <ConditionSummary conditions={achievement.conditions} />,
},
{
key: "badge",
label: "展示徽章",
width: "minmax(190px, 0.9fr)",
},
{
key: "reward",
label: "奖励资源组",
width: "minmax(190px, 0.9fr)",
},
{
key: "display",
label: "展示策略",
width: "minmax(150px, 0.7fr)",
render: (achievement) => (
<div className={styles.stack}>
<span>{autoPinPolicyLabel(achievement.autoPinPolicy)}</span>
<span className={styles.meta}>{achievement.honorVisibility ? "荣誉墙可见" : "荣誉墙隐藏"}</span>
</div>
),
},
{
key: "effective",
label: "有效期",
width: "minmax(220px, 1.05fr)",
render: (achievement) => (
<div className={styles.stack}>
<span>{achievement.effectiveFromMs ? formatMillis(achievement.effectiveFromMs) : "立即生效"}</span>
<span className={styles.meta}>
{achievement.effectiveToMs ? formatMillis(achievement.effectiveToMs) : "长期有效"}
</span>
</div>
),
},
{
key: "version",
label: "版本 / 排序",
width: "minmax(120px, 0.6fr)",
render: (achievement) => (
<div className={styles.stack}>
<span>v{achievement.version || 1}</span>
<span className={styles.meta}>{achievement.sortOrder || 0}</span>
</div>
),
},
{
key: "status",
label: "状态",
width: "minmax(116px, 0.55fr)",
},
{
key: "updatedAt",
label: "更新时间",
width: "minmax(170px, 0.8fr)",
render: (achievement) => formatMillis(achievement.updatedAtMs || achievement.createdAtMs),
},
{
key: "actions",
label: "操作",
width: "minmax(76px, 0.35fr)",
},
];
export function AchievementConfigPage() {
const page = useAchievementsPage();
const items = page.data.items || [];
const total = page.data.total || 0;
const isEditing = page.activeAction === "edit";
const columns = baseColumns.map((column) => {
if (column.key === "badge") {
return {
...column,
render: (achievement) => (
<ResourceLabel id={achievement.primaryBadgeResourceId} resources={page.badgeResources} />
),
};
}
if (column.key === "reward") {
return {
...column,
render: (achievement) => (
<ResourceGroupLabel groups={page.resourceGroups} id={achievement.rewardResourceGroupId} />
),
};
}
if (column.key === "status") {
return {
...column,
render: (achievement) => <AchievementStatusSwitch achievement={achievement} page={page} />,
};
}
if (column.key === "actions") {
return {
...column,
render: (achievement) => <AchievementRowActions achievement={achievement} page={page} />,
};
}
return column;
});
return (
<AdminListPage>
<AdminListToolbar
filters={
<>
<AdminFilterSelect
label="合集类型"
options={collectionTypeFilterOptions}
value={page.collectionType}
onChange={page.changeCollectionType}
/>
<AdminSearchBox
placeholder="搜索合集 ID"
value={page.collectionId}
onChange={page.changeCollectionId}
/>
<AdminFilterResetButton
disabled={!page.collectionType && !page.collectionId}
onClick={page.resetFilters}
/>
</>
}
actions={
page.abilities.canCreate ? (
<AdminActionIconButton label="添加成就" primary onClick={page.openCreate}>
<Add fontSize="small" />
</AdminActionIconButton>
) : null
}
/>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<AdminListBody>
<DataTable
columns={columns}
items={items}
minWidth="1710px"
pagination={
total > 0
? {
page: page.page,
pageSize: page.data.pageSize || 10,
total,
onPageChange: page.setPage,
}
: undefined
}
rowKey={(achievement) => achievement.achievementId}
/>
</AdminListBody>
</DataState>
<AchievementFormDialog
disabled={isEditing ? !page.abilities.canUpdate : !page.abilities.canCreate}
form={page.form}
loading={isEditing ? page.loadingAction === "edit" : page.loadingAction === "create"}
mode={isEditing ? "edit" : "create"}
open={page.activeAction === "create" || page.activeAction === "edit"}
page={page}
onClose={page.closeAction}
onSubmit={page.submitAchievement}
/>
</AdminListPage>
);
}
function AchievementIdentity({ achievement }) {
return (
<div className={styles.identity}>
<span className={styles.typeBadge}>{achievementTypeLabel(achievement.achievementType)}</span>
<div className={styles.identityText}>
<span className={styles.name}>{achievement.title || achievement.achievementId}</span>
<span className={styles.meta}>{achievement.achievementId}</span>
<span className={styles.meta}>
{collectionTypeLabel(achievement.collectionType)} · {achievement.collectionId || "ordinary"}
</span>
</div>
</div>
);
}
function ConditionSummary({ conditions = [] }) {
const first = conditions[0] || {};
if (!first.metricType) {
return <span className={styles.emptyValue}>未配置</span>;
}
return (
<div className={styles.stack}>
<span>{first.metricType}</span>
<span className={styles.meta}>
{formatNumber(first.targetValue)} {targetUnitLabel(first.targetUnit)}
{conditions.length > 1 ? ` · 共 ${conditions.length}` : ""}
</span>
</div>
);
}
function AchievementStatusSwitch({ achievement, page }) {
const checked = achievement.status === "active";
const disabled =
!page.abilities.canUpdate ||
achievement.status === "archived" ||
page.loadingAction === `status-${achievement.achievementId}`;
return (
<div className={styles.statusCell}>
<AdminSwitch
checked={checked}
disabled={disabled}
inputProps={{ "aria-label": checked ? "暂停成就" : "启用成就" }}
onChange={(event) => page.toggleAchievement(achievement, event.target.checked)}
/>
<span className={styles.meta}>{achievementStatusLabel(achievement.status)}</span>
</div>
);
}
function AchievementRowActions({ achievement, page }) {
return (
<AdminRowActions>
<AdminActionIconButton
disabled={!page.abilities.canUpdate}
label="编辑成就"
onClick={() => page.openEdit(achievement)}
>
<EditOutlined fontSize="small" />
</AdminActionIconButton>
</AdminRowActions>
);
}
function AchievementFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open, page }) {
return (
<AdminFormDialog
loading={loading}
open={open}
size="wide"
submitDisabled={disabled || loading}
submitLabel={mode === "edit" ? "保存成就" : "创建成就"}
title={mode === "edit" ? "编辑成就" : "创建成就"}
onClose={onClose}
onSubmit={onSubmit}
>
<AdminFormSection title="基础信息">
<AdminFormFieldGrid>
<TextField
disabled={disabled}
label="合集类型"
required
select
value={form.collectionType}
onChange={changeField(page.setForm, "collectionType")}
>
{collectionTypeOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
disabled={disabled}
label="成就类型"
required
select
value={form.achievementType}
onChange={changeField(page.setForm, "achievementType")}
>
{achievementTypeOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
disabled={disabled}
label="合集 ID"
value={form.collectionId}
onChange={changeField(page.setForm, "collectionId")}
/>
<TextField
disabled={disabled}
label="状态"
required
select
value={form.status}
onChange={changeField(page.setForm, "status")}
>
{achievementStatusOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
disabled={disabled}
label="成就名称"
required
value={form.title}
onChange={changeField(page.setForm, "title")}
/>
<TextField
disabled={disabled}
label="描述"
value={form.description}
onChange={changeField(page.setForm, "description")}
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection
actions={
<AdminActionIconButton
disabled={disabled}
label="添加条件"
type="button"
onClick={page.addCondition}
>
<Add fontSize="small" />
</AdminActionIconButton>
}
title="达成条件"
>
<AdminFormList>
{form.conditions.map((condition, index) => (
<AdminFormListRow
key={index}
columns="minmax(180px, 1fr) minmax(110px, 0.55fr) minmax(120px, 0.6fr) minmax(220px, 1.1fr) 44px"
>
<TextField
disabled={disabled}
label="指标类型"
required
value={condition.metricType}
onChange={(event) => page.updateCondition(index, "metricType", event.target.value)}
/>
<TextField
disabled={disabled}
inputProps={{ min: 1 }}
label="目标值"
required
type="number"
value={condition.targetValue}
onChange={(event) => page.updateCondition(index, "targetValue", event.target.value)}
/>
<TextField
disabled={disabled}
label="目标单位"
required
select
value={condition.targetUnit}
onChange={(event) => page.updateCondition(index, "targetUnit", event.target.value)}
>
{targetUnitOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
disabled={disabled}
label="维度过滤 JSON"
value={condition.dimensionFilterJson}
onChange={(event) =>
page.updateCondition(index, "dimensionFilterJson", event.target.value)
}
/>
<AdminActionIconButton
disabled={disabled || form.conditions.length <= 1}
label="删除条件"
type="button"
onClick={() => page.removeCondition(index)}
>
<DeleteOutlineOutlined fontSize="small" />
</AdminActionIconButton>
</AdminFormListRow>
))}
</AdminFormList>
</AdminFormSection>
<AdminFormSection title="徽章与奖励">
<AdminFormFieldGrid>
<ResourceSelect
disabled={disabled}
label="达成展示徽章"
resources={page.badgeResources}
value={form.primaryBadgeResourceId}
onChange={changeField(page.setForm, "primaryBadgeResourceId")}
/>
<ResourceGroupSelect
disabled={disabled}
groups={page.resourceGroups}
label="达成奖励资源组"
value={form.rewardResourceGroupId}
onChange={changeField(page.setForm, "rewardResourceGroupId")}
/>
<TextField
disabled={disabled}
label="展示策略"
select
value={form.autoPinPolicy}
onChange={changeField(page.setForm, "autoPinPolicy")}
>
{autoPinPolicyOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<AdminFormSwitchField
checked={form.honorVisibility}
checkedLabel="显示"
disabled={disabled}
label="荣誉墙"
uncheckedLabel="隐藏"
onChange={(checked) => page.setForm((current) => ({ ...current, honorVisibility: checked }))}
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="时间与展示">
<AdminFormFieldGrid>
<TextField
disabled={disabled}
label="开始时间"
type="datetime-local"
value={form.effectiveFrom}
InputLabelProps={{ shrink: true }}
onChange={changeField(page.setForm, "effectiveFrom")}
/>
<TextField
disabled={disabled}
label="结束时间"
type="datetime-local"
value={form.effectiveTo}
InputLabelProps={{ shrink: true }}
onChange={changeField(page.setForm, "effectiveTo")}
/>
<TextField
disabled={disabled}
label="排序"
type="number"
value={form.sortOrder}
onChange={changeField(page.setForm, "sortOrder")}
/>
<TextField
disabled={disabled}
label="展示配置 JSON"
value={form.displayConfigJson}
onChange={changeField(page.setForm, "displayConfigJson")}
/>
</AdminFormFieldGrid>
</AdminFormSection>
</AdminFormDialog>
);
}
function ResourceSelect({ disabled, label, onChange, resources, value }) {
const options = withSelectedResource(resources, value);
return (
<TextField select disabled={disabled} label={label} value={String(value || "")} onChange={onChange}>
<MenuItem value="">不配置</MenuItem>
{options.map((resource) => (
<MenuItem key={resource.resourceId} value={String(resource.resourceId)}>
{resource.name || resource.resourceCode || resource.resourceId}
</MenuItem>
))}
</TextField>
);
}
function ResourceGroupSelect({ disabled, groups, label, onChange, value }) {
const options = withSelectedGroup(groups, value);
return (
<TextField select disabled={disabled} label={label} value={String(value || "")} onChange={onChange}>
<MenuItem value="">不配置</MenuItem>
{options.map((group) => (
<MenuItem key={group.groupId} value={String(group.groupId)}>
{group.name || group.groupCode || group.groupId}
</MenuItem>
))}
</TextField>
);
}
function ResourceLabel({ id, resources }) {
if (!id) {
return <span className={styles.emptyValue}>未配置</span>;
}
const resource = resources.find((item) => Number(item.resourceId) === Number(id));
return (
<div className={styles.stack}>
<span className={styles.resourceText}>{resource?.name || resource?.resourceCode || `资源 #${id}`}</span>
<span className={styles.meta}>ID {id}</span>
</div>
);
}
function ResourceGroupLabel({ groups, id }) {
if (!id) {
return <span className={styles.emptyValue}>未配置</span>;
}
const group = groups.find((item) => Number(item.groupId) === Number(id));
return (
<div className={styles.stack}>
<span className={styles.resourceText}>{group?.name || group?.groupCode || `资源组 #${id}`}</span>
<span className={styles.meta}>ID {id}</span>
</div>
);
}
function changeField(setter, key) {
return (event) => {
const value = event.target.value;
setter((current) => ({ ...current, [key]: value }));
};
}
function withSelectedResource(resources, value) {
const selectedId = Number(value || 0);
if (!selectedId || resources.some((resource) => Number(resource.resourceId) === selectedId)) {
return resources;
}
return [{ resourceId: selectedId, name: `资源 #${selectedId}` }, ...resources];
}
function withSelectedGroup(groups, value) {
const selectedId = Number(value || 0);
if (!selectedId || groups.some((group) => Number(group.groupId) === selectedId)) {
return groups;
}
return [{ groupId: selectedId, name: `资源组 #${selectedId}` }, ...groups];
}
function achievementStatusLabel(status) {
return achievementStatusLabels[status] || status || "-";
}
function achievementTypeLabel(type) {
return achievementTypeLabels[type] || type || "-";
}
function autoPinPolicyLabel(policy) {
return autoPinPolicyLabels[policy] || policy || "-";
}
function collectionTypeLabel(type) {
return collectionTypeLabels[type] || type || "-";
}
function targetUnitLabel(unit) {
return targetUnitLabels[unit] || unit || "";
}
function formatNumber(value) {
return Number(value || 0).toLocaleString("zh-CN");
}

View File

@ -0,0 +1,12 @@
import { PERMISSIONS } from "@/app/permissions";
import { useAuth } from "@/app/auth/AuthProvider.jsx";
export function useAchievementAbilities() {
const { can } = useAuth();
return {
canCreate: can(PERMISSIONS.achievementCreate),
canUpdate: can(PERMISSIONS.achievementUpdate),
canView: can(PERMISSIONS.achievementView),
};
}

View File

@ -0,0 +1,12 @@
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
export const achievementRoutes = [
{
label: "成就配置",
loader: () => import("./pages/AchievementConfigPage.jsx").then((module) => module.AchievementConfigPage),
menuCode: MENU_CODES.achievementConfig,
pageKey: "achievement-config",
path: "/activities/achievements",
permission: PERMISSIONS.achievementView,
},
];

View File

@ -0,0 +1,107 @@
import { z } from "zod";
const numberInput = z.union([z.string(), z.number()]);
const optionalNumberInput = numberInput.optional();
const statusSchema = z.enum(["draft", "active", "paused", "archived"]);
const collectionTypeSchema = z.enum(["ordinary", "activity_limited"]);
const achievementTypeSchema = z.enum(["ordinary", "activity_limited"]);
const autoPinPolicySchema = z.enum(["none", "if_empty", "always"]);
const conditionSchema = z.object({
dimensionFilterJson: z.string().optional(),
metricType: z.string().trim().min(1, "请输入指标类型").max(80, "指标类型不能超过 80 个字符"),
targetUnit: z.string().trim().min(1, "请选择目标单位").max(32, "目标单位不能超过 32 个字符"),
targetValue: numberInput,
});
export const achievementFormSchema = z
.object({
achievementType: achievementTypeSchema,
autoPinPolicy: autoPinPolicySchema,
collectionId: z.string().trim().max(80, "合集 ID 不能超过 80 个字符").optional(),
collectionType: collectionTypeSchema,
conditions: z.array(conditionSchema).min(1, "至少配置一个达成条件").max(10, "最多配置 10 个达成条件"),
description: z.string().trim().max(240, "描述不能超过 240 个字符").optional(),
displayConfigJson: z.string().optional(),
effectiveFrom: z.string().optional(),
effectiveTo: z.string().optional(),
honorVisibility: z.boolean(),
primaryBadgeResourceId: optionalNumberInput,
rewardResourceGroupId: optionalNumberInput,
sortOrder: optionalNumberInput,
status: statusSchema,
title: z.string().trim().min(1, "请输入成就名称").max(64, "成就名称不能超过 64 个字符"),
})
.superRefine((value, context) => {
validateOptionalId(context, "primaryBadgeResourceId", value.primaryBadgeResourceId, "展示徽章必须是有效资源");
validateOptionalId(context, "rewardResourceGroupId", value.rewardResourceGroupId, "奖励资源组必须是有效资源组");
validateInteger(context, "sortOrder", value.sortOrder || 0, "排序必须是整数");
validateJsonObject(context, "displayConfigJson", value.displayConfigJson, "展示配置 JSON 不正确");
const fromMs = datetimeToMs(value.effectiveFrom);
const toMs = datetimeToMs(value.effectiveTo);
if (fromMs > 0 && toMs > 0 && toMs <= fromMs) {
context.addIssue({ code: "custom", message: "结束时间必须晚于开始时间", path: ["effectiveTo"] });
}
value.conditions.forEach((condition, index) => {
validatePositiveInteger(
context,
`conditions.${index}.targetValue`,
condition.targetValue,
"目标值必须是正整数",
);
validateJsonObject(
context,
`conditions.${index}.dimensionFilterJson`,
condition.dimensionFilterJson,
"维度过滤 JSON 不正确",
);
});
});
export type AchievementForm = z.infer<typeof achievementFormSchema>;
function validatePositiveInteger(context: z.RefinementCtx, path: string, value: unknown, message: string) {
const numeric = Number(value);
if (!Number.isInteger(numeric) || numeric <= 0) {
context.addIssue({ code: "custom", message, path: path.split(".") });
}
}
function validateOptionalId(context: z.RefinementCtx, path: string, value: unknown, message: string) {
const raw = String(value ?? "").trim();
if (!raw) {
return;
}
validatePositiveInteger(context, path, raw, message);
}
function validateInteger(context: z.RefinementCtx, path: string, value: unknown, message: string) {
const numeric = Number(value);
if (!Number.isInteger(numeric)) {
context.addIssue({ code: "custom", message, path: [path] });
}
}
function validateJsonObject(context: z.RefinementCtx, path: string, value: unknown, message: string) {
const raw = String(value || "").trim();
if (!raw) {
return;
}
try {
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
context.addIssue({ code: "custom", message, path: path.split(".") });
}
} catch {
context.addIssue({ code: "custom", message, path: path.split(".") });
}
}
function datetimeToMs(value: unknown) {
const raw = String(value || "").trim();
if (!raw) {
return 0;
}
const parsed = new Date(raw).getTime();
return Number.isFinite(parsed) ? parsed : 0;
}

View File

@ -0,0 +1,239 @@
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
import { apiRequest } from "@/shared/api/request";
import type { EntityId, PageQuery } from "@/shared/api/types";
export type LevelTrackCode = "wealth" | "game" | "charm";
export type LevelConfigStatus = "active" | "disabled";
export interface LevelRuleDto {
createdAtMs?: number;
displayConfigJson?: string;
level: number;
longBadgeResourceId?: number;
name?: string;
requiredValue: number;
rewardResourceGroupId?: number;
sortOrder?: number;
status?: LevelConfigStatus | string;
track: LevelTrackCode;
updatedAtMs?: number;
}
export interface LevelTierDto {
createdAtMs?: number;
displayAvatarFrameResourceId?: number;
displayBadgeResourceId?: number;
displayConfigJson?: string;
maxLevel: number;
minLevel: number;
name?: string;
rewardResourceGroupId?: number;
status?: LevelConfigStatus | string;
tierId: number;
track: LevelTrackCode;
updatedAtMs?: number;
}
export interface LevelTrackDto {
createdAtMs?: number;
displayConfigJson?: string;
name?: string;
rules: LevelRuleDto[];
sortOrder?: number;
status?: LevelConfigStatus | string;
tiers: LevelTierDto[];
track: LevelTrackCode;
updatedAtMs?: number;
}
export interface LevelConfigDto {
serverTimeMs?: number;
tracks: LevelTrackDto[];
}
export interface LevelTrackPayload {
display_config_json?: string;
name: string;
sort_order: number;
status: LevelConfigStatus;
}
export interface LevelRulePayload {
long_badge_resource_id?: number;
name: string;
required_value: number;
reward_resource_group_id?: number;
sort_order: number;
status: LevelConfigStatus;
}
export interface LevelTierPayload {
display_avatar_frame_resource_id?: number;
display_badge_resource_id?: number;
display_config_json?: string;
max_level: number;
min_level: number;
name: string;
reward_resource_group_id?: number;
status: LevelConfigStatus;
track: LevelTrackCode;
}
type AnyRecord = Record<string, unknown>;
export function listLevelConfig(query: PageQuery = {}): Promise<LevelConfigDto> {
const endpoint = API_ENDPOINTS.listLevelConfig;
return apiRequest<unknown>(apiEndpointPath(API_OPERATIONS.listLevelConfig), {
method: endpoint.method,
query,
}).then(normalizeLevelConfig);
}
export function updateTrack(track: LevelTrackCode, payload: LevelTrackPayload): Promise<LevelTrackDto> {
const endpoint = API_ENDPOINTS.updateTrack;
return apiRequest<unknown, LevelTrackPayload>(apiEndpointPath(API_OPERATIONS.updateTrack, { track }), {
body: payload,
method: endpoint.method,
}).then(normalizeTrack);
}
export function upsertRule(
track: LevelTrackCode,
level: EntityId,
payload: LevelRulePayload,
): Promise<LevelRuleDto> {
const endpoint = API_ENDPOINTS.upsertRule;
return apiRequest<unknown, LevelRulePayload>(
apiEndpointPath(API_OPERATIONS.upsertRule, { level, track }),
{
body: payload,
method: endpoint.method,
},
).then(normalizeRule);
}
export function createTier(payload: LevelTierPayload): Promise<LevelTierDto> {
const endpoint = API_ENDPOINTS.createTier;
return apiRequest<unknown, LevelTierPayload>(apiEndpointPath(API_OPERATIONS.createTier), {
body: payload,
method: endpoint.method,
}).then(normalizeTier);
}
export function updateTier(tierId: EntityId, payload: LevelTierPayload): Promise<LevelTierDto> {
const endpoint = API_ENDPOINTS.updateTier;
return apiRequest<unknown, LevelTierPayload>(apiEndpointPath(API_OPERATIONS.updateTier, { tier_id: tierId }), {
body: payload,
method: endpoint.method,
}).then(normalizeTier);
}
function normalizeLevelConfig(raw: unknown): LevelConfigDto {
const source = asRecord(raw);
return {
serverTimeMs: numberValue(source.serverTimeMs ?? source.server_time_ms),
tracks: arrayValue(source.tracks).map(normalizeTrack),
};
}
function normalizeTrack(raw: unknown): LevelTrackDto {
const source = asRecord(raw);
return {
createdAtMs: numberValue(source.createdAtMs ?? source.created_at_ms),
displayConfigJson: jsonString(source.displayConfigJson ?? source.display_config_json),
name: stringValue(source.name),
rules: arrayValue(source.rules).map(normalizeRule),
sortOrder: numberValue(source.sortOrder ?? source.sort_order),
status: stringValue(source.status),
tiers: arrayValue(source.tiers).map(normalizeTier),
track: stringValue(source.track) as LevelTrackCode,
updatedAtMs: numberValue(source.updatedAtMs ?? source.updated_at_ms),
};
}
function normalizeRule(raw: unknown): LevelRuleDto {
const source = asRecord(raw);
const displayConfig = source.displayConfigJson ?? source.display_config_json;
const displayConfigObject = parseDisplayConfig(displayConfig);
return {
createdAtMs: numberValue(source.createdAtMs ?? source.created_at_ms),
displayConfigJson: jsonString(displayConfig),
level: numberValue(source.level),
longBadgeResourceId: numberValue(
source.longBadgeResourceId ??
source.long_badge_resource_id ??
displayConfigObject.longBadgeResourceId ??
displayConfigObject.long_badge_resource_id,
),
name: stringValue(source.name),
requiredValue: numberValue(source.requiredValue ?? source.required_value),
rewardResourceGroupId: numberValue(source.rewardResourceGroupId ?? source.reward_resource_group_id),
sortOrder: numberValue(source.sortOrder ?? source.sort_order),
status: stringValue(source.status),
track: stringValue(source.track) as LevelTrackCode,
updatedAtMs: numberValue(source.updatedAtMs ?? source.updated_at_ms),
};
}
function normalizeTier(raw: unknown): LevelTierDto {
const source = asRecord(raw);
return {
createdAtMs: numberValue(source.createdAtMs ?? source.created_at_ms),
displayAvatarFrameResourceId: numberValue(
source.displayAvatarFrameResourceId ?? source.display_avatar_frame_resource_id,
),
displayBadgeResourceId: numberValue(source.displayBadgeResourceId ?? source.display_badge_resource_id),
displayConfigJson: jsonString(source.displayConfigJson ?? source.display_config_json),
maxLevel: numberValue(source.maxLevel ?? source.max_level),
minLevel: numberValue(source.minLevel ?? source.min_level),
name: stringValue(source.name),
rewardResourceGroupId: numberValue(source.rewardResourceGroupId ?? source.reward_resource_group_id),
status: stringValue(source.status),
tierId: numberValue(source.tierId ?? source.tier_id),
track: stringValue(source.track) as LevelTrackCode,
updatedAtMs: numberValue(source.updatedAtMs ?? source.updated_at_ms),
};
}
function asRecord(value: unknown): AnyRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as AnyRecord) : {};
}
function arrayValue(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function numberValue(value: unknown): number {
if (value === undefined || value === null || value === "") {
return 0;
}
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : 0;
}
function stringValue(value: unknown): string {
return value === undefined || value === null ? "" : String(value);
}
function jsonString(value: unknown): string {
if (value === undefined || value === null || value === "") {
return "";
}
if (typeof value === "string") {
return value;
}
return JSON.stringify(value);
}
function parseDisplayConfig(value: unknown): AnyRecord {
const raw = jsonString(value).trim();
if (!raw) {
return {};
}
try {
return asRecord(JSON.parse(raw));
} catch {
return {};
}
}

View File

@ -0,0 +1,18 @@
export const levelTrackOptions = [
["wealth", "财富等级"],
["game", "游戏等级"],
["charm", "魅力等级"],
];
export const levelTrackLabels = Object.fromEntries(levelTrackOptions);
export const levelStatusOptions = [
["active", "启用"],
["disabled", "禁用"],
];
export const levelStatusLabels = Object.fromEntries(levelStatusOptions);
export const levelTrackFilterOptions = [["", "全部等级"], ...levelTrackOptions];
export const levelStatusFilterOptions = [["", "全部状态"], ...levelStatusOptions];

View File

@ -0,0 +1,344 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { parseForm } from "@/shared/forms/validation";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { listResourceGroups, listResources } from "@/features/resources/api";
import { levelTrackLabels } from "@/features/level-config/constants.js";
import { createTier, listLevelConfig, updateTier, updateTrack, upsertRule } from "@/features/level-config/api";
import { useLevelConfigAbilities } from "@/features/level-config/permissions.js";
import {
levelRuleFormSchema,
levelTierFormSchema,
levelTrackFormSchema,
} from "@/features/level-config/schema";
const emptyConfig = { serverTimeMs: 0, tracks: [] };
const defaultTrack = "wealth";
const resourcePageQuery = { page: 1, page_size: 500, status: "active" };
export function useLevelConfigPage() {
const abilities = useLevelConfigAbilities();
const { showToast } = useToast();
const [config, setConfig] = useState(emptyConfig);
const [resources, setResources] = useState([]);
const [resourceGroups, setResourceGroups] = useState([]);
const [track, setTrack] = useState(defaultTrack);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [activeAction, setActiveAction] = useState("");
const [selectedRule, setSelectedRule] = useState(null);
const [selectedTier, setSelectedTier] = useState(null);
const [selectedTrack, setSelectedTrack] = useState(null);
const [ruleForm, setRuleForm] = useState(() => buildRuleForm());
const [tierForm, setTierForm] = useState(() => buildTierForm());
const [trackForm, setTrackForm] = useState(() => buildTrackForm());
const [loadingAction, setLoadingAction] = useState("");
const reload = useCallback(async () => {
setLoading(true);
setError("");
try {
const [nextConfig, resourcePage, groupPage] = await Promise.all([
listLevelConfig(),
listResources(resourcePageQuery),
listResourceGroups(resourcePageQuery),
]);
setConfig(nextConfig);
setResources(resourcePage.items || []);
setResourceGroups(groupPage.items || []);
} catch (err) {
setError(err.message || "加载等级配置失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void reload();
}, [reload]);
const tracks = useMemo(
() => (config.tracks || []).filter((item) => item.track === track),
[config.tracks, track],
);
const badgeResources = useMemo(
() => resources.filter((resource) => resource.resourceType === "badge"),
[resources],
);
const avatarFrameResources = useMemo(
() => resources.filter((resource) => resource.resourceType === "avatar_frame"),
[resources],
);
const changeTrack = (value) => setTrack(value);
const openRuleCreate = (trackCode = resolveDefaultTrack(config, track)) => {
setSelectedRule(null);
setRuleForm(buildRuleForm(null, resolveTrack(config, trackCode)));
setActiveAction("rule-create");
};
const openRuleEdit = (rule) => {
setSelectedRule(rule);
setRuleForm(buildRuleForm(rule));
setActiveAction("rule-edit");
};
const openTierCreate = (trackCode = resolveDefaultTrack(config, track)) => {
setSelectedTier(null);
setTierForm(buildTierForm(null, resolveTrack(config, trackCode)));
setActiveAction("tier-create");
};
const openTierEdit = (tier) => {
setSelectedTier(tier);
setTierForm(buildTierForm(tier));
setActiveAction("tier-edit");
};
const openTrackEdit = (levelTrack) => {
setSelectedTrack(levelTrack);
setTrackForm(buildTrackForm(levelTrack));
setActiveAction("track-edit");
};
const closeAction = () => {
setActiveAction("");
setSelectedRule(null);
setSelectedTier(null);
setSelectedTrack(null);
};
const submitRule = async (event) => {
event.preventDefault();
const editing = activeAction === "rule-edit";
const parsed = parseForm(levelRuleFormSchema, ruleForm);
const level = Number(parsed.level);
await runAction(editing ? "rule-edit" : "rule-create", editing ? "等级规则已更新" : "等级规则已创建", async () => {
await upsertRule(parsed.track, level, buildRulePayload(parsed));
closeAction();
await reload();
});
};
const submitTier = async (event) => {
event.preventDefault();
const editing = activeAction === "tier-edit";
if (editing && !selectedTier?.tierId) {
return;
}
const parsed = parseForm(levelTierFormSchema, tierForm);
await runAction(editing ? "tier-edit" : "tier-create", editing ? "等级层已更新" : "等级层已创建", async () => {
const payload = buildTierPayload(parsed);
if (editing) {
await updateTier(selectedTier.tierId, payload);
} else {
await createTier(payload);
}
closeAction();
await reload();
});
};
const submitTrack = async (event) => {
event.preventDefault();
if (!selectedTrack?.track) {
return;
}
const parsed = parseForm(levelTrackFormSchema, trackForm);
await runAction("track-edit", "等级类型已更新", async () => {
await updateTrack(selectedTrack.track, buildTrackPayload(parsed));
closeAction();
await reload();
});
};
const toggleRule = async (rule, nextEnabled = rule.status !== "active") => {
if (!abilities.canUpdate || !rule?.track || !rule?.level) {
return;
}
const nextStatus = nextEnabled ? "active" : "disabled";
if (rule.status === nextStatus) {
return;
}
await runAction(`rule-status-${rule.track}-${rule.level}`, nextEnabled ? "等级规则已启用" : "等级规则已停用", async () => {
await upsertRule(rule.track, rule.level, buildRulePayload(buildRuleForm({ ...rule, status: nextStatus })));
await reload();
});
};
const toggleTier = async (tier, nextEnabled = tier.status !== "active") => {
if (!abilities.canUpdate || !tier?.tierId) {
return;
}
const nextStatus = nextEnabled ? "active" : "disabled";
if (tier.status === nextStatus) {
return;
}
await runAction(`tier-status-${tier.tierId}`, nextEnabled ? "等级层已启用" : "等级层已停用", async () => {
await updateTier(tier.tierId, buildTierPayload(buildTierForm({ ...tier, status: nextStatus })));
await reload();
});
};
const runAction = async (action, successMessage, submitter) => {
setLoadingAction(action);
try {
await submitter();
showToast(successMessage, "success");
} catch (err) {
showToast(err.message || "操作失败", "error");
} finally {
setLoadingAction("");
}
};
return {
abilities,
activeAction,
avatarFrameResources,
badgeResources,
changeTrack,
closeAction,
config,
error,
loading,
loadingAction,
openRuleCreate,
openRuleEdit,
openTierCreate,
openTierEdit,
openTrackEdit,
reload,
resourceGroups,
resources,
ruleForm,
selectedRule,
selectedTier,
selectedTrack,
setRuleForm,
setTierForm,
setTrackForm,
submitRule,
submitTier,
submitTrack,
tierForm,
toggleRule,
toggleTier,
track,
trackForm,
tracks,
};
}
function resolveDefaultTrack(config, selectedTrack) {
if (selectedTrack) {
return selectedTrack;
}
return config.tracks?.[0]?.track || defaultTrack;
}
function resolveTrack(config, trackCode) {
return (config.tracks || []).find((item) => item.track === trackCode) || { track: trackCode, rules: [], tiers: [] };
}
function buildRuleForm(rule = null, levelTrack = null) {
const nextLevel = rule ? rule.level : nextRuleLevel(levelTrack);
const trackCode = rule?.track || levelTrack?.track || defaultTrack;
return {
level: valueOrEmpty(nextLevel),
longBadgeResourceId: optionalId(rule?.longBadgeResourceId),
name: rule?.name || `${levelTrackLabels[trackCode] || "等级"} ${nextLevel}`,
requiredValue: valueOrEmpty(rule?.requiredValue),
rewardResourceGroupId: optionalId(rule?.rewardResourceGroupId),
sortOrder: valueOrDefault(rule?.sortOrder, nextLevel),
status: rule?.status || "active",
track: trackCode,
};
}
function buildTierForm(tier = null, levelTrack = null) {
const minLevel = tier ? tier.minLevel : nextTierStart(levelTrack);
const maxLevel = tier ? tier.maxLevel : minLevel + 9;
const trackCode = tier?.track || levelTrack?.track || defaultTrack;
return {
displayAvatarFrameResourceId: optionalId(tier?.displayAvatarFrameResourceId),
displayBadgeResourceId: optionalId(tier?.displayBadgeResourceId),
displayConfigJson: tier?.displayConfigJson || "",
maxLevel: valueOrEmpty(maxLevel),
minLevel: valueOrEmpty(minLevel),
name: tier?.name || `${levelTrackLabels[trackCode] || "等级"} ${minLevel}-${maxLevel}`,
rewardResourceGroupId: optionalId(tier?.rewardResourceGroupId),
status: tier?.status || "active",
track: trackCode,
};
}
function buildTrackForm(levelTrack = null) {
return {
displayConfigJson: levelTrack?.displayConfigJson || "",
name: levelTrack?.name || (levelTrack?.track ? levelTrackLabels[levelTrack.track] : ""),
sortOrder: valueOrDefault(levelTrack?.sortOrder, 0),
status: levelTrack?.status || "active",
};
}
function buildRulePayload(form) {
return {
long_badge_resource_id: Number(form.longBadgeResourceId || 0),
name: String(form.name || "").trim(),
required_value: Number(form.requiredValue || 0),
reward_resource_group_id: Number(form.rewardResourceGroupId || 0),
sort_order: Number(form.sortOrder || 0),
status: form.status,
};
}
function buildTierPayload(form) {
return {
display_avatar_frame_resource_id: Number(form.displayAvatarFrameResourceId || 0),
display_badge_resource_id: Number(form.displayBadgeResourceId || 0),
display_config_json: normalizedJson(form.displayConfigJson),
max_level: Number(form.maxLevel),
min_level: Number(form.minLevel),
name: String(form.name || "").trim(),
reward_resource_group_id: Number(form.rewardResourceGroupId || 0),
status: form.status,
track: form.track,
};
}
function buildTrackPayload(form) {
return {
display_config_json: normalizedJson(form.displayConfigJson),
name: String(form.name || "").trim(),
sort_order: Number(form.sortOrder || 0),
status: form.status,
};
}
function nextRuleLevel(levelTrack) {
const levels = (levelTrack?.rules || []).map((rule) => Number(rule.level || 0));
return Math.max(0, ...levels) + 1;
}
function nextTierStart(levelTrack) {
const levels = (levelTrack?.tiers || []).map((tier) => Number(tier.maxLevel || 0));
return Math.max(0, ...levels) + 1;
}
function optionalId(value) {
return value ? String(value) : "";
}
function valueOrEmpty(value) {
return value === 0 || value ? String(value) : "";
}
function valueOrDefault(value, fallback) {
return value === 0 || value ? String(value) : String(fallback);
}
function normalizedJson(value) {
const raw = String(value || "").trim();
return raw || "{}";
}

View File

@ -0,0 +1,331 @@
.pageBody {
flex: 1;
min-height: 0;
overflow: auto;
padding: var(--space-4) var(--space-5) var(--space-5);
background: linear-gradient(180deg, rgba(248, 250, 252, 0.72), rgba(255, 255, 255, 0));
}
.trackList {
display: grid;
gap: var(--space-4);
}
.trackTabsBar {
display: flex;
min-height: 0;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-4);
border: 1px solid var(--border);
border-radius: var(--radius-card);
background: var(--bg-card);
padding: var(--space-1);
}
.trackTabs {
min-width: 0;
flex: 1;
}
.trackTabs :global(.MuiTabs-flexContainer) {
gap: var(--space-1);
}
.trackTabs :global(.MuiTabs-indicator) {
display: none;
}
.trackTabs :global(.MuiTabs-scrollButtons) {
width: 32px;
border-radius: var(--radius-control);
color: var(--text-tertiary);
}
.trackTabs .trackTab {
min-width: 176px;
min-height: 48px;
align-items: flex-start;
border-radius: var(--radius-control);
color: var(--text-secondary);
padding: var(--space-2) var(--space-3);
text-align: left;
text-transform: none;
transition:
background var(--motion-fast) var(--ease-standard),
box-shadow var(--motion-fast) var(--ease-standard),
color var(--motion-fast) var(--ease-standard),
transform var(--motion-base) var(--ease-emphasized);
}
.trackTabs .trackTab:hover {
background: var(--primary-hover);
color: var(--text-primary);
}
.trackTabs .trackTab:global(.Mui-selected) {
background: var(--primary);
box-shadow: var(--brand-shadow);
color: var(--active-contrast);
}
.trackTabs .trackTab:global(.Mui-selected) .trackTabMeta {
color: rgba(255, 255, 255, 0.76);
}
.trackTabLabel {
display: grid;
min-width: 0;
gap: 3px;
font-weight: 760;
line-height: 18px;
}
.trackTabMeta {
color: var(--text-tertiary);
font-size: 12px;
font-weight: 650;
line-height: 16px;
transition: color var(--motion-fast) var(--ease-standard);
}
.tabActions {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: var(--space-2);
padding-right: var(--space-1);
}
.trackSection {
display: grid;
min-width: 0;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-card);
background: var(--bg-card);
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.05);
}
.trackHeader {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-4);
border-bottom: 1px solid var(--border-soft);
background: rgba(248, 250, 252, 0.82);
}
.trackTitleGroup,
.identityText,
.stack {
display: grid;
min-width: 0;
gap: 2px;
}
.trackTitleGroup {
gap: var(--space-2);
}
.trackTitleRow {
display: flex;
min-width: 0;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2);
}
.trackTitle {
overflow: hidden;
color: var(--text-primary);
font-size: 16px;
font-weight: 760;
text-overflow: ellipsis;
white-space: nowrap;
}
.trackMetaList {
display: flex;
min-width: 0;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2);
color: var(--text-tertiary);
font-size: var(--admin-font-size);
}
.trackMetaList span {
display: inline-flex;
min-height: 22px;
align-items: center;
border-radius: var(--radius-pill);
background: rgba(100, 116, 139, 0.09);
padding: 0 var(--space-2);
line-height: 1;
}
.meta {
overflow: hidden;
color: var(--text-tertiary);
font-size: var(--admin-font-size);
text-overflow: ellipsis;
white-space: nowrap;
}
.tableStack {
display: grid;
gap: var(--space-3);
padding: var(--space-4);
}
.tableBlock {
display: grid;
gap: var(--space-2);
min-width: 0;
}
.tableHeader {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.tableTitle {
color: var(--text-secondary);
font-size: var(--admin-font-size);
font-weight: 720;
}
.tableCount {
display: inline-flex;
height: 24px;
flex: 0 0 auto;
align-items: center;
border: 1px solid var(--border-soft);
border-radius: var(--radius-pill);
background: var(--bg-card-strong);
color: var(--text-tertiary);
padding: 0 var(--space-2);
font-size: 12px;
font-weight: 650;
line-height: 1;
}
.compactTable {
flex: 0 0 auto;
min-height: 0;
border: 1px solid var(--border);
border-radius: var(--radius-control);
}
.compactTable :global(.table-scroll) {
flex: 0 0 auto;
}
.compactTable :global(.admin-table) {
min-height: 0;
}
.compactTable :global(.admin-row) {
min-height: 52px;
}
.compactTable :global(.admin-row--head),
.compactTable :global(.admin-row--head .admin-cell--fixed-left),
.compactTable :global(.admin-row--head .admin-cell--fixed-right),
.compactTable :global(.admin-cell--head) {
min-height: 40px;
}
.compactTable :global(.empty-state--table) {
min-height: 96px;
}
.identity {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-3);
}
.levelBadge,
.rangeBadge {
display: inline-flex;
min-width: 62px;
height: 28px;
align-items: center;
justify-content: center;
border: 1px solid var(--primary-border);
border-radius: var(--radius-control);
background: var(--primary-surface);
color: var(--primary);
font-size: var(--admin-font-size);
font-weight: 760;
line-height: 1;
}
.rangeBadge {
min-width: 76px;
}
.name,
.resourceText {
overflow: hidden;
color: var(--text-primary);
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.statusCell {
display: inline-flex;
min-width: 0;
align-items: center;
gap: var(--space-1);
}
.emptyValue {
color: var(--text-tertiary);
}
.empty {
display: flex;
min-height: 220px;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
font-size: var(--admin-font-size);
}
@media (max-width: 720px) {
.trackHeader {
align-items: flex-start;
flex-direction: column;
}
.trackTabsBar {
align-items: stretch;
flex-direction: column;
}
.pageBody,
.tableStack,
.trackHeader {
padding: var(--space-3);
}
.trackTabs .trackTab {
min-width: 148px;
}
.tabActions {
justify-content: flex-end;
padding: 0 var(--space-1) var(--space-1);
}
}

View File

@ -0,0 +1,711 @@
import Add from "@mui/icons-material/Add";
import EditOutlined from "@mui/icons-material/EditOutlined";
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import MenuItem from "@mui/material/MenuItem";
import Tab from "@mui/material/Tab";
import Tabs from "@mui/material/Tabs";
import TextField from "@mui/material/TextField";
import {
AdminFormDialog,
AdminFormFieldGrid,
AdminFormSection,
AdminFormSwitchField,
} from "@/shared/ui/AdminFormDialog.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import {
AdminActionIconButton,
AdminListBody,
AdminListPage,
AdminRowActions,
} from "@/shared/ui/AdminListLayout.jsx";
import { formatMillis } from "@/shared/utils/time.js";
import {
levelStatusLabels,
levelTrackLabels,
levelTrackOptions,
} from "@/features/level-config/constants.js";
import { useLevelConfigPage } from "@/features/level-config/hooks/useLevelConfigPage.js";
import styles from "@/features/level-config/level-config.module.css";
export function LevelConfigPage() {
const page = useLevelConfigPage();
const trackTabs = buildTrackTabs(page.config.tracks);
return (
<AdminListPage>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<AdminListBody>
<div className={styles.pageBody}>
<div className={styles.trackTabsBar}>
<Tabs
allowScrollButtonsMobile
className={styles.trackTabs}
value={page.track}
variant="scrollable"
onChange={(_, nextTrack) => page.changeTrack(nextTrack)}
>
{trackTabs.map((item) => (
<Tab
className={styles.trackTab}
key={item.value}
label={
<span className={styles.trackTabLabel}>
<span>{item.label}</span>
<span className={styles.trackTabMeta}>
{item.ruleCount} 条规则 · {item.tierCount} 个等级层
</span>
</span>
}
value={item.value}
/>
))}
</Tabs>
<div className={styles.tabActions}>
<AdminActionIconButton
disabled={page.loading}
label="刷新配置"
type="button"
onClick={page.reload}
>
<RefreshOutlined fontSize="small" />
</AdminActionIconButton>
<Button
disabled={!page.abilities.canUpdate}
startIcon={<Add fontSize="small" />}
type="button"
variant="primary"
onClick={() => page.openRuleCreate(page.track)}
>
添加规则
</Button>
<Button
disabled={!page.abilities.canUpdate}
startIcon={<Add fontSize="small" />}
type="button"
variant="primary"
onClick={() => page.openTierCreate(page.track)}
>
添加等级层
</Button>
</div>
</div>
{page.tracks.length ? (
<div className={styles.trackList}>
{page.tracks.map((track) => (
<LevelTrackSection key={track.track} page={page} track={track} />
))}
</div>
) : (
<div className={styles.empty}>当前无数据</div>
)}
</div>
</AdminListBody>
</DataState>
<RuleFormDialog page={page} />
<TierFormDialog page={page} />
<TrackFormDialog page={page} />
</AdminListPage>
);
}
function LevelTrackSection({ page, track }) {
const rules = track.rules || [];
const tiers = track.tiers || [];
const totalRules = track.rules?.length || 0;
const totalTiers = track.tiers?.length || 0;
const ruleColumns = buildRuleColumns(page);
const tierColumns = buildTierColumns(page);
return (
<section className={styles.trackSection}>
<div className={styles.trackHeader}>
<div className={styles.trackTitleGroup}>
<div className={styles.trackTitleRow}>
<span className={styles.trackTitle}>{track.name || levelTrackLabels[track.track] || track.track}</span>
</div>
<div className={styles.trackMetaList}>
<span>{track.track}</span>
<span>{totalRules} 条规则</span>
<span>{totalTiers} 个等级层</span>
<span>排序 {track.sortOrder || 0}</span>
</div>
</div>
<AdminRowActions>
<AdminActionIconButton
disabled={!page.abilities.canUpdate}
label="编辑等级类型"
type="button"
onClick={() => page.openTrackEdit(track)}
>
<EditOutlined fontSize="small" />
</AdminActionIconButton>
<AdminActionIconButton
disabled={!page.abilities.canUpdate}
label="添加本类型等级规则"
type="button"
onClick={() => page.openRuleCreate(track.track)}
>
<Add fontSize="small" />
</AdminActionIconButton>
<AdminActionIconButton
disabled={!page.abilities.canUpdate}
label="添加本类型等级层"
type="button"
onClick={() => page.openTierCreate(track.track)}
>
<Add fontSize="small" />
</AdminActionIconButton>
</AdminRowActions>
</div>
<div className={styles.tableStack}>
<div className={styles.tableBlock}>
<TableBlockHeader countLabel={`${rules.length}`} title="每级升级数值" />
<DataTable
className={styles.compactTable}
columns={ruleColumns}
items={rules}
minWidth="1180px"
rowKey={(rule) => `${rule.track}-${rule.level}`}
/>
</div>
<div className={styles.tableBlock}>
<TableBlockHeader countLabel={`${tiers.length}`} title="等级层展示与礼物" />
<DataTable
className={styles.compactTable}
columns={tierColumns}
items={tiers}
minWidth="1180px"
rowKey={(tier) => tier.tierId || `${tier.track}-${tier.minLevel}-${tier.maxLevel}`}
/>
</div>
</div>
</section>
);
}
function TableBlockHeader({ countLabel, title }) {
return (
<div className={styles.tableHeader}>
<div className={styles.tableTitle}>{title}</div>
<span className={styles.tableCount}>{countLabel}</span>
</div>
);
}
function buildTrackTabs(tracks = []) {
return levelTrackOptions.map(([value, label]) => {
const track = tracks.find((item) => item.track === value);
return {
label: track?.name || label,
ruleCount: track?.rules?.length || 0,
tierCount: track?.tiers?.length || 0,
value,
};
});
}
function buildRuleColumns(page) {
return [
{
key: "level",
label: "等级",
width: "minmax(180px, 0.8fr)",
render: (rule) => <RuleIdentity rule={rule} />,
},
{
key: "requiredValue",
label: "升级数值",
width: "minmax(140px, 0.65fr)",
render: (rule) => formatNumber(rule.requiredValue),
},
{
key: "longBadge",
label: "长徽章素材",
width: "minmax(200px, 1fr)",
render: (rule) => <ResourceLabel id={rule.longBadgeResourceId} resources={page.badgeResources} />,
},
{
key: "reward",
label: "升级礼物",
width: "minmax(200px, 1fr)",
render: (rule) => <ResourceGroupLabel id={rule.rewardResourceGroupId} groups={page.resourceGroups} />,
},
{
key: "status",
label: "状态",
width: "minmax(116px, 0.55fr)",
render: (rule) => <RuleStatusSwitch page={page} rule={rule} />,
},
{
key: "sort",
label: "排序",
width: "minmax(90px, 0.45fr)",
render: (rule) => rule.sortOrder || 0,
},
{
key: "updatedAt",
label: "更新时间",
width: "minmax(170px, 0.75fr)",
render: (rule) => formatMillis(rule.updatedAtMs || rule.createdAtMs),
},
{
key: "actions",
label: "操作",
width: "minmax(76px, 0.35fr)",
render: (rule) => <RuleRowActions page={page} rule={rule} />,
},
];
}
function buildTierColumns(page) {
return [
{
key: "range",
label: "等级层",
width: "minmax(220px, 1fr)",
render: (tier) => <TierIdentity tier={tier} />,
},
{
key: "frame",
label: "头像框",
width: "minmax(200px, 0.9fr)",
render: (tier) => <ResourceLabel id={tier.displayAvatarFrameResourceId} resources={page.avatarFrameResources} />,
},
{
key: "badge",
label: "徽章",
width: "minmax(200px, 0.9fr)",
render: (tier) => <ResourceLabel id={tier.displayBadgeResourceId} resources={page.badgeResources} />,
},
{
key: "reward",
label: "升级礼物",
width: "minmax(200px, 1fr)",
render: (tier) => <ResourceGroupLabel id={tier.rewardResourceGroupId} groups={page.resourceGroups} />,
},
{
key: "status",
label: "状态",
width: "minmax(116px, 0.55fr)",
render: (tier) => <TierStatusSwitch page={page} tier={tier} />,
},
{
key: "updatedAt",
label: "更新时间",
width: "minmax(170px, 0.75fr)",
render: (tier) => formatMillis(tier.updatedAtMs || tier.createdAtMs),
},
{
key: "actions",
label: "操作",
width: "minmax(76px, 0.35fr)",
render: (tier) => <TierRowActions page={page} tier={tier} />,
},
];
}
function RuleIdentity({ rule }) {
return (
<div className={styles.identity}>
<span className={styles.levelBadge}>Lv.{rule.level}</span>
<div className={styles.identityText}>
<span className={styles.name}>{rule.name || `${levelTrackLabels[rule.track]} ${rule.level}`}</span>
<span className={styles.meta}>{levelTrackLabels[rule.track] || rule.track}</span>
</div>
</div>
);
}
function TierIdentity({ tier }) {
return (
<div className={styles.identity}>
<span className={styles.rangeBadge}>
{tier.minLevel}-{tier.maxLevel}
</span>
<div className={styles.identityText}>
<span className={styles.name}>{tier.name || `${tier.minLevel}-${tier.maxLevel}`}</span>
<span className={styles.meta}>{levelTrackLabels[tier.track] || tier.track}</span>
</div>
</div>
);
}
function RuleStatusSwitch({ page, rule }) {
const checked = rule.status === "active";
return (
<div className={styles.statusCell}>
<AdminSwitch
checked={checked}
disabled={!page.abilities.canUpdate || page.loadingAction === `rule-status-${rule.track}-${rule.level}`}
inputProps={{ "aria-label": checked ? "停用等级规则" : "启用等级规则" }}
onChange={(event) => page.toggleRule(rule, event.target.checked)}
/>
<span className={styles.meta}>{levelStatusLabel(rule.status)}</span>
</div>
);
}
function TierStatusSwitch({ page, tier }) {
const checked = tier.status === "active";
return (
<div className={styles.statusCell}>
<AdminSwitch
checked={checked}
disabled={!page.abilities.canUpdate || page.loadingAction === `tier-status-${tier.tierId}`}
inputProps={{ "aria-label": checked ? "停用等级层" : "启用等级层" }}
onChange={(event) => page.toggleTier(tier, event.target.checked)}
/>
<span className={styles.meta}>{levelStatusLabel(tier.status)}</span>
</div>
);
}
function RuleRowActions({ page, rule }) {
return (
<AdminRowActions>
<AdminActionIconButton
disabled={!page.abilities.canUpdate}
label="编辑等级规则"
type="button"
onClick={() => page.openRuleEdit(rule)}
>
<EditOutlined fontSize="small" />
</AdminActionIconButton>
</AdminRowActions>
);
}
function TierRowActions({ page, tier }) {
return (
<AdminRowActions>
<AdminActionIconButton
disabled={!page.abilities.canUpdate}
label="编辑等级层"
type="button"
onClick={() => page.openTierEdit(tier)}
>
<EditOutlined fontSize="small" />
</AdminActionIconButton>
</AdminRowActions>
);
}
function RuleFormDialog({ page }) {
const open = page.activeAction === "rule-create" || page.activeAction === "rule-edit";
const editing = page.activeAction === "rule-edit";
const form = page.ruleForm;
return (
<AdminFormDialog
disabled={!page.abilities.canUpdate}
loading={page.loadingAction === page.activeAction}
open={open}
size="wide"
submitLabel={editing ? "保存规则" : "创建规则"}
title={editing ? "编辑等级规则" : "添加等级规则"}
onClose={page.closeAction}
onSubmit={page.submitRule}
>
<AdminFormSection title="升级配置">
<AdminFormFieldGrid>
<TextField
select
disabled={editing || !page.abilities.canUpdate}
label="等级类型"
value={form.track}
onChange={changeField(page.setRuleForm, "track")}
>
{levelTrackOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
disabled={editing || !page.abilities.canUpdate}
inputProps={{ min: 1 }}
label="等级"
type="number"
value={form.level}
onChange={changeField(page.setRuleForm, "level")}
/>
<TextField
disabled={!page.abilities.canUpdate}
inputProps={{ min: 0 }}
label="升级数值"
type="number"
value={form.requiredValue}
onChange={changeField(page.setRuleForm, "requiredValue")}
/>
<TextField
disabled={!page.abilities.canUpdate}
label="展示名称"
value={form.name}
onChange={changeField(page.setRuleForm, "name")}
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="展示与礼物">
<AdminFormFieldGrid>
<ResourceSelect
disabled={!page.abilities.canUpdate}
label="长徽章素材"
resources={page.badgeResources}
value={form.longBadgeResourceId}
onChange={changeField(page.setRuleForm, "longBadgeResourceId")}
/>
<ResourceGroupSelect
disabled={!page.abilities.canUpdate}
groups={page.resourceGroups}
label="升级礼物"
value={form.rewardResourceGroupId}
onChange={changeField(page.setRuleForm, "rewardResourceGroupId")}
/>
<TextField
disabled={!page.abilities.canUpdate}
label="排序"
type="number"
value={form.sortOrder}
onChange={changeField(page.setRuleForm, "sortOrder")}
/>
<AdminFormSwitchField
checked={form.status === "active"}
checkedLabel="启用"
disabled={!page.abilities.canUpdate}
label="状态"
uncheckedLabel="停用"
onChange={(checked) =>
page.setRuleForm((current) => ({ ...current, status: checked ? "active" : "disabled" }))
}
/>
</AdminFormFieldGrid>
</AdminFormSection>
</AdminFormDialog>
);
}
function TierFormDialog({ page }) {
const open = page.activeAction === "tier-create" || page.activeAction === "tier-edit";
const editing = page.activeAction === "tier-edit";
const form = page.tierForm;
return (
<AdminFormDialog
disabled={!page.abilities.canUpdate}
loading={page.loadingAction === page.activeAction}
open={open}
size="wide"
submitLabel={editing ? "保存等级层" : "创建等级层"}
title={editing ? "编辑等级层" : "添加等级层"}
onClose={page.closeAction}
onSubmit={page.submitTier}
>
<AdminFormSection title="等级范围">
<AdminFormFieldGrid>
<TextField
select
disabled={editing || !page.abilities.canUpdate}
label="等级类型"
value={form.track}
onChange={changeField(page.setTierForm, "track")}
>
{levelTrackOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
disabled={!page.abilities.canUpdate}
inputProps={{ min: 1 }}
label="起始等级"
type="number"
value={form.minLevel}
onChange={changeField(page.setTierForm, "minLevel")}
/>
<TextField
disabled={!page.abilities.canUpdate}
inputProps={{ min: 1 }}
label="结束等级"
type="number"
value={form.maxLevel}
onChange={changeField(page.setTierForm, "maxLevel")}
/>
<TextField
disabled={!page.abilities.canUpdate}
label="等级层名称"
value={form.name}
onChange={changeField(page.setTierForm, "name")}
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="展示与礼物">
<AdminFormFieldGrid>
<ResourceSelect
disabled={!page.abilities.canUpdate}
label="头像框素材"
resources={page.avatarFrameResources}
value={form.displayAvatarFrameResourceId}
onChange={changeField(page.setTierForm, "displayAvatarFrameResourceId")}
/>
<ResourceSelect
disabled={!page.abilities.canUpdate}
label="徽章素材"
resources={page.badgeResources}
value={form.displayBadgeResourceId}
onChange={changeField(page.setTierForm, "displayBadgeResourceId")}
/>
<ResourceGroupSelect
disabled={!page.abilities.canUpdate}
groups={page.resourceGroups}
label="升级礼物"
value={form.rewardResourceGroupId}
onChange={changeField(page.setTierForm, "rewardResourceGroupId")}
/>
<AdminFormSwitchField
checked={form.status === "active"}
checkedLabel="启用"
disabled={!page.abilities.canUpdate}
label="状态"
uncheckedLabel="停用"
onChange={(checked) =>
page.setTierForm((current) => ({ ...current, status: checked ? "active" : "disabled" }))
}
/>
</AdminFormFieldGrid>
</AdminFormSection>
</AdminFormDialog>
);
}
function TrackFormDialog({ page }) {
const form = page.trackForm;
return (
<AdminFormDialog
disabled={!page.abilities.canUpdate}
loading={page.loadingAction === "track-edit"}
open={page.activeAction === "track-edit"}
submitLabel="保存等级类型"
title="编辑等级类型"
onClose={page.closeAction}
onSubmit={page.submitTrack}
>
<AdminFormSection title={levelTrackLabels[page.selectedTrack?.track] || "等级类型"}>
<AdminFormFieldGrid>
<TextField
disabled={!page.abilities.canUpdate}
label="名称"
value={form.name}
onChange={changeField(page.setTrackForm, "name")}
/>
<TextField
disabled={!page.abilities.canUpdate}
label="排序"
type="number"
value={form.sortOrder}
onChange={changeField(page.setTrackForm, "sortOrder")}
/>
<AdminFormSwitchField
checked={form.status === "active"}
checkedLabel="启用"
disabled={!page.abilities.canUpdate}
label="状态"
uncheckedLabel="停用"
onChange={(checked) =>
page.setTrackForm((current) => ({ ...current, status: checked ? "active" : "disabled" }))
}
/>
</AdminFormFieldGrid>
</AdminFormSection>
</AdminFormDialog>
);
}
function ResourceSelect({ disabled, label, onChange, resources, value }) {
const options = withSelectedResource(resources, value);
return (
<TextField select disabled={disabled} label={label} value={String(value || "")} onChange={onChange}>
<MenuItem value="">不配置</MenuItem>
{options.map((resource) => (
<MenuItem key={resource.resourceId} value={String(resource.resourceId)}>
{resource.name || resource.resourceCode || resource.resourceId}
</MenuItem>
))}
</TextField>
);
}
function ResourceGroupSelect({ disabled, groups, label, onChange, value }) {
const options = withSelectedGroup(groups, value);
return (
<TextField select disabled={disabled} label={label} value={String(value || "")} onChange={onChange}>
<MenuItem value="">不配置</MenuItem>
{options.map((group) => (
<MenuItem key={group.groupId} value={String(group.groupId)}>
{group.name || group.groupCode || group.groupId}
</MenuItem>
))}
</TextField>
);
}
function ResourceLabel({ id, resources }) {
if (!id) {
return <span className={styles.emptyValue}>未配置</span>;
}
const resource = resources.find((item) => Number(item.resourceId) === Number(id));
return (
<div className={styles.stack}>
<span className={styles.resourceText}>{resource?.name || resource?.resourceCode || `资源 #${id}`}</span>
<span className={styles.meta}>ID {id}</span>
</div>
);
}
function ResourceGroupLabel({ groups, id }) {
if (!id) {
return <span className={styles.emptyValue}>未配置</span>;
}
const group = groups.find((item) => Number(item.groupId) === Number(id));
return (
<div className={styles.stack}>
<span className={styles.resourceText}>{group?.name || group?.groupCode || `资源组 #${id}`}</span>
<span className={styles.meta}>ID {id}</span>
</div>
);
}
function changeField(setter, key) {
return (event) => {
const value = event.target.value;
setter((current) => ({ ...current, [key]: value }));
};
}
function withSelectedResource(resources, value) {
const selectedId = Number(value || 0);
if (!selectedId || resources.some((resource) => Number(resource.resourceId) === selectedId)) {
return resources;
}
return [{ resourceId: selectedId, name: `资源 #${selectedId}` }, ...resources];
}
function withSelectedGroup(groups, value) {
const selectedId = Number(value || 0);
if (!selectedId || groups.some((group) => Number(group.groupId) === selectedId)) {
return groups;
}
return [{ groupId: selectedId, name: `资源组 #${selectedId}` }, ...groups];
}
function levelStatusLabel(status) {
return levelStatusLabels[status] || status || "-";
}
function formatNumber(value) {
return Number(value || 0).toLocaleString("zh-CN");
}

View File

@ -0,0 +1,11 @@
import { PERMISSIONS } from "@/app/permissions";
import { useAuth } from "@/app/auth/AuthProvider.jsx";
export function useLevelConfigAbilities() {
const { can } = useAuth();
return {
canUpdate: can(PERMISSIONS.levelConfigUpdate),
canView: can(PERMISSIONS.levelConfigView),
};
}

View File

@ -0,0 +1,12 @@
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
export const levelConfigRoutes = [
{
label: "等级配置",
loader: () => import("./pages/LevelConfigPage.jsx").then((module) => module.LevelConfigPage),
menuCode: MENU_CODES.appUserLevelConfig,
pageKey: "app-user-level-config",
path: "/app/users/level-config",
permission: PERMISSIONS.levelConfigView,
},
];

View File

@ -0,0 +1,99 @@
import { z } from "zod";
const trackSchema = z.enum(["wealth", "game", "charm"]);
const statusSchema = z.enum(["active", "disabled"]);
const numberInput = z.union([z.string(), z.number()]);
export const levelTrackFormSchema = z
.object({
displayConfigJson: z.string().optional(),
name: z.string().trim().min(1, "请输入等级名称").max(64, "等级名称不能超过 64 个字符"),
sortOrder: numberInput.optional(),
status: statusSchema,
})
.superRefine((value, context) => {
validateInteger(context, "sortOrder", value.sortOrder || 0, "排序必须是整数");
});
export const levelRuleFormSchema = z
.object({
level: numberInput,
longBadgeResourceId: numberInput.optional(),
name: z.string().trim().min(1, "请输入等级展示名称").max(64, "等级展示名称不能超过 64 个字符"),
requiredValue: numberInput,
rewardResourceGroupId: numberInput.optional(),
sortOrder: numberInput.optional(),
status: statusSchema,
track: trackSchema,
})
.superRefine((value, context) => {
validatePositiveInteger(context, "level", value.level, "等级必须是正整数");
validateNonNegativeInteger(context, "requiredValue", value.requiredValue, "升级数值必须是非负整数");
validateOptionalId(context, "longBadgeResourceId", value.longBadgeResourceId, "长徽章素材必须是有效资源");
validateOptionalId(context, "rewardResourceGroupId", value.rewardResourceGroupId, "升级礼物必须是有效资源组");
validateInteger(context, "sortOrder", value.sortOrder || 0, "排序必须是整数");
});
export const levelTierFormSchema = z
.object({
displayAvatarFrameResourceId: numberInput.optional(),
displayBadgeResourceId: numberInput.optional(),
displayConfigJson: z.string().optional(),
maxLevel: numberInput,
minLevel: numberInput,
name: z.string().trim().min(1, "请输入等级层名称").max(64, "等级层名称不能超过 64 个字符"),
rewardResourceGroupId: numberInput.optional(),
status: statusSchema,
track: trackSchema,
})
.superRefine((value, context) => {
const minLevel = Number(value.minLevel);
const maxLevel = Number(value.maxLevel);
validatePositiveInteger(context, "minLevel", value.minLevel, "起始等级必须是正整数");
validatePositiveInteger(context, "maxLevel", value.maxLevel, "结束等级必须是正整数");
if (Number.isInteger(minLevel) && Number.isInteger(maxLevel) && maxLevel < minLevel) {
context.addIssue({ code: "custom", message: "结束等级不能小于起始等级", path: ["maxLevel"] });
}
validateOptionalId(
context,
"displayAvatarFrameResourceId",
value.displayAvatarFrameResourceId,
"头像框素材必须是有效资源",
);
validateOptionalId(context, "displayBadgeResourceId", value.displayBadgeResourceId, "徽章素材必须是有效资源");
validateOptionalId(context, "rewardResourceGroupId", value.rewardResourceGroupId, "升级礼物必须是有效资源组");
});
export type LevelRuleForm = z.infer<typeof levelRuleFormSchema>;
export type LevelTierForm = z.infer<typeof levelTierFormSchema>;
export type LevelTrackForm = z.infer<typeof levelTrackFormSchema>;
function validatePositiveInteger(context: z.RefinementCtx, path: string, value: unknown, message: string) {
const numeric = Number(value);
if (!Number.isInteger(numeric) || numeric <= 0) {
context.addIssue({ code: "custom", message, path: [path] });
}
}
function validateNonNegativeInteger(context: z.RefinementCtx, path: string, value: unknown, message: string) {
const numeric = Number(value);
if (!Number.isInteger(numeric) || numeric < 0) {
context.addIssue({ code: "custom", message, path: [path] });
}
}
function validateInteger(context: z.RefinementCtx, path: string, value: unknown, message: string) {
const numeric = Number(value);
if (!Number.isInteger(numeric)) {
context.addIssue({ code: "custom", message, path: [path] });
}
}
function validateOptionalId(context: z.RefinementCtx, path: string, value: unknown, message: string) {
const raw = String(value ?? "").trim();
if (!raw) {
return;
}
validatePositiveInteger(context, path, raw, message);
}

View File

@ -0,0 +1,39 @@
import { afterEach, expect, test, vi } from "vitest";
import { setAccessToken } from "@/shared/api/request";
import { createCoinAdjustment, listCoinAdjustments, lookupCoinAdjustmentTarget } from "./api";
afterEach(() => {
setAccessToken("");
vi.unstubAllGlobals();
});
test("coin adjustment APIs use generated admin paths", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () => new Response(JSON.stringify({ code: 0, data: { items: [], page: 1, pageSize: 20, total: 0 } })),
),
);
await listCoinAdjustments({ page: 1, page_size: 20, user_keyword: "1001" });
await lookupCoinAdjustmentTarget("1001");
await createCoinAdjustment({
amount: 500,
commandId: "coin-adjustment-test",
reason: "运营发放",
targetUserId: "1001",
});
const [listUrl, listInit] = vi.mocked(fetch).mock.calls[0];
const [lookupUrl, lookupInit] = vi.mocked(fetch).mock.calls[1];
const [createUrl, createInit] = vi.mocked(fetch).mock.calls[2];
expect(String(listUrl)).toContain("/api/v1/admin/operations/coin-adjustments?");
expect(String(listUrl)).toContain("user_keyword=1001");
expect(listInit?.method).toBe("GET");
expect(String(lookupUrl)).toContain("/api/v1/admin/operations/coin-adjustments/target?");
expect(String(lookupUrl)).toContain("user_id=1001");
expect(lookupInit?.method).toBe("GET");
expect(String(createUrl)).toContain("/api/v1/admin/operations/coin-adjustments");
expect(createInit?.method).toBe("POST");
});

View File

@ -1,6 +1,14 @@
import { apiRequest } from "@/shared/api/request";
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
import type { ApiPage, CoinLedgerEntryDto, PageQuery } from "@/shared/api/types";
import type {
ApiPage,
CoinAdjustmentCreateDto,
CoinAdjustmentDto,
CoinAdjustmentPayload,
CoinLedgerEntryDto,
CoinLedgerUserDto,
PageQuery,
} from "@/shared/api/types";
export function listCoinLedger(query: PageQuery = {}): Promise<ApiPage<CoinLedgerEntryDto>> {
const endpoint = API_ENDPOINTS.listCoinLedger;
@ -9,3 +17,30 @@ export function listCoinLedger(query: PageQuery = {}): Promise<ApiPage<CoinLedge
query,
});
}
export function listCoinAdjustments(query: PageQuery = {}): Promise<ApiPage<CoinAdjustmentDto>> {
const endpoint = API_ENDPOINTS.listCoinAdjustments;
return apiRequest<ApiPage<CoinAdjustmentDto>>(apiEndpointPath(API_OPERATIONS.listCoinAdjustments), {
method: endpoint.method,
query,
});
}
export function lookupCoinAdjustmentTarget(userId: string): Promise<CoinLedgerUserDto> {
const endpoint = API_ENDPOINTS.lookupCoinAdjustmentTarget;
return apiRequest<CoinLedgerUserDto>(apiEndpointPath(API_OPERATIONS.lookupCoinAdjustmentTarget), {
method: endpoint.method,
query: { user_id: userId },
});
}
export function createCoinAdjustment(payload: CoinAdjustmentPayload): Promise<CoinAdjustmentCreateDto> {
const endpoint = API_ENDPOINTS.createCoinAdjustment;
return apiRequest<CoinAdjustmentCreateDto, CoinAdjustmentPayload>(
apiEndpointPath(API_OPERATIONS.createCoinAdjustment),
{
body: payload,
method: endpoint.method,
},
);
}

View File

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

View File

@ -0,0 +1,403 @@
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
import SearchOutlined from "@mui/icons-material/SearchOutlined";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { useMemo, useState } from "react";
import { createCoinAdjustment, listCoinAdjustments, lookupCoinAdjustmentTarget } from "@/features/operations/api";
import { useCoinAdjustmentAbilities } from "@/features/operations/permissions.js";
import styles from "@/features/operations/operations.module.css";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import {
AdminFormAmountField,
AdminFormDialog,
AdminFormFieldGrid,
AdminFormSection,
} from "@/shared/ui/AdminFormDialog.jsx";
import {
AdminActionIconButton,
AdminFilterResetButton,
AdminListBody,
AdminListPage,
AdminListToolbar,
} from "@/shared/ui/AdminListLayout.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
const pageSize = 20;
const adjustmentReasons = ["运营发放", "活动补发", "系统补偿", "异常扣回", "风控扣减"];
const emptyForm = () => ({
amount: "",
mode: "increase",
reason: adjustmentReasons[0],
userKeyword: "",
});
const columns = [
{
key: "user",
label: "接收用户",
render: (item) => <AdjustmentUserCell item={item} />,
width: "minmax(260px, 1.35fr)",
},
{
key: "amount",
label: "增减数量",
render: (item) => <AdjustmentAmount item={item} />,
width: "minmax(130px, 0.65fr)",
},
{
key: "reason",
label: "发放理由",
render: (item) => item.reason || "-",
width: "minmax(170px, 0.85fr)",
},
{
key: "operator",
label: "发放人",
render: (item) => operatorName(item),
width: "minmax(150px, 0.8fr)",
},
{
key: "balance",
label: "余额",
render: (item) => formatNumber(item.availableAfter),
width: "minmax(120px, 0.65fr)",
},
{
key: "createdAtMs",
label: "时间",
render: (item) => formatMillis(item.createdAtMs),
width: "minmax(170px, 0.85fr)",
},
];
export function CoinAdjustmentPage() {
const abilities = useCoinAdjustmentAbilities();
const { showToast } = useToast();
const [page, setPage] = useState(1);
const [userKeyword, setUserKeyword] = useState("");
const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" });
const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState(emptyForm);
const [targetUser, setTargetUser] = useState(null);
const [lookupLoading, setLookupLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const filters = useMemo(
() => ({
end_at_ms: timeRange.endMs || "",
start_at_ms: timeRange.startMs || "",
user_keyword: userKeyword,
}),
[timeRange.endMs, timeRange.startMs, userKeyword],
);
const query = usePaginatedQuery({
errorMessage: "获取金币增减列表失败",
fetcher: listCoinAdjustments,
filters,
page,
pageSize,
queryKey: ["coin-adjustments", filters, page],
});
const data = query.data || { items: [], total: 0, page, pageSize };
const items = data.items || [];
const total = data.total || 0;
const tableColumns = columns.map((column) =>
column.key === "user"
? {
...column,
filter: createTextColumnFilter({
placeholder: "长 ID / 短 ID / 昵称",
value: userKeyword,
onChange: (value) => {
setUserKeyword(value);
setPage(1);
},
}),
}
: column,
);
const resetFilters = () => {
setUserKeyword("");
setTimeRange({ endMs: "", startMs: "" });
setPage(1);
};
const changeTimeRange = (nextRange) => {
setTimeRange(nextRange);
setPage(1);
};
const openDialog = () => {
setForm(emptyForm());
setTargetUser(null);
setDialogOpen(true);
};
const closeDialog = () => {
if (submitting) {
return;
}
setDialogOpen(false);
setTargetUser(null);
};
const changeForm = (patch) => {
setForm((current) => ({ ...current, ...patch }));
};
const searchTargetUser = async () => {
const keyword = form.userKeyword.trim();
if (!keyword) {
showToast("请输入用户 ID", "error");
return;
}
setLookupLoading(true);
try {
const user = await lookupCoinAdjustmentTarget(keyword);
setTargetUser(user);
} catch (err) {
setTargetUser(null);
showToast(err.message || "查询用户失败", "error");
} finally {
setLookupLoading(false);
}
};
const submitAdjustment = async (event) => {
event.preventDefault();
if (!targetUser?.userId) {
showToast("请先搜索用户", "error");
return;
}
let payload;
try {
payload = buildAdjustmentPayload(form, targetUser);
} catch (err) {
showToast(err.message || "金币增减参数不正确", "error");
return;
}
setSubmitting(true);
try {
await createCoinAdjustment(payload);
showToast("金币已调整", "success");
setForm(emptyForm());
setTargetUser(null);
setDialogOpen(false);
await query.reload();
} catch (err) {
showToast(err.message || "金币调整失败", "error");
} finally {
setSubmitting(false);
}
};
return (
<AdminListPage>
<AdminListToolbar
actions={
abilities.canCreate ? (
<AdminActionIconButton label="金币增减" primary onClick={openDialog}>
<MonetizationOnOutlined fontSize="small" />
</AdminActionIconButton>
) : null
}
filters={
<>
<TimeRangeFilter value={timeRange} onChange={changeTimeRange} />
<AdminFilterResetButton
disabled={!userKeyword && !timeRange.startMs && !timeRange.endMs}
onClick={resetFilters}
/>
</>
}
/>
<DataState error={query.error} loading={query.loading} onRetry={query.reload}>
<AdminListBody>
<DataTable
columns={tableColumns}
items={items}
minWidth="1010px"
pagination={
total > 0
? {
page,
pageSize: data.pageSize || pageSize,
total,
onPageChange: setPage,
}
: undefined
}
rowKey={(item) => item.entryId}
/>
</AdminListBody>
</DataState>
<AdminFormDialog
disabled={!abilities.canCreate}
loading={submitting}
open={dialogOpen}
size="large"
submitDisabled={!abilities.canCreate || submitting || !targetUser?.userId}
submitLabel="确认"
title="金币增减"
onClose={closeDialog}
onSubmit={submitAdjustment}
>
<AdminFormSection title="接收用户">
<div className={styles.lookupRow}>
<TextField
disabled={!abilities.canCreate || submitting}
label="用户 ID"
required
value={form.userKeyword}
onChange={(event) => {
changeForm({ userKeyword: event.target.value });
setTargetUser(null);
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
searchTargetUser();
}
}}
/>
<Button
disabled={!abilities.canCreate || lookupLoading || submitting}
startIcon={<SearchOutlined fontSize="small" />}
onClick={searchTargetUser}
>
搜索
</Button>
</div>
{targetUser ? <TargetUserPreview user={targetUser} /> : null}
</AdminFormSection>
<AdminFormSection title="金币信息">
<AdminFormFieldGrid columns="repeat(3, minmax(0, 1fr))">
<TextField
disabled={!abilities.canCreate || submitting}
label="类型"
required
select
value={form.mode}
onChange={(event) => changeForm({ mode: event.target.value })}
>
<MenuItem value="increase">增加</MenuItem>
<MenuItem value="decrease">扣减</MenuItem>
</TextField>
<AdminFormAmountField
disabled={!abilities.canCreate || submitting}
inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }}
label="发放数量"
required
unit="金币"
value={form.amount}
onChange={(event) => changeForm({ amount: event.target.value })}
/>
<TextField
disabled={!abilities.canCreate || submitting}
label="发放理由"
required
select
value={form.reason}
onChange={(event) => changeForm({ reason: event.target.value })}
>
{adjustmentReasons.map((reason) => (
<MenuItem key={reason} value={reason}>
{reason}
</MenuItem>
))}
</TextField>
</AdminFormFieldGrid>
</AdminFormSection>
</AdminFormDialog>
</AdminListPage>
);
}
function AdjustmentUserCell({ item }) {
const user = item.user || {};
const name = user.username || "-";
return (
<div className={styles.identity}>
<span className={styles.avatar}>
{user.avatar ? <img alt="" src={user.avatar} /> : <PersonOutlineOutlined fontSize="small" />}
</span>
<span className={styles.identityText}>
<span className={styles.primaryText}>{name}</span>
<span className={styles.meta}>{userIdText(user, item.userId)}</span>
</span>
</div>
);
}
function TargetUserPreview({ user }) {
return (
<div className={styles.targetPreview}>
<span className={styles.avatar}>
{user.avatar ? <img alt="" src={user.avatar} /> : <PersonOutlineOutlined fontSize="small" />}
</span>
<span className={styles.identityText}>
<span className={styles.primaryText}>{user.username || "-"}</span>
<span className={styles.meta}>{userIdText(user, user.userId)}</span>
</span>
</div>
);
}
function AdjustmentAmount({ item }) {
const delta = Number(item.availableDelta ?? item.amount ?? 0);
const expense = delta < 0;
const sign = expense ? "-" : "+";
return (
<span
className={`${styles.amount} ${styles.adjustmentAmount} ${
expense ? styles.amountExpense : styles.amountIncome
}`}
>
{sign}
{formatNumber(Math.abs(delta))}
</span>
);
}
function buildAdjustmentPayload(form, targetUser) {
const rawAmount = String(form.amount || "").trim();
if (!/^[1-9]\d*$/.test(rawAmount)) {
throw new Error("请输入金币数量");
}
const amount = Number(rawAmount);
if (!Number.isSafeInteger(amount)) {
throw new Error("金币数量过大");
}
const signedAmount = form.mode === "decrease" ? -amount : amount;
const reason = String(form.reason || "").trim();
if (!reason) {
throw new Error("请选择发放理由");
}
return {
amount: signedAmount,
commandId: `coin-adjustment-${Date.now()}`,
reason,
targetUserId: String(targetUser.userId),
};
}
function userIdText(user, fallbackUserId) {
const ids = [user.displayUserId, user.userId || fallbackUserId].filter(Boolean);
return ids.length ? ids.join(" / ") : "-";
}
function operatorName(item) {
const operator = item.operator || {};
return operator.name || operator.username || item.operatorUserId || "-";
}
function formatNumber(value) {
return Number(value || 0).toLocaleString("zh-CN");
}

View File

@ -0,0 +1,11 @@
import { useAuth } from "@/app/auth/AuthProvider.jsx";
import { PERMISSIONS } from "@/app/permissions";
export function useCoinAdjustmentAbilities() {
const { can } = useAuth();
return {
canCreate: can(PERMISSIONS.coinAdjustmentCreate),
canView: can(PERMISSIONS.coinAdjustmentView),
};
}

View File

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

View File

@ -0,0 +1,62 @@
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
import { apiRequest } from "@/shared/api/request";
import type { ApiList } from "@/shared/api/types";
export interface RegionBlockDto {
blockId: number;
keyword: string;
countryCode: string;
enabled: boolean;
createdAtMs: number;
updatedAtMs: number;
}
export interface RegionBlockPayload {
keywords: string[];
}
type RawRegionBlock = RegionBlockDto & Record<string, unknown>;
export function listRegionBlocks(): Promise<ApiList<RegionBlockDto>> {
const endpoint = API_ENDPOINTS.listRegionBlocks;
return apiRequest<ApiList<RawRegionBlock>>(apiEndpointPath(API_OPERATIONS.listRegionBlocks), {
method: endpoint.method,
}).then((data) => ({
items: (data.items || []).map(normalizeRegionBlock),
total: Number(data.total || 0),
}));
}
export function replaceRegionBlocks(payload: RegionBlockPayload): Promise<ApiList<RegionBlockDto>> {
const endpoint = API_ENDPOINTS.replaceRegionBlocks;
return apiRequest<ApiList<RawRegionBlock>, RegionBlockPayload>(
apiEndpointPath(API_OPERATIONS.replaceRegionBlocks),
{
body: payload,
method: endpoint.method,
},
).then((data) => ({
items: (data.items || []).map(normalizeRegionBlock),
total: Number(data.total || 0),
}));
}
function normalizeRegionBlock(item: RawRegionBlock): RegionBlockDto {
return {
blockId: numberValue(item.blockId ?? item.block_id),
keyword: stringValue(item.keyword),
countryCode: stringValue(item.countryCode ?? item.country_code),
enabled: Boolean(item.enabled),
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
};
}
function stringValue(value: unknown) {
return value === undefined || value === null ? "" : String(value);
}
function numberValue(value: unknown) {
const number = Number(value || 0);
return Number.isFinite(number) ? number : 0;
}

View File

@ -0,0 +1,150 @@
import { useEffect, useMemo, useState } from "react";
import { parseForm, FormValidationError } from "@/shared/forms/validation";
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { listRegionBlocks, replaceRegionBlocks } from "@/features/region-blocks/api";
import { useRegionBlockAbilities } from "@/features/region-blocks/permissions.js";
import { regionBlockKeywordSchema, regionBlockReplaceSchema } from "@/features/region-blocks/schema";
const emptyData = { items: [], total: 0 };
const countryCodePattern = /^[A-Za-z]{2,3}$/;
export function useRegionBlockPage() {
const abilities = useRegionBlockAbilities();
const { showToast } = useToast();
const [keywordInput, setKeywordInput] = useState("");
const [draftKeywords, setDraftKeywords] = useState([]);
const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false);
const {
data = emptyData,
error,
loading,
reload,
} = useAdminQuery(listRegionBlocks, {
errorMessage: "加载地区屏蔽配置失败",
initialData: emptyData,
queryKey: ["region-blocks"],
});
const serverKeywords = useMemo(() => (data.items || []).map((item) => item.keyword).filter(Boolean), [data.items]);
const serverKeywordKey = serverKeywords.join("\n");
const serverBlockByKeyword = useMemo(() => {
const blocks = new Map();
(data.items || []).forEach((item) => blocks.set(normalizeKey(item.keyword), item));
return blocks;
}, [data.items]);
useEffect(() => {
if (!dirty) {
setDraftKeywords(serverKeywords);
}
}, [dirty, serverKeywordKey, serverKeywords]);
const draftItems = useMemo(
() =>
draftKeywords.map((keyword, index) => {
const saved = serverBlockByKeyword.get(normalizeKey(keyword));
return (
saved || {
blockId: `draft-${index}-${keyword}`,
countryCode: countryCodePattern.test(keyword) ? keyword.toUpperCase() : "",
createdAtMs: 0,
enabled: true,
keyword,
updatedAtMs: 0,
}
);
}),
[draftKeywords, serverBlockByKeyword],
);
const addKeyword = () => {
if (!abilities.canUpdate) {
return;
}
try {
const keyword = parseForm(regionBlockKeywordSchema, keywordInput);
if (draftKeywords.some((item) => normalizeKey(item) === normalizeKey(keyword))) {
showToast("屏蔽词已存在", "warning");
return;
}
setDraftKeywords([...draftKeywords, normalizeKeyword(keyword)]);
setKeywordInput("");
setDirty(true);
} catch (err) {
showValidationError(err, showToast, "屏蔽词参数不正确");
}
};
const submitInput = (event) => {
event.preventDefault();
addKeyword();
};
const removeKeyword = (keyword) => {
if (!abilities.canUpdate) {
return;
}
setDraftKeywords(draftKeywords.filter((item) => normalizeKey(item) !== normalizeKey(keyword)));
setDirty(true);
};
const resetDraft = () => {
setDraftKeywords(serverKeywords);
setKeywordInput("");
setDirty(false);
};
const save = async () => {
if (!abilities.canUpdate || saving) {
return;
}
setSaving(true);
try {
const payload = parseForm(regionBlockReplaceSchema, { keywords: draftKeywords });
const saved = await replaceRegionBlocks(payload);
setDraftKeywords((saved.items || []).map((item) => item.keyword).filter(Boolean));
setDirty(false);
showToast("地区屏蔽配置已保存", "success");
await reload();
} catch (err) {
showValidationError(err, showToast, "保存地区屏蔽配置失败");
} finally {
setSaving(false);
}
};
return {
abilities,
addKeyword,
dirty,
draftItems,
error,
keywordInput,
loading,
reload,
removeKeyword,
resetDraft,
save,
saving,
setKeywordInput,
submitInput,
};
}
function normalizeKeyword(value) {
const keyword = value.trim();
return countryCodePattern.test(keyword) ? keyword.toUpperCase() : keyword;
}
function normalizeKey(value) {
return String(value || "").trim().toLowerCase();
}
function showValidationError(err, showToast, fallback) {
if (err instanceof FormValidationError) {
showToast(err.message, "error");
return;
}
showToast(err?.message || fallback, "error");
}

View File

@ -0,0 +1,135 @@
import AddOutlined from "@mui/icons-material/AddOutlined";
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
import RestartAltOutlined from "@mui/icons-material/RestartAltOutlined";
import SaveOutlined from "@mui/icons-material/SaveOutlined";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { IconButton } from "@/shared/ui/IconButton.jsx";
import { AdminListBody, AdminListPage, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx";
import { formatMillis } from "@/shared/utils/time.js";
import { useRegionBlockPage } from "@/features/region-blocks/hooks/useRegionBlockPage.js";
import styles from "@/features/region-blocks/region-blocks.module.css";
export function RegionBlockPage() {
const page = useRegionBlockPage();
const columns = buildColumns(page);
return (
<AdminListPage>
<AdminListToolbar
actions={
<>
<Button
disabled={!page.dirty || page.saving}
startIcon={<RestartAltOutlined fontSize="small" />}
onClick={page.resetDraft}
>
重置
</Button>
<Button
disabled={page.loading || page.saving}
startIcon={<RefreshOutlined fontSize="small" />}
onClick={page.reload}
>
刷新
</Button>
<Button
disabled={!page.abilities.canUpdate || !page.dirty || page.saving}
startIcon={<SaveOutlined fontSize="small" />}
variant="primary"
onClick={page.save}
>
保存
</Button>
</>
}
filters={
<form className={styles.addForm} onSubmit={page.submitInput}>
<TextField
className={styles.keywordInput}
disabled={!page.abilities.canUpdate || page.saving}
label="屏蔽词"
placeholder="国家码或国家名称"
size="small"
value={page.keywordInput}
onChange={(event) => page.setKeywordInput(event.target.value)}
/>
<Button
disabled={!page.abilities.canUpdate || page.saving}
startIcon={<AddOutlined fontSize="small" />}
type="submit"
variant="primary"
>
添加
</Button>
</form>
}
/>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<AdminListBody>
<DataTable
columns={columns}
items={page.draftItems}
minWidth="880px"
rowKey={(item) => item.blockId || item.keyword}
/>
</AdminListBody>
</DataState>
</AdminListPage>
);
}
function buildColumns(page) {
return [
{
key: "keyword",
label: "屏蔽词",
width: "minmax(220px, 1.2fr)",
render: (item) => <KeywordCell item={item} />,
},
{
key: "countryCode",
label: "国家码",
width: "minmax(120px, 0.6fr)",
render: (item) => item.countryCode || "保存后解析",
},
{
key: "updatedAtMs",
label: "更新时间",
width: "minmax(180px, 0.85fr)",
render: (item) => (item.updatedAtMs ? formatMillis(item.updatedAtMs) : "-"),
},
{
key: "actions",
label: "操作",
width: "minmax(96px, 0.4fr)",
render: (item) => (
<Tooltip arrow title="移除">
<span>
<IconButton
disabled={!page.abilities.canUpdate || page.saving}
label="移除"
tone="danger"
onClick={() => page.removeKeyword(item.keyword)}
>
<DeleteOutlineOutlined fontSize="small" />
</IconButton>
</span>
</Tooltip>
),
},
];
}
function KeywordCell({ item }) {
return (
<div className={styles.keywordCell}>
<span className={styles.keyword}>{item.keyword}</span>
<span className={styles.meta}>{item.enabled ? "启用" : "停用"}</span>
</div>
);
}

View File

@ -0,0 +1,11 @@
import { useAuth } from "@/app/auth/AuthProvider.jsx";
import { PERMISSIONS } from "@/app/permissions";
export function useRegionBlockAbilities() {
const { can } = useAuth();
return {
canUpdate: can(PERMISSIONS.regionBlockUpdate),
canView: can(PERMISSIONS.regionBlockView),
};
}

View File

@ -0,0 +1,41 @@
.addForm {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-3);
}
.keywordInput {
flex: 0 1 320px;
}
.keywordCell {
display: grid;
min-width: 0;
gap: 2px;
}
.keyword {
overflow: hidden;
color: var(--text-primary);
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta {
color: var(--text-tertiary);
font-size: var(--admin-font-size);
}
@media (max-width: 760px) {
.addForm {
align-items: stretch;
flex-direction: column;
}
.keywordInput {
flex: 1 1 auto;
width: 100%;
}
}

View File

@ -0,0 +1,12 @@
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
export const regionBlockRoutes = [
{
label: "地区屏蔽",
loader: () => import("./pages/RegionBlockPage.jsx").then((module) => module.RegionBlockPage),
menuCode: MENU_CODES.appUserRegionBlocks,
pageKey: "app-user-region-blocks",
path: "/app/users/region-blocks",
permission: PERMISSIONS.regionBlockView,
},
];

View File

@ -0,0 +1,13 @@
import { z } from "zod";
export const regionBlockKeywordSchema = z
.string()
.trim()
.min(1, "请输入屏蔽词")
.max(128, "屏蔽词不能超过 128 个字符");
export const regionBlockReplaceSchema = z.object({
keywords: z.array(regionBlockKeywordSchema).max(200, "最多配置 200 个屏蔽词"),
});
export type RegionBlockReplaceForm = z.infer<typeof regionBlockReplaceSchema>;

View File

@ -0,0 +1,123 @@
import SaveOutlined from "@mui/icons-material/SaveOutlined";
import Drawer from "@mui/material/Drawer";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import { Button } from "@/shared/ui/Button.jsx";
const rewardTypeOptions = [
["coin", "金币"],
["resource_group", "资源组"],
];
export function RegistrationRewardConfigDrawer({
abilities,
configLoading,
configSaving,
form,
onClose,
onSubmit,
open,
resourceGroups,
setForm,
}) {
const disabled = !abilities.canUpdate || configLoading || configSaving;
return (
<Drawer anchor="right" open={open} onClose={configSaving ? undefined : onClose}>
<form className="form-drawer" onSubmit={onSubmit}>
<h2>注册奖励配置</h2>
<section className="form-drawer__section">
<div className="form-drawer__section-title">发放状态</div>
<div className="form-drawer__grid">
<AdminSwitch
checked={form.enabled}
checkedLabel="开启"
disabled={disabled}
label="注册奖励状态"
uncheckedLabel="关闭"
onChange={(event) =>
setForm((current) => ({ ...current, enabled: event.target.checked }))
}
/>
<TextField
disabled={disabled}
inputProps={{ min: 0 }}
label="每日限制份数"
type="number"
value={form.dailyLimit}
onChange={(event) =>
setForm((current) => ({ ...current, dailyLimit: event.target.value }))
}
/>
</div>
</section>
<section className="form-drawer__section">
<div className="form-drawer__section-title">奖励内容</div>
<div className="form-drawer__grid">
<TextField
select
disabled={disabled}
label="奖励类型"
value={form.rewardType}
onChange={(event) =>
setForm((current) => ({ ...current, rewardType: event.target.value }))
}
>
{rewardTypeOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
{form.rewardType === "resource_group" ? (
<TextField
select
disabled={disabled}
label="资源组"
value={form.resourceGroupId}
onChange={(event) =>
setForm((current) => ({
...current,
resourceGroupId: event.target.value,
}))
}
>
<MenuItem value="">请选择资源组</MenuItem>
{resourceGroups.map((group) => (
<MenuItem key={group.groupId} value={String(group.groupId)}>
{group.name || group.groupCode || group.groupId}
</MenuItem>
))}
</TextField>
) : (
<TextField
disabled={disabled}
inputProps={{ min: 1 }}
label="金币数量"
type="number"
value={form.coinAmount}
onChange={(event) =>
setForm((current) => ({ ...current, coinAmount: event.target.value }))
}
/>
)}
</div>
</section>
<div className="form-drawer__actions">
<Button disabled={configSaving} type="button" onClick={onClose}>
取消
</Button>
<Button
disabled={disabled}
startIcon={<SaveOutlined fontSize="small" />}
type="submit"
variant="primary"
>
保存
</Button>
</div>
</form>
</Drawer>
);
}

View File

@ -0,0 +1,83 @@
import EditOutlined from "@mui/icons-material/EditOutlined";
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
import { Button } from "@/shared/ui/Button.jsx";
import { AdminActionIconButton } from "@/shared/ui/AdminListLayout.jsx";
import styles from "@/features/registration-reward/registration-reward.module.css";
export function RegistrationRewardConfigSummary({
canUpdate,
config,
configLoading,
onEdit,
onRefresh,
resourceGroups,
}) {
return (
<section className={styles.summaryPanel}>
<div className={styles.summaryHeader}>
<span className={styles.title}>注册奖励配置</span>
</div>
<div className={styles.summaryItems}>
<SummaryItem label="发放状态">
<ConfigStatus config={config} loading={configLoading} />
</SummaryItem>
<SummaryItem label="每日限制">{config ? formatNumber(config.dailyLimit) : configLoading ? "加载中" : "-"}</SummaryItem>
<SummaryItem label="奖励">{config ? rewardLabel(config, resourceGroups) : configLoading ? "加载中" : "-"}</SummaryItem>
</div>
<div className={styles.summaryActions}>
<AdminActionIconButton disabled={configLoading} label="刷新配置" type="button" onClick={onRefresh}>
<RefreshOutlined fontSize="small" />
</AdminActionIconButton>
<Button
disabled={!canUpdate || configLoading || !config}
startIcon={<EditOutlined fontSize="small" />}
type="button"
variant="primary"
onClick={onEdit}
>
编辑配置
</Button>
</div>
</section>
);
}
function SummaryItem({ children, label }) {
return (
<div className={styles.summaryItem}>
<span className={styles.summaryLabel}>{label}</span>
<span className={styles.summaryValue}>{children}</span>
</div>
);
}
function ConfigStatus({ config, loading }) {
if (!config) {
return (
<span className="status-badge status-badge--stopped">
<span className="status-point" />
{loading ? "加载中" : "-"}
</span>
);
}
return (
<span className={["status-badge", config.enabled ? "status-badge--running" : "status-badge--stopped"].join(" ")}>
<span className="status-point" />
{config.enabled ? "已开启" : "已关闭"}
</span>
);
}
function rewardLabel(config, resourceGroups) {
if (config.rewardType === "resource_group") {
const group = resourceGroups.find((item) => Number(item.groupId) === Number(config.resourceGroupId));
return `资源组 / ${group?.name || group?.groupCode || (config.resourceGroupId ? `#${config.resourceGroupId}` : "-")}`;
}
return `${formatNumber(config.coinAmount)} 金币`;
}
function formatNumber(value) {
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
}

View File

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

View File

@ -1,28 +1,14 @@
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
import SaveOutlined from "@mui/icons-material/SaveOutlined";
import Avatar from "@mui/material/Avatar";
import MenuItem from "@mui/material/MenuItem";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import TextField from "@mui/material/TextField";
import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import {
AdminActionIconButton,
AdminListBody,
AdminListPage,
AdminListToolbar,
} from "@/shared/ui/AdminListLayout.jsx";
import { AdminListBody, AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
import { RegistrationRewardConfigDrawer } from "@/features/registration-reward/components/RegistrationRewardConfigDrawer.jsx";
import { RegistrationRewardConfigSummary } from "@/features/registration-reward/components/RegistrationRewardConfigSummary.jsx";
import { useRegistrationRewardPage } from "@/features/registration-reward/hooks/useRegistrationRewardPage.js";
import styles from "@/features/registration-reward/registration-reward.module.css";
const rewardTypeOptions = [
["coin", "金币"],
["resource_group", "资源组"],
];
const claimStatusOptions = [
["pending", "发放中"],
["granted", "已发放"],
@ -103,117 +89,13 @@ export function RegistrationRewardPage() {
return (
<AdminListPage>
<form className={styles.configPanel} onSubmit={page.submitConfig}>
<div className={styles.configHeader}>
<div className={styles.stack}>
<span className={styles.title}>注册奖励配置</span>
<span className={styles.meta}>注册成功后由服务端自动发放</span>
</div>
<div className={styles.configActions}>
<AdminActionIconButton
disabled={page.configLoading}
label="刷新配置"
type="button"
onClick={page.reloadConfig}
>
<RefreshOutlined fontSize="small" />
</AdminActionIconButton>
<Button
disabled={!page.abilities.canUpdate || page.configLoading || page.configSaving}
startIcon={<SaveOutlined fontSize="small" />}
type="submit"
variant="primary"
>
保存
</Button>
</div>
</div>
<div className={styles.configSections}>
<section className={styles.configSection}>
<div className={styles.configSectionTitle}>发放状态</div>
<div className={styles.configGrid}>
<AdminSwitch
checked={page.form.enabled}
checkedLabel="开启"
disabled={!page.abilities.canUpdate || page.configLoading}
label="注册奖励状态"
uncheckedLabel="关闭"
onChange={(event) =>
page.setForm((current) => ({ ...current, enabled: event.target.checked }))
}
/>
<TextField
disabled={!page.abilities.canUpdate || page.configLoading}
inputProps={{ min: 0 }}
label="每日限制份数"
type="number"
value={page.form.dailyLimit}
onChange={(event) =>
page.setForm((current) => ({ ...current, dailyLimit: event.target.value }))
}
/>
</div>
</section>
<section className={styles.configSection}>
<div className={styles.configSectionTitle}>奖励内容</div>
<div className={styles.configGrid}>
<TextField
select
disabled={!page.abilities.canUpdate || page.configLoading}
label="奖励类型"
value={page.form.rewardType}
onChange={(event) =>
page.setForm((current) => ({ ...current, rewardType: event.target.value }))
}
>
{rewardTypeOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
{page.form.rewardType === "resource_group" ? (
<TextField
select
disabled={!page.abilities.canUpdate || page.configLoading}
label="资源组"
value={page.form.resourceGroupId}
onChange={(event) =>
page.setForm((current) => ({
...current,
resourceGroupId: event.target.value,
}))
}
>
<MenuItem value="">请选择资源组</MenuItem>
{page.resourceGroups.map((group) => (
<MenuItem key={group.groupId} value={String(group.groupId)}>
{group.name || group.groupCode || group.groupId}
</MenuItem>
))}
</TextField>
) : (
<TextField
disabled={!page.abilities.canUpdate || page.configLoading}
inputProps={{ min: 1 }}
label="金币数量"
type="number"
value={page.form.coinAmount}
onChange={(event) =>
page.setForm((current) => ({ ...current, coinAmount: event.target.value }))
}
/>
)}
</div>
</section>
</div>
</form>
<AdminListToolbar
actions={
<AdminActionIconButton label="刷新记录" type="button" onClick={page.reloadClaims}>
<RefreshOutlined fontSize="small" />
</AdminActionIconButton>
}
<RegistrationRewardConfigSummary
canUpdate={page.abilities.canUpdate}
config={page.config}
configLoading={page.configLoading}
resourceGroups={page.resourceGroups}
onEdit={page.openConfigDrawer}
onRefresh={page.reloadConfig}
/>
<DataState error={page.claimsError} loading={page.claimsLoading} onRetry={page.reloadClaims}>
<AdminListBody>
@ -235,6 +117,17 @@ export function RegistrationRewardPage() {
/>
</AdminListBody>
</DataState>
<RegistrationRewardConfigDrawer
abilities={page.abilities}
configLoading={page.configLoading}
configSaving={page.configSaving}
form={page.form}
open={page.configDrawerOpen}
resourceGroups={page.resourceGroups}
setForm={page.setForm}
onClose={page.closeConfigDrawer}
onSubmit={page.submitConfig}
/>
</AdminListPage>
);
}

View File

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

View File

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

View File

@ -25,6 +25,7 @@ export interface ResourceDto {
export interface ResourcePayload {
amount: number;
animationUrl?: string;
assetUrl: string;
name: string;
previewUrl: string;
@ -34,6 +35,32 @@ export interface ResourcePayload {
status: string;
}
export interface EmojiPackDto {
appCode?: string;
resourceId: number;
resourceCode?: string;
name?: string;
status?: string;
category?: string;
pricingType?: string;
coverUrl?: string;
animationUrl?: string;
regionIds?: number[];
sortOrder?: number;
createdAtMs?: number;
updatedAtMs?: number;
}
export interface EmojiPackPayload {
animationUrl: string;
category: string;
coverUrl: string;
name: string;
pricingType: string;
regionIds: number[];
sortOrder: number;
}
export interface ResourceGroupItemDto {
groupItemId: number;
itemType?: string;
@ -124,6 +151,13 @@ export interface ResourceGrantOperatorDto {
username?: string;
}
export interface ResourceGrantUserDto {
avatar?: string;
displayUserId?: string;
userId?: number | string;
username?: string;
}
export interface ResourceGrantDto {
appCode?: string;
commandId?: string;
@ -137,7 +171,8 @@ export interface ResourceGrantDto {
operatorUserId?: number;
reason?: string;
status?: string;
targetUserId?: number;
targetUser?: ResourceGrantUserDto;
targetUserId?: number | string;
updatedAtMs?: number;
}
@ -295,6 +330,29 @@ export function disableGift(giftId: string): Promise<GiftDto> {
});
}
export function listEmojiPacks(query: PageQuery = {}): Promise<ApiPage<EmojiPackDto>> {
const endpoint = API_ENDPOINTS.listEmojiPacks;
return apiRequest<ApiPage<EmojiPackDto>>(apiEndpointPath(API_OPERATIONS.listEmojiPacks), {
method: endpoint.method,
query,
});
}
export function listEmojiPackCategories(): Promise<string[]> {
const endpoint = API_ENDPOINTS.listEmojiPackCategories;
return apiRequest<string[]>(apiEndpointPath(API_OPERATIONS.listEmojiPackCategories), {
method: endpoint.method,
});
}
export function createEmojiPack(payload: EmojiPackPayload): Promise<EmojiPackDto> {
const endpoint = API_ENDPOINTS.createEmojiPack;
return apiRequest<EmojiPackDto, EmojiPackPayload>(apiEndpointPath(API_OPERATIONS.createEmojiPack), {
body: payload,
method: endpoint.method,
});
}
export function listResourceGrants(query: PageQuery = {}): Promise<ApiPage<ResourceGrantDto>> {
const endpoint = API_ENDPOINTS.listResourceGrants;
return apiRequest<ApiPage<ResourceGrantDto>>(apiEndpointPath(API_OPERATIONS.listResourceGrants), {

View File

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

View File

@ -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(),

View File

@ -0,0 +1,320 @@
import Add from "@mui/icons-material/Add";
import ImageOutlined from "@mui/icons-material/ImageOutlined";
import Autocomplete from "@mui/material/Autocomplete";
import TextField from "@mui/material/TextField";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import {
AdminFormDialog,
AdminFormFieldGrid,
AdminFormSection,
AdminFormSwitchField,
} from "@/shared/ui/AdminFormDialog.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { AdminActionIconButton, AdminListBody, AdminListPage, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx";
import { MultiValueAutocomplete } from "@/shared/ui/MultiValueAutocomplete.jsx";
import { UploadField } from "@/shared/ui/UploadField.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
import { emojiPackPricingLabels, resourceStatusFilters } from "@/features/resources/constants.js";
import { useEmojiPackListPage } from "@/features/resources/hooks/useResourcePages.js";
import styles from "@/features/resources/resources.module.css";
const columns = [
{
key: "emoji",
label: "表情包",
width: "minmax(260px, 1.4fr)",
render: (item) => <EmojiPackIdentity item={item} />,
},
{
key: "animation",
label: "动效图",
width: "minmax(220px, 1.2fr)",
render: (item) => item.animationUrl || "-",
},
{
key: "category",
label: "分类 / 类型",
width: "minmax(150px, 0.75fr)",
render: (item) => <EmojiPackTags item={item} />,
},
{
key: "regions",
label: "区域",
width: "minmax(180px, 0.9fr)",
},
{
key: "status",
label: "状态",
width: "minmax(90px, 0.45fr)",
},
{
key: "time",
label: "更新时间",
width: "minmax(170px, 0.8fr)",
render: (item) => formatMillis(item.updatedAtMs || item.createdAtMs),
},
];
export function EmojiPackListPage() {
const page = useEmojiPackListPage();
const items = page.data.items || [];
const total = page.data.total || 0;
const regionOptions = [{ label: "全部区域", value: "0" }, ...page.regionOptions];
const regionLabelById = buildRegionLabelMap(regionOptions);
const createDisabled = !page.abilities.canCreateEmojiPack || !page.abilities.canUpload;
const tableColumns = columns.map((column) =>
column.key === "emoji"
? {
...column,
filter: createTextColumnFilter({
placeholder: "搜索表情包名称",
value: page.query,
onChange: page.changeQuery,
}),
}
: column.key === "status"
? {
...column,
filter: createOptionsColumnFilter({
options: resourceStatusFilters,
placeholder: "搜索状态",
value: page.status,
onChange: page.changeStatus,
}),
render: (item) => <EmojiPackStatusSwitch item={item} page={page} />,
}
: column.key === "regions"
? {
...column,
render: (item) => <RegionTags regionIds={item.regionIds} regionLabelById={regionLabelById} />,
}
: column,
);
return (
<AdminListPage>
<AdminListToolbar
actions={
page.abilities.canCreateEmojiPack ? (
<AdminActionIconButton label="添加表情包" primary onClick={page.openCreateEmojiPack}>
<Add fontSize="small" />
</AdminActionIconButton>
) : null
}
/>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<AdminListBody>
<DataTable
columns={tableColumns}
items={items}
minWidth="1120px"
pagination={
total > 0
? {
page: page.page,
pageSize: page.data.pageSize || 10,
total,
onPageChange: page.setPage,
}
: undefined
}
rowKey={(item) => item.resourceId}
/>
</AdminListBody>
</DataState>
<EmojiPackFormDialog
categoryOptions={mergeCategoryOptions(page.categoryOptions, page.form.category)}
disabled={createDisabled}
form={page.form}
loadingCategories={page.categoryOptionsLoading}
loading={page.loadingAction === "emoji-pack-create"}
open={page.activeAction === "create"}
regionLabelById={regionLabelById}
regionOptions={regionOptions.map((option) => option.value)}
setForm={page.setForm}
uploadDisabled={!page.abilities.canUpload}
onClose={page.closeAction}
onSubmit={page.submitEmojiPack}
/>
</AdminListPage>
);
}
function EmojiPackFormDialog({
categoryOptions,
disabled,
form,
loadingCategories,
loading,
onClose,
onSubmit,
open,
regionLabelById,
regionOptions,
setForm,
uploadDisabled,
}) {
return (
<AdminFormDialog
loading={loading}
open={open}
size="large"
submitDisabled={disabled || loading}
title="添加表情包"
onClose={onClose}
onSubmit={onSubmit}
>
<AdminFormSection title="基础信息">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<TextField
disabled={disabled}
label="名称"
required
value={form.name}
onChange={(event) => setForm({ ...form, name: event.target.value })}
/>
<Autocomplete
freeSolo
disabled={disabled}
loading={loadingCategories}
options={categoryOptions}
value={form.category}
onChange={(_, value) => setForm({ ...form, category: normalizeCategoryInput(value) })}
onInputChange={(_, value) => setForm({ ...form, category: normalizeCategoryInput(value) })}
renderInput={(params) => <TextField {...params} label="分类" required />}
/>
<TextField
disabled={disabled}
label="排序"
type="number"
value={form.sortOrder}
onChange={(event) => setForm({ ...form, sortOrder: event.target.value })}
/>
<MultiValueAutocomplete
disabled={disabled}
label="区域"
labels={regionLabelById}
options={regionOptions}
value={form.regionIds}
onChange={(regionIds) => setForm({ ...form, regionIds: normalizeSelectedRegions(regionIds) })}
/>
<AdminFormSwitchField
checked={form.pricingType === "free"}
checkedLabel="免费"
disabled={disabled}
label="付费类型"
uncheckedLabel="付费"
onChange={(checked) => setForm({ ...form, pricingType: checked ? "free" : "paid" })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="素材">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<UploadField
disabled={disabled || uploadDisabled}
label="封面图"
value={form.coverUrl}
onChange={(coverUrl) => setForm({ ...form, coverUrl })}
/>
<UploadField
disabled={disabled || uploadDisabled}
label="动效图"
value={form.animationUrl}
onChange={(animationUrl) => setForm({ ...form, animationUrl })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
</AdminFormDialog>
);
}
function EmojiPackTags({ item }) {
const pricingType = item.pricingType || "free";
return (
<div className="admin-tag-list">
<span className="admin-tag">{item.category || "默认"}</span>
<span className="admin-tag">{emojiPackPricingLabels[pricingType] || pricingType}</span>
</div>
);
}
function EmojiPackStatusSwitch({ item, page }) {
const checked = item.status === "active";
return (
<AdminSwitch
checked={checked}
disabled={!page.abilities.canUpdate || page.loadingAction === `emoji-pack-status-${item.resourceId}`}
inputProps={{ "aria-label": checked ? "禁用表情包" : "启用表情包" }}
onChange={(event) => page.toggleEmojiPack(item, event.target.checked)}
/>
);
}
function EmojiPackIdentity({ item }) {
const preview = imageURL(item.coverUrl);
return (
<div className={styles.identity}>
<span className={styles.thumb}>
{preview ? <img src={preview} alt="" /> : <ImageOutlined fontSize="small" />}
</span>
<div className={styles.identityText}>
<span className={styles.name}>{item.name || "-"}</span>
<span className={styles.meta}>{item.resourceCode || item.resourceId}</span>
</div>
</div>
);
}
function RegionTags({ regionIds = [], regionLabelById }) {
const values = regionIds.length ? regionIds.map(String) : ["0"];
return (
<div className="admin-tag-list">
{values.map((value) => (
<span className="admin-tag" key={value}>
{regionLabelById[value] || value}
</span>
))}
</div>
);
}
function normalizeSelectedRegions(regionIds) {
const values = (regionIds || []).map(String);
if (!values.length || values.includes("0")) {
return ["0"];
}
return values;
}
function normalizeCategoryInput(value) {
return String(value || "").trimStart();
}
function mergeCategoryOptions(options = [], value = "默认") {
const merged = new Set(["默认"]);
options.forEach((option) => {
const category = String(option || "").trim();
if (category) {
merged.add(category);
}
});
const category = String(value || "").trim();
if (category) {
merged.add(category);
}
return Array.from(merged).sort((left, right) => left.localeCompare(right, "zh-Hans-CN"));
}
function buildRegionLabelMap(regionOptions) {
return Object.fromEntries(regionOptions.map((option) => [String(option.value), option.label]));
}
function imageURL(value) {
const url = String(value || "").trim();
if (!url) {
return "";
}
return /\.(avif|gif|jpe?g|png|webp)(\?|#|$)/i.test(url) ? url : "";
}

View File

@ -35,9 +35,9 @@ const grantColumns = [
},
{
key: "target",
label: "用户 ID",
width: "minmax(120px, 0.7fr)",
render: (grant) => grant.targetUserId || "-",
label: "用户信息",
width: "minmax(240px, 1.1fr)",
render: (grant) => <GrantTargetUser grant={grant} />,
},
{
key: "operator",
@ -293,6 +293,26 @@ function GrantItems({ grant }) {
);
}
function GrantTargetUser({ grant }) {
const user = grant.targetUser || grant.target_user || grant.user || {};
const userId = user.userId || user.user_id || grant.targetUserId;
const displayUserId = user.displayUserId || user.display_user_id;
const username = user.username || "";
const name = username || (displayUserId ? `用户 ${displayUserId}` : userId ? `用户 ${userId}` : "-");
return (
<div className={styles.identity}>
<span className={styles.operatorAvatar}>
{user.avatar ? <img alt="" src={user.avatar} /> : <PersonOutlineOutlined fontSize="small" />}
</span>
<span className={styles.identityText}>
<span className={styles.name}>{name}</span>
<span className={styles.meta}>长ID {userId || "-"}</span>
{displayUserId ? <span className={styles.meta}>短ID {displayUserId}</span> : null}
</span>
</div>
);
}
function GrantOperator({ grant }) {
const operator = grant.operator || {};
const source = operator.source || grant.grantSource || "";

View File

@ -23,7 +23,6 @@ import {
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
import {
grantStrategyLabels,
resourceStatusFilters,
resourceTypeFilters,
resourceTypeLabels,
@ -31,7 +30,7 @@ import {
import { useResourceListPage } from "@/features/resources/hooks/useResourcePages.js";
import styles from "@/features/resources/resources.module.css";
const resourceTypeOptions = resourceTypeFilters.filter(([value]) => value);
const resourceTypeOptions = resourceTypeFilters.filter(([value]) => value && value !== "emoji_pack");
const baseColumns = [
{
@ -46,18 +45,6 @@ const baseColumns = [
width: "minmax(120px, 0.7fr)",
render: (resource) => resourceTypeLabels[resource.resourceType] || resource.resourceType || "-",
},
{
key: "grant",
label: "发放规则",
width: "minmax(170px, 1fr)",
render: (resource) => <GrantInfo resource={resource} />,
},
{
key: "scopes",
label: "使用范围",
width: "minmax(180px, 1fr)",
render: (resource) => <TagList values={resource.usageScopes} />,
},
{
key: "status",
label: "状态",
@ -102,22 +89,22 @@ export function ResourceListPage() {
}),
}
: column.key === "status"
? {
...column,
filter: createOptionsColumnFilter({
options: resourceStatusFilters,
placeholder: "搜索状态",
value: page.status,
onChange: page.changeStatus,
}),
render: (resource) => <ResourceStatusSwitch page={page} resource={resource} />,
}
: column.key === "actions"
? {
...column,
render: (resource) => <ResourceRowActions page={page} resource={resource} />,
}
: column,
? {
...column,
filter: createOptionsColumnFilter({
options: resourceStatusFilters,
placeholder: "搜索状态",
value: page.status,
onChange: page.changeStatus,
}),
render: (resource) => <ResourceStatusSwitch page={page} resource={resource} />,
}
: column.key === "actions"
? {
...column,
render: (resource) => <ResourceRowActions page={page} resource={resource} />,
}
: column,
);
return (
@ -136,7 +123,7 @@ export function ResourceListPage() {
<DataTable
columns={columns}
items={items}
minWidth="1120px"
minWidth="820px"
pagination={
total > 0
? {
@ -217,7 +204,9 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
required
select
value={form.resourceType}
onChange={(event) => setForm({ ...form, resourceType: event.target.value, walletAssetAmount: "" })}
onChange={(event) =>
setForm({ ...form, resourceType: event.target.value, walletAssetAmount: "" })
}
>
{resourceTypeOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
@ -241,15 +230,17 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<UploadField
disabled={disabled || uploadDisabled}
kind="file"
label="资源封面"
value={form.previewUrl}
onChange={(previewUrl) => setForm({ ...form, previewUrl })}
/>
<UploadField
disabled={disabled || uploadDisabled}
label="资源素材"
value={form.assetUrl}
onChange={(assetUrl) => setForm({ ...form, assetUrl })}
kind="file"
label="动效素材"
value={form.animationUrl}
onChange={(animationUrl) => setForm({ ...form, animationUrl })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
@ -282,21 +273,6 @@ function ResourceIdentity({ resource }) {
);
}
function GrantInfo({ resource }) {
const strategy = grantStrategyLabels[resource.grantStrategy] || resource.grantStrategy || "-";
if (resource.walletAssetType || resource.walletAssetAmount) {
return (
<div className={styles.stack}>
<span>{strategy}</span>
<span className={styles.meta}>
{resource.walletAssetType || "-"} {formatNumber(resource.walletAssetAmount)}
</span>
</div>
);
}
return strategy;
}
function ResourceStatusSwitch({ page, resource }) {
const checked = resource.status === "active";
return (
@ -309,22 +285,6 @@ function ResourceStatusSwitch({ page, resource }) {
);
}
function TagList({ values = [] }) {
const items = values.filter(Boolean);
if (!items.length) {
return "-";
}
return (
<div className="admin-tag-list">
{items.map((value) => (
<span className="admin-tag" key={value}>
{value}
</span>
))}
</div>
);
}
function imageURL(value) {
const url = String(value || "").trim();
if (!url) {
@ -332,7 +292,3 @@ function imageURL(value) {
}
return /\.(avif|gif|jpe?g|png|webp)(\?|#|$)/i.test(url) ? url : "";
}
function formatNumber(value) {
return Number(value || 0).toLocaleString("zh-CN");
}

View File

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

View File

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

View File

@ -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(),

View File

@ -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",

View File

@ -11,6 +11,7 @@ export function RoomDetailDrawer({ onClose, open, room }) {
const owner = room?.owner || {};
const normalizedStatus = room?.status === "active" ? "active" : "closed";
const ownerDisplayId = ownerLongShortId(owner, room);
const closeOperator = room?.closedByAdminName || (room?.closedByAdminId ? String(room.closedByAdminId) : "");
return (
<SideDrawer open={open} title="房间详情" width="wide" onClose={onClose}>
@ -42,6 +43,17 @@ export function RoomDetailDrawer({ onClose, open, room }) {
title="基础信息"
/>
{normalizedStatus === "closed" ? (
<DetailSection
items={[
["操作人", closeOperator],
["操作时间", formatMillis(room.closedAtMs)],
["关闭原因", room.closeReason]
]}
title="关闭信息"
/>
) : null}
<section className={styles.detailSection}>
<h3 className={styles.detailSectionTitle}>房主信息</h3>
<div className={styles.detailOwner}>

View File

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

View File

@ -5,6 +5,7 @@ import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import { AdminFormDialog, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { IconButton } from "@/shared/ui/IconButton.jsx";
@ -53,7 +54,7 @@ const columns = [
key: "status",
label: "状态",
width: "minmax(100px, 0.7fr)",
render: (room) => <RoomStatus status={room.status} />,
render: (room) => roomStatusLabels[room.status === "active" ? "active" : "closed"],
},
{
key: "time",
@ -98,7 +99,7 @@ export function RoomListPage() {
value: page.status,
onChange: page.changeStatus,
}),
render: (room) => <RoomStatus status={room.status} />,
render: (room) => <RoomStatusSwitch page={page} room={room} />,
}
: column.key === "roomContribution"
? {
@ -188,6 +189,26 @@ export function RoomListPage() {
/>
</ActionModal>
<ActionModal
disabled={!page.abilities.canUpdate}
loading={page.loadingAction === `status-${page.activeRoom?.roomId}`}
open={page.activeAction === "status"}
sectionTitle="关闭信息"
title="关闭房间"
onClose={page.closeAction}
onSubmit={page.submitStatus}
>
<TextField
disabled={!page.abilities.canUpdate}
label="关闭原因"
multiline
minRows={3}
required
value={page.statusForm.closeReason}
onChange={(event) => page.setStatusForm({ ...page.statusForm, closeReason: event.target.value })}
/>
</ActionModal>
</section>
);
}
@ -268,14 +289,19 @@ function RoomActions({ page, room }) {
);
}
function RoomStatus({ status }) {
const normalizedStatus = status === "active" ? "active" : "closed";
const tone = normalizedStatus === "active" ? "running" : "stopped";
function RoomStatusSwitch({ page, room }) {
const checked = room.status === "active";
return (
<span className={`status-badge status-badge--${tone}`}>
<span className="status-point" />
{roomStatusLabels[normalizedStatus]}
</span>
<div className={styles.statusCell}>
<AdminSwitch
checked={checked}
checkedLabel="正常"
disabled={!page.abilities.canUpdate || page.loadingAction === `status-${room.roomId}`}
inputProps={{ "aria-label": checked ? "关闭房间" : "恢复房间" }}
uncheckedLabel="关闭"
onChange={(event) => page.toggleRoomStatus(room, event.target.checked)}
/>
</div>
);
}

View File

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

View File

@ -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({

View File

@ -27,14 +27,17 @@ export const API_OPERATIONS = {
cancelRoomPin: "cancelRoomPin",
changePassword: "changePassword",
closeAgency: "closeAgency",
createAchievementDefinition: "createAchievementDefinition",
createAgency: "createAgency",
createAppVersion: "createAppVersion",
createBanner: "createBanner",
createBD: "createBD",
createBDLeader: "createBDLeader",
createCatalog: "createCatalog",
createCoinAdjustment: "createCoinAdjustment",
createCoinSeller: "createCoinSeller",
createCountry: "createCountry",
createEmojiPack: "createEmojiPack",
createGift: "createGift",
createMenu: "createMenu",
createPermission: "createPermission",
@ -46,6 +49,7 @@ export const API_OPERATIONS = {
createRole: "createRole",
createRoomPin: "createRoomPin",
createTaskDefinition: "createTaskDefinition",
createTier: "createTier",
createUser: "createUser",
createUserExportJob: "createUserExportJob",
creditCoinSellerStock: "creditCoinSellerStock",
@ -83,6 +87,7 @@ export const API_OPERATIONS = {
getUser: "getUser",
grantResource: "grantResource",
grantResourceGroup: "grantResourceGroup",
listAchievementDefinitions: "listAchievementDefinitions",
listAgencies: "listAgencies",
listApps: "listApps",
listAppVersions: "listAppVersions",
@ -90,19 +95,24 @@ export const API_OPERATIONS = {
listBDLeaders: "listBDLeaders",
listBDs: "listBDs",
listCatalog: "listCatalog",
listCoinAdjustments: "listCoinAdjustments",
listCoinLedger: "listCoinLedger",
listCoinSellers: "listCoinSellers",
listCountries: "listCountries",
listEmojiPackCategories: "listEmojiPackCategories",
listEmojiPacks: "listEmojiPacks",
listGifts: "listGifts",
listH5Links: "listH5Links",
listHosts: "listHosts",
listJobs: "listJobs",
listLevelConfig: "listLevelConfig",
listLoginLogs: "listLoginLogs",
listOperationLogs: "listOperationLogs",
listPermissions: "listPermissions",
listPlatforms: "listPlatforms",
listRechargeBills: "listRechargeBills",
listRechargeProducts: "listRechargeProducts",
listRegionBlocks: "listRegionBlocks",
listRegions: "listRegions",
listRegistrationRewardClaims: "listRegistrationRewardClaims",
listResourceGrants: "listResourceGrants",
@ -116,9 +126,11 @@ export const API_OPERATIONS = {
listUsers: "listUsers",
login: "login",
logout: "logout",
lookupCoinAdjustmentTarget: "lookupCoinAdjustmentTarget",
me: "me",
navigationMenus: "navigationMenus",
refresh: "refresh",
replaceRegionBlocks: "replaceRegionBlocks",
replaceRegionCountries: "replaceRegionCountries",
replaceRoleDataScopes: "replaceRoleDataScopes",
replaceRolePermissions: "replaceRolePermissions",
@ -132,6 +144,7 @@ export const API_OPERATIONS = {
setTaskDefinitionStatus: "setTaskDefinitionStatus",
sortMenus: "sortMenus",
syncPermissions: "syncPermissions",
updateAchievementDefinition: "updateAchievementDefinition",
updateAppVersion: "updateAppVersion",
updateBanner: "updateBanner",
updateCatalog: "updateCatalog",
@ -152,10 +165,13 @@ export const API_OPERATIONS = {
updateRoom: "updateRoom",
updateRoomConfig: "updateRoomConfig",
updateTaskDefinition: "updateTaskDefinition",
updateTier: "updateTier",
updateTrack: "updateTrack",
updateUser: "updateUser",
updateUserStatus: "updateUserStatus",
uploadFile: "uploadFile",
uploadImage: "uploadImage"
uploadImage: "uploadImage",
upsertRule: "upsertRule"
} as const;
export type ApiOperationId = (typeof API_OPERATIONS)[keyof typeof API_OPERATIONS];
@ -257,6 +273,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "agency:status",
permissions: ["agency:status"]
},
createAchievementDefinition: {
method: "POST",
operationId: API_OPERATIONS.createAchievementDefinition,
path: "/v1/admin/activity/achievements",
permission: "achievement:create",
permissions: ["achievement:create"]
},
createAgency: {
method: "POST",
operationId: API_OPERATIONS.createAgency,
@ -299,6 +322,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "game:create",
permissions: ["game:create"]
},
createCoinAdjustment: {
method: "POST",
operationId: API_OPERATIONS.createCoinAdjustment,
path: "/v1/admin/operations/coin-adjustments",
permission: "coin-adjustment:create",
permissions: ["coin-adjustment:create"]
},
createCoinSeller: {
method: "POST",
operationId: API_OPERATIONS.createCoinSeller,
@ -313,6 +343,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "country:create",
permissions: ["country:create"]
},
createEmojiPack: {
method: "POST",
operationId: API_OPERATIONS.createEmojiPack,
path: "/v1/admin/emoji-packs",
permission: "emoji-pack:create",
permissions: ["emoji-pack:create"]
},
createGift: {
method: "POST",
operationId: API_OPERATIONS.createGift,
@ -390,6 +427,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "daily-task:create",
permissions: ["daily-task:create"]
},
createTier: {
method: "POST",
operationId: API_OPERATIONS.createTier,
path: "/v1/admin/users/level-config/tiers",
permission: "level-config:update",
permissions: ["level-config:update"]
},
createUser: {
method: "POST",
operationId: API_OPERATIONS.createUser,
@ -649,6 +693,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "resource-grant:create",
permissions: ["resource-grant:create"]
},
listAchievementDefinitions: {
method: "GET",
operationId: API_OPERATIONS.listAchievementDefinitions,
path: "/v1/admin/activity/achievements",
permission: "achievement:view",
permissions: ["achievement:view"]
},
listAgencies: {
method: "GET",
operationId: API_OPERATIONS.listAgencies,
@ -696,6 +747,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "game:view",
permissions: ["game:view"]
},
listCoinAdjustments: {
method: "GET",
operationId: API_OPERATIONS.listCoinAdjustments,
path: "/v1/admin/operations/coin-adjustments",
permission: "coin-adjustment:view",
permissions: ["coin-adjustment:view"]
},
listCoinLedger: {
method: "GET",
operationId: API_OPERATIONS.listCoinLedger,
@ -717,6 +775,20 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "country:view",
permissions: ["country:view"]
},
listEmojiPackCategories: {
method: "GET",
operationId: API_OPERATIONS.listEmojiPackCategories,
path: "/v1/admin/emoji-packs/categories",
permission: "emoji-pack:view",
permissions: ["emoji-pack:view"]
},
listEmojiPacks: {
method: "GET",
operationId: API_OPERATIONS.listEmojiPacks,
path: "/v1/admin/emoji-packs",
permission: "emoji-pack:view",
permissions: ["emoji-pack:view"]
},
listGifts: {
method: "GET",
operationId: API_OPERATIONS.listGifts,
@ -745,6 +817,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "job:view",
permissions: ["job:view"]
},
listLevelConfig: {
method: "GET",
operationId: API_OPERATIONS.listLevelConfig,
path: "/v1/admin/users/level-config",
permission: "level-config:view",
permissions: ["level-config:view"]
},
listLoginLogs: {
method: "GET",
operationId: API_OPERATIONS.listLoginLogs,
@ -787,6 +866,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "payment-product:view",
permissions: ["payment-product:view"]
},
listRegionBlocks: {
method: "GET",
operationId: API_OPERATIONS.listRegionBlocks,
path: "/v1/admin/users/region-blocks",
permission: "region-block:view",
permissions: ["region-block:view"]
},
listRegions: {
method: "GET",
operationId: API_OPERATIONS.listRegions,
@ -874,6 +960,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
operationId: API_OPERATIONS.logout,
path: "/v1/auth/logout"
},
lookupCoinAdjustmentTarget: {
method: "GET",
operationId: API_OPERATIONS.lookupCoinAdjustmentTarget,
path: "/v1/admin/operations/coin-adjustments/target",
permission: "coin-adjustment:create",
permissions: ["coin-adjustment:create"]
},
me: {
method: "GET",
operationId: API_OPERATIONS.me,
@ -889,6 +982,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
operationId: API_OPERATIONS.refresh,
path: "/v1/auth/refresh"
},
replaceRegionBlocks: {
method: "PUT",
operationId: API_OPERATIONS.replaceRegionBlocks,
path: "/v1/admin/users/region-blocks",
permission: "region-block:update",
permissions: ["region-block:update"]
},
replaceRegionCountries: {
method: "PUT",
operationId: API_OPERATIONS.replaceRegionCountries,
@ -978,6 +1078,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "permission:sync",
permissions: ["permission:sync"]
},
updateAchievementDefinition: {
method: "PUT",
operationId: API_OPERATIONS.updateAchievementDefinition,
path: "/v1/admin/activity/achievements/{achievement_id}",
permission: "achievement:update",
permissions: ["achievement:update"]
},
updateAppVersion: {
method: "PUT",
operationId: API_OPERATIONS.updateAppVersion,
@ -1118,6 +1225,20 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "daily-task:update",
permissions: ["daily-task:update"]
},
updateTier: {
method: "PUT",
operationId: API_OPERATIONS.updateTier,
path: "/v1/admin/users/level-config/tiers/{tier_id}",
permission: "level-config:update",
permissions: ["level-config:update"]
},
updateTrack: {
method: "PUT",
operationId: API_OPERATIONS.updateTrack,
path: "/v1/admin/users/level-config/tracks/{track}",
permission: "level-config:update",
permissions: ["level-config:update"]
},
updateUser: {
method: "PATCH",
operationId: API_OPERATIONS.updateUser,
@ -1145,6 +1266,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
path: "/v1/admin/files/image/upload",
permission: "upload:create",
permissions: ["upload:create"]
},
upsertRule: {
method: "PUT",
operationId: API_OPERATIONS.upsertRule,
path: "/v1/admin/users/level-config/rules/{track}/{level}",
permission: "level-config:update",
permissions: ["level-config:update"]
}
};

View File

@ -4,6 +4,38 @@
*/
export interface paths {
"/admin/activity/achievements": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listAchievementDefinitions"];
put?: never;
post: operations["createAchievementDefinition"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/activity/achievements/{achievement_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put: operations["updateAchievementDefinition"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/activity/daily-tasks": {
parameters: {
query?: never;
@ -420,6 +452,38 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/emoji-packs": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listEmojiPacks"];
put?: never;
post: operations["createEmojiPack"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/emoji-packs/categories": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listEmojiPackCategories"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/files/image/upload": {
parameters: {
query?: never;
@ -612,6 +676,38 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/operations/coin-adjustments": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listCoinAdjustments"];
put?: never;
post: operations["createCoinAdjustment"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/operations/coin-adjustments/target": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["lookupCoinAdjustmentTarget"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/operations/coin-ledger": {
parameters: {
query?: never;
@ -1012,6 +1108,102 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/users/level-config": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listLevelConfig"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/users/level-config/rules/{track}/{level}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put: operations["upsertRule"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/users/level-config/tiers": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["createTier"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/users/level-config/tiers/{tier_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put: operations["updateTier"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/users/level-config/tracks/{track}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put: operations["updateTrack"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/users/region-blocks": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listRegionBlocks"];
put: operations["replaceRegionBlocks"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/app/users": {
parameters: {
query?: never;
@ -2102,6 +2294,44 @@ export interface components {
}
export type $defs = Record<string, never>;
export interface operations {
listAchievementDefinitions: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
createAchievementDefinition: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
updateAchievementDefinition: {
parameters: {
query?: never;
header?: never;
path: {
achievement_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
listTaskDefinitions: {
parameters: {
query?: never;
@ -2638,6 +2868,42 @@ export interface operations {
200: components["responses"]["EmptyResponse"];
};
};
listEmojiPacks: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
createEmojiPack: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
listEmojiPackCategories: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
uploadImage: {
parameters: {
query?: never;
@ -2837,6 +3103,42 @@ export interface operations {
200: components["responses"]["HostProfilePageResponse"];
};
};
listCoinAdjustments: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
createCoinAdjustment: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
lookupCoinAdjustmentTarget: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
listCoinLedger: {
parameters: {
query?: never;
@ -3319,6 +3621,97 @@ export interface operations {
200: components["responses"]["EmptyResponse"];
};
};
listLevelConfig: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
upsertRule: {
parameters: {
query?: never;
header?: never;
path: {
track: number;
level: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
createTier: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
updateTier: {
parameters: {
query?: never;
header?: never;
path: {
tier_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
updateTrack: {
parameters: {
query?: never;
header?: never;
path: {
track: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
listRegionBlocks: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
replaceRegionBlocks: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
appListUsers: {
parameters: {
query?: never;

View File

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

View File

@ -1,4 +1,5 @@
import Refresh from "@mui/icons-material/Refresh";
import Skeleton from "@mui/material/Skeleton";
import { Button } from "./Button.jsx";
import { usePageLoadingStatus } from "./PageLoadBoundary.jsx";
@ -8,7 +9,7 @@ export function DataState({ error, loading, children, onRetry, reportPageLoading
if (loading) {
return (
<div className="data-state data-state--loading">
<div className="page-skeleton__block" />
<ContentSkeleton />
</div>
);
}
@ -29,3 +30,33 @@ export function DataState({ error, loading, children, onRetry, reportPageLoading
return children;
}
function ContentSkeleton() {
return (
<div className="data-state__skeleton" aria-hidden="true">
<div className="data-state__skeleton-toolbar">
<div className="data-state__skeleton-filters">
<Skeleton animation="wave" className="data-state__skeleton-filter" variant="rounded" />
<Skeleton animation="wave" className="data-state__skeleton-filter" variant="rounded" />
<Skeleton animation="wave" className="data-state__skeleton-reset" variant="rounded" />
</div>
<div className="data-state__skeleton-actions">
<Skeleton animation="wave" className="data-state__skeleton-action" variant="rounded" />
<Skeleton animation="wave" className="data-state__skeleton-action" variant="rounded" />
</div>
</div>
<div className="data-state__skeleton-table">
<Skeleton animation="wave" className="data-state__skeleton-head" variant="rounded" />
{Array.from({ length: 7 }, (_, index) => (
<div className="data-state__skeleton-row" key={index}>
<Skeleton animation="wave" className="data-state__skeleton-cell data-state__skeleton-cell--main" variant="rounded" />
<Skeleton animation="wave" className="data-state__skeleton-cell" variant="rounded" />
<Skeleton animation="wave" className="data-state__skeleton-cell" variant="rounded" />
<Skeleton animation="wave" className="data-state__skeleton-cell data-state__skeleton-cell--short" variant="rounded" />
<Skeleton animation="wave" className="data-state__skeleton-cell data-state__skeleton-cell--action" variant="rounded" />
</div>
))}
</div>
</div>
);
}

View File

@ -19,6 +19,7 @@ export function UploadField({
kind = "image",
label,
onChange,
uploadKind,
value = ""
}) {
const inputId = useId();
@ -27,10 +28,12 @@ export function UploadField({
const [uploading, setUploading] = useState(false);
const [localPreview, setLocalPreview] = useState(null);
const isImage = kind === "image";
const uploadMode = uploadKind || kind;
const useImageUpload = uploadMode === "image";
const source = localPreview?.url || value;
const assetKind = localPreview?.assetKind || getAssetKind(value, kind);
const displayName = localPreview?.name || getDisplayValue(value);
const inputAccept = accept || (isImage ? imageAccept : undefined);
const inputAccept = accept !== undefined ? accept : useImageUpload && isImage ? imageAccept : undefined;
useEffect(() => {
return () => {
@ -65,7 +68,7 @@ export function UploadField({
});
setUploading(true);
try {
const result = isImage ? await uploadImage(file) : await uploadFile(file);
const result = useImageUpload ? await uploadImage(file) : await uploadFile(file);
onChange(result.url);
setLocalPreview(null);
showToast("上传成功", "success");
@ -146,7 +149,7 @@ function AssetPreview({ assetKind, isImage, src }) {
if (assetKind === "pag") {
return <PAGAssetPreview src={src} />;
}
if (isImage) {
if (assetKind === "image" || isImage) {
return <RasterAssetPreview src={src} />;
}
return (
@ -328,6 +331,9 @@ function getAssetKind(value, kind, contentType = "") {
if (extension === "pag") {
return "pag";
}
if (isRasterImageExtension(extension)) {
return "image";
}
const normalizedType = String(contentType || "").toLowerCase();
if (normalizedType.includes("svga")) {
return "svga";
@ -335,9 +341,20 @@ function getAssetKind(value, kind, contentType = "") {
if (normalizedType.includes("pag")) {
return "pag";
}
if (isRasterImageContentType(normalizedType)) {
return "image";
}
return kind === "image" ? "image" : "file";
}
function isRasterImageExtension(extension) {
return ["avif", "gif", "jpg", "jpeg", "png", "webp"].includes(extension);
}
function isRasterImageContentType(contentType) {
return ["image/avif", "image/gif", "image/jpeg", "image/png", "image/webp"].includes(contentType);
}
function getExtension(value) {
if (!value) {
return "";
@ -359,6 +376,9 @@ function safePathname(value) {
}
function assetKindLabel(assetKind, isImage) {
if (assetKind === "image") {
return "图片";
}
if (assetKind === "svga") {
return "SVGA";
}

View File

@ -0,0 +1,32 @@
import { fireEvent, render, waitFor } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { uploadFile, uploadImage } from "@/shared/api/upload";
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
import { UploadField } from "./UploadField.jsx";
vi.mock("@/shared/api/upload", () => ({
uploadFile: vi.fn(async () => ({ url: "https://media.haiyihy.com/admin/files/effect.bin" })),
uploadImage: vi.fn(async () => ({ url: "https://media.haiyihy.com/admin/images/effect.png" }))
}));
test("file upload field does not restrict picker type and uses generic upload endpoint", async () => {
const onChange = vi.fn();
const { container } = render(
<ToastProvider>
<UploadField kind="file" label="动效素材" value="" onChange={onChange} />
</ToastProvider>
);
const input = container.querySelector('input[type="file"]');
expect(input).not.toHaveAttribute("accept");
fireEvent.change(input, {
target: {
files: [new File(["effect"], "effect.bin", { type: "application/octet-stream" })]
}
});
await waitFor(() => expect(uploadFile).toHaveBeenCalledTimes(1));
expect(uploadImage).not.toHaveBeenCalled();
expect(onChange).toHaveBeenCalledWith("https://media.haiyihy.com/admin/files/effect.bin");
});

View File

@ -1029,11 +1029,93 @@
}
.data-state--loading {
display: flex;
align-items: stretch;
justify-content: stretch;
padding: var(--space-4);
}
.data-state--loading .page-skeleton__block {
min-height: 220px;
.data-state__skeleton {
display: grid;
width: 100%;
min-height: 360px;
grid-template-rows: auto minmax(0, 1fr);
gap: var(--space-4);
}
.data-state__skeleton-toolbar {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
}
.data-state__skeleton-filters,
.data-state__skeleton-actions {
display: flex;
min-width: 0;
align-items: center;
flex-wrap: wrap;
gap: var(--space-3);
}
.data-state__skeleton-actions {
flex: 0 0 auto;
}
.data-state__skeleton-filter {
width: 150px;
height: var(--control-height);
}
.data-state__skeleton-reset,
.data-state__skeleton-action {
width: 84px;
height: var(--control-height);
}
.data-state__skeleton-table {
display: grid;
min-height: 0;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-card);
background: var(--bg-card);
}
.data-state__skeleton-head {
height: var(--table-head-height);
border-radius: 0;
background-color: var(--bg-card-strong);
}
.data-state__skeleton-row {
display: grid;
min-height: var(--table-row-height);
align-items: center;
grid-template-columns: minmax(180px, 1.35fr) minmax(100px, 0.7fr) minmax(140px, 0.95fr) minmax(90px, 0.55fr) minmax(54px, 0.35fr);
gap: var(--space-4);
padding: 0 var(--space-4);
border-top: 1px solid var(--row-border);
}
.data-state__skeleton-cell {
width: min(100%, 150px);
height: 18px;
}
.data-state__skeleton-cell--main {
width: min(100%, 220px);
}
.data-state__skeleton-cell--short {
width: 72px;
}
.data-state__skeleton-cell--action {
width: 34px;
height: 28px;
}
.data-state__title {

1
var/admin-dev.pid Normal file
View File

@ -0,0 +1 @@
99664