语音房宝箱
This commit is contained in:
parent
7c527ee791
commit
90bf486385
@ -33,7 +33,7 @@
|
|||||||
- CSS token:`src/styles/tokens.css`。
|
- CSS token:`src/styles/tokens.css`。
|
||||||
- 样式入口:`src/styles/app.css`,实际样式按 `src/styles/*.css`、`src/features/*/*.css` 和 `*.module.css` 拆分。
|
- 样式入口:`src/styles/app.css`,实际样式按 `src/styles/*.css`、`src/features/*/*.css` 和 `*.module.css` 拆分。
|
||||||
- 服务端状态:使用 `@tanstack/react-query`,共享入口在 `src/shared/query/` 和 `src/shared/hooks/useAdminQuery.js`。
|
- 服务端状态:使用 `@tanstack/react-query`,共享入口在 `src/shared/query/` 和 `src/shared/hooks/useAdminQuery.js`。
|
||||||
- 接口契约:`pnpm gen:api` 会先从 `../hyapp-server/server/admin` 路由同步 `contracts/admin-openapi.json`,再生成 `src/shared/api/generated/schema.d.ts` 和 `src/shared/api/generated/endpoints.ts`。
|
- 接口契约:`contracts/admin-openapi.json` 是本仓库的前端 API 契约源;`pnpm gen:api` 只基于该契约生成 `src/shared/api/generated/schema.d.ts` 和 `src/shared/api/generated/endpoints.ts`。
|
||||||
|
|
||||||
## 目录规范
|
## 目录规范
|
||||||
|
|
||||||
@ -41,10 +41,10 @@
|
|||||||
- `src/shared/`:跨业务共享的 API 基础层、UI 组件、hooks、工具和图表封装。
|
- `src/shared/`:跨业务共享的 API 基础层、UI 组件、hooks、工具和图表封装。
|
||||||
- `src/features/<module>/`:业务模块,内部放 `api.ts`、`routes.js`、`permissions.js`、`schema.ts`、`hooks/`、`components/`、`pages/`。
|
- `src/features/<module>/`:业务模块,内部放 `api.ts`、`routes.js`、`permissions.js`、`schema.ts`、`hooks/`、`components/`、`pages/`。
|
||||||
- 新增后台业务模块必须优先落在 `src/features/`,不要重新创建横向 `pages/`、`api/`、`components/` 目录。
|
- 新增后台业务模块必须优先落在 `src/features/`,不要重新创建横向 `pages/`、`api/`、`components/` 目录。
|
||||||
- 新增模块优先复制 `admin-module.example.json`,改成业务配置后运行 `pnpm gen:module <config.json>`;它会生成 feature、权限常量、路由接入、OpenAPI 契约和后端 seed/routes 片段。
|
- 新增模块优先复制 `admin-module.example.json`,改成业务配置后运行 `pnpm gen:module <config.json>`;它会生成 feature、权限常量、路由接入和 OpenAPI 契约。
|
||||||
- 只需要预览生成内容时运行 `pnpm gen:module <config.json> --dry-run`。
|
- 只需要预览生成内容时运行 `pnpm gen:module <config.json> --dry-run`。
|
||||||
- `pnpm scaffold:feature <feature-name>` 是低层级骨架命令,只有在不需要权限/菜单/契约自动接入时使用。
|
- `pnpm scaffold:feature <feature-name>` 是低层级骨架命令,只有在不需要权限/菜单/契约自动接入时使用。
|
||||||
- 后端路由或权限变更后先运行 `pnpm gen:api`,只需要同步契约时运行 `pnpm sync:contract`。
|
- API 契约或权限变更后运行 `pnpm gen:api`。
|
||||||
- 前端 API 路径必须从 `src/shared/api/generated/endpoints.ts` 取,不要在 feature API 中重新手写路径。
|
- 前端 API 路径必须从 `src/shared/api/generated/endpoints.ts` 取,不要在 feature API 中重新手写路径。
|
||||||
- 表单提交前必须走模块内 `schema.ts` 或共享 schema 校验。
|
- 表单提交前必须走模块内 `schema.ts` 或共享 schema 校验。
|
||||||
|
|
||||||
|
|||||||
@ -214,6 +214,32 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/admin/activity/room-treasure/config": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "getRoomTreasureConfig",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "room-treasure:view",
|
||||||
|
"x-permissions": [
|
||||||
|
"room-treasure:view"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"put": {
|
||||||
|
"operationId": "updateRoomTreasureConfig",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "room-treasure:update",
|
||||||
|
"x-permissions": [
|
||||||
|
"room-treasure:update"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/admin/activity/seven-day-checkin/claims": {
|
"/admin/activity/seven-day-checkin/claims": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listSevenDayCheckInClaims",
|
"operationId": "listSevenDayCheckInClaims",
|
||||||
@ -2349,72 +2375,6 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/exports/users": {
|
|
||||||
"post": {
|
|
||||||
"operationId": "createUserExportJob",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"$ref": "#/components/responses/EmptyResponse"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"x-permission": "export:create",
|
|
||||||
"x-permissions": [
|
|
||||||
"export:create"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/jobs": {
|
|
||||||
"get": {
|
|
||||||
"operationId": "listJobs",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"$ref": "#/components/responses/EmptyResponse"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"x-permission": "job:view",
|
|
||||||
"x-permissions": [
|
|
||||||
"job:view"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/jobs/{id}/artifact": {
|
|
||||||
"get": {
|
|
||||||
"operationId": "downloadJobArtifact",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"$ref": "#/components/responses/EmptyResponse"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"$ref": "#/components/parameters/Id"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"x-permission": "job:view",
|
|
||||||
"x-permissions": [
|
|
||||||
"job:view"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/jobs/{id}/cancel": {
|
|
||||||
"post": {
|
|
||||||
"operationId": "cancelJob",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"$ref": "#/components/responses/EmptyResponse"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"$ref": "#/components/parameters/Id"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"x-permission": "job:cancel",
|
|
||||||
"x-permissions": [
|
|
||||||
"job:cancel"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/logs/login": {
|
"/logs/login": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listLoginLogs",
|
"operationId": "listLoginLogs",
|
||||||
|
|||||||
@ -8,12 +8,11 @@
|
|||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"check:contracts": "tsx scripts/validate-admin-architecture.ts",
|
"check:contracts": "tsx scripts/validate-admin-architecture.ts",
|
||||||
"gen:api": "node scripts/sync-openapi-from-backend.mjs && openapi-typescript contracts/admin-openapi.json -o src/shared/api/generated/schema.d.ts && node scripts/generate-api-client.mjs",
|
"gen:api": "openapi-typescript contracts/admin-openapi.json -o src/shared/api/generated/schema.d.ts && node scripts/generate-api-client.mjs",
|
||||||
"gen:module": "node scripts/generate-admin-module.mjs",
|
"gen:module": "node scripts/generate-admin-module.mjs",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"scaffold:feature": "node scripts/scaffold-feature.mjs",
|
"scaffold:feature": "node scripts/scaffold-feature.mjs",
|
||||||
"sync:contract": "node scripts/sync-openapi-from-backend.mjs",
|
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"preview": "vite preview --host 0.0.0.0"
|
"preview": "vite preview --host 0.0.0.0"
|
||||||
|
|||||||
@ -48,7 +48,7 @@ if (!skipApiGenerate) {
|
|||||||
run(process.execPath, ["scripts/generate-api-client.mjs"]);
|
run(process.execPath, ["scripts/generate-api-client.mjs"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Module ${moduleConfig.name} generated. Run pnpm check:contracts after backend routes are implemented.`);
|
console.log(`Module ${moduleConfig.name} generated. Run pnpm check:contracts after updating the local API contract.`);
|
||||||
|
|
||||||
function normalizeConfig(config) {
|
function normalizeConfig(config) {
|
||||||
const name = toKebabCase(required(config.name, "name"));
|
const name = toKebabCase(required(config.name, "name"));
|
||||||
@ -119,8 +119,6 @@ function queueFileWrites(config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const generatedDir = path.join(rootDir, "generated/admin-modules", config.name);
|
const generatedDir = path.join(rootDir, "generated/admin-modules", config.name);
|
||||||
planWrite(path.join(generatedDir, "backend-seed-snippet.go.txt"), renderBackendSeedSnippet(config), { overwrite: true });
|
|
||||||
planWrite(path.join(generatedDir, "backend-routes-snippet.go.txt"), renderBackendRoutesSnippet(config), { overwrite: true });
|
|
||||||
planWrite(path.join(generatedDir, "permissions.md"), renderPermissionChecklist(config), { overwrite: true });
|
planWrite(path.join(generatedDir, "permissions.md"), renderPermissionChecklist(config), { overwrite: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -351,29 +349,6 @@ function renderCss() {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderBackendSeedSnippet(config) {
|
|
||||||
const permissions = config.permissions
|
|
||||||
.map((permission) => `\t{Name: "${permission.name}", Code: "${permission.code}", Kind: "${permission.kind}"},`)
|
|
||||||
.join("\n");
|
|
||||||
return `// Add to hyapp-server/server/admin/internal/repository/seed.go defaultPermissions.
|
|
||||||
${permissions}
|
|
||||||
|
|
||||||
// Add to defaultMenus.
|
|
||||||
{Title: "${config.label}", Code: "${config.menuCode}", Path: "${config.routePath}", Icon: "${config.icon}", PermissionCode: "${config.viewPermission.code}", Sort: ${config.sort}, Visible: true},
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderBackendRoutesSnippet(config) {
|
|
||||||
const lines = config.apiRoutes.map((route) => {
|
|
||||||
const permissionGuard = route.permission
|
|
||||||
? `, middleware.RequirePermission("${route.permission}")`
|
|
||||||
: "";
|
|
||||||
return `protected.${route.method}("${toGinPath(route.path)}"${permissionGuard}, h.${route.handler})`;
|
|
||||||
});
|
|
||||||
return `// Add to the module RegisterRoutes function.
|
|
||||||
${lines.join("\n")}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPermissionChecklist(config) {
|
function renderPermissionChecklist(config) {
|
||||||
const rows = config.permissions
|
const rows = config.permissions
|
||||||
@ -390,7 +365,7 @@ function renderPermissionChecklist(config) {
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
${rows}
|
${rows}
|
||||||
|
|
||||||
## 后端路由
|
## API 路由
|
||||||
|
|
||||||
| Method | Path | Permission | Handler |
|
| Method | Path | Permission | Handler |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
@ -527,9 +502,6 @@ function toOpenApiPath(routePath) {
|
|||||||
return routePath.replace(/:([A-Za-z0-9_]+)/g, "{$1}");
|
return routePath.replace(/:([A-Za-z0-9_]+)/g, "{$1}");
|
||||||
}
|
}
|
||||||
|
|
||||||
function toGinPath(routePath) {
|
|
||||||
return routePath.replace(/\{([^}]+)\}/g, ":$1");
|
|
||||||
}
|
|
||||||
|
|
||||||
function toKebabCase(value) {
|
function toKebabCase(value) {
|
||||||
return value
|
return value
|
||||||
|
|||||||
@ -1,151 +0,0 @@
|
|||||||
import { readdir, readFile, writeFile } from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
||||||
const defaultBackendDir = path.resolve(rootDir, "../hyapp-server/server/admin");
|
|
||||||
const backendDir = process.env.HYAPP_ADMIN_SERVER_DIR
|
|
||||||
? path.resolve(process.env.HYAPP_ADMIN_SERVER_DIR)
|
|
||||||
: defaultBackendDir;
|
|
||||||
const contractPath = path.join(rootDir, "contracts/admin-openapi.json");
|
|
||||||
const methods = ["get", "post", "put", "patch", "delete"];
|
|
||||||
|
|
||||||
const contract = JSON.parse(await readFile(contractPath, "utf8"));
|
|
||||||
const routes = await readBackendRoutes(path.join(backendDir, "internal/modules"));
|
|
||||||
|
|
||||||
for (const route of routes) {
|
|
||||||
contract.paths[route.path] = contract.paths[route.path] || {};
|
|
||||||
const operation = contract.paths[route.path][route.method] || {};
|
|
||||||
operation.operationId = route.operationId;
|
|
||||||
operation.responses = operation.responses || { "200": { "$ref": "#/components/responses/EmptyResponse" } };
|
|
||||||
|
|
||||||
const pathParams = [...route.path.matchAll(/\{([^}]+)\}/g)].map((match) => match[1]);
|
|
||||||
if (pathParams.length && !operation.parameters) {
|
|
||||||
operation.parameters = pathParams.map((name) =>
|
|
||||||
name === "id"
|
|
||||||
? { "$ref": "#/components/parameters/Id" }
|
|
||||||
: {
|
|
||||||
in: "path",
|
|
||||||
name,
|
|
||||||
required: true,
|
|
||||||
schema: { type: "integer" }
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (route.permissions.length) {
|
|
||||||
operation["x-permission"] = operation["x-permission"] || route.permissions[0];
|
|
||||||
operation["x-permissions"] = route.permissions;
|
|
||||||
}
|
|
||||||
|
|
||||||
contract.paths[route.path][route.method] = operation;
|
|
||||||
}
|
|
||||||
|
|
||||||
contract.paths = sortObject(contract.paths, (pathItem) => sortObject(pathItem, undefined, methods));
|
|
||||||
|
|
||||||
await writeFile(contractPath, `${JSON.stringify(contract, null, 2)}\n`);
|
|
||||||
console.log(`Synced ${routes.length} backend routes into ${path.relative(rootDir, contractPath)}`);
|
|
||||||
|
|
||||||
async function readBackendRoutes(modulesDir) {
|
|
||||||
const modules = await readdir(modulesDir, { withFileTypes: true });
|
|
||||||
const routes = [];
|
|
||||||
|
|
||||||
for (const moduleEntry of modules.filter((entry) => entry.isDirectory())) {
|
|
||||||
const moduleDir = path.join(modulesDir, moduleEntry.name);
|
|
||||||
const files = await readdir(moduleDir, { withFileTypes: true });
|
|
||||||
for (const fileEntry of files.filter((entry) => entry.isFile() && entry.name.endsWith(".go"))) {
|
|
||||||
const source = await readFile(path.join(moduleDir, fileEntry.name), "utf8");
|
|
||||||
if (!source.includes("RegisterRoutes")) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
routes.push(...parseRoutes(source, moduleEntry.name));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return routes.sort((left, right) => `${left.path}:${left.method}`.localeCompare(`${right.path}:${right.method}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseRoutes(source, moduleName) {
|
|
||||||
const groupPrefixes = new Map([
|
|
||||||
["api", ""],
|
|
||||||
["protected", ""]
|
|
||||||
]);
|
|
||||||
const routes = [];
|
|
||||||
|
|
||||||
for (const line of source.split("\n")) {
|
|
||||||
const groupMatch = line.match(/^\s*(\w+)\s*:=\s*(\w+)\.Group\("([^"]+)"\)/);
|
|
||||||
if (groupMatch) {
|
|
||||||
const [, groupName, parentName, prefix] = groupMatch;
|
|
||||||
groupPrefixes.set(groupName, `${groupPrefixes.get(parentName) || ""}${prefix}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const routeMatch = line.match(/^\s*(\w+)\.(GET|POST|PUT|PATCH|DELETE)\("([^"]+)"(?:,\s*(.*))?\)/);
|
|
||||||
if (!routeMatch) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [, groupName, method, routePath, args = ""] = routeMatch;
|
|
||||||
const handlerMatch = args.match(/h\.([A-Za-z0-9_]+)/g);
|
|
||||||
const handlerName = handlerMatch?.at(-1)?.replace("h.", "") || `${method}${routePath}`;
|
|
||||||
const permissions = readPermissions(args);
|
|
||||||
|
|
||||||
routes.push({
|
|
||||||
method: method.toLowerCase(),
|
|
||||||
operationId: operationIdForHandler(moduleName, handlerName, routePath, method),
|
|
||||||
path: toOpenApiPath(`${groupPrefixes.get(groupName) || ""}${routePath}`),
|
|
||||||
permissions
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return routes;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readPermissions(args) {
|
|
||||||
const anyMatch = args.match(/RequireAnyPermission\(([^)]*)\)/);
|
|
||||||
if (anyMatch) {
|
|
||||||
return [...anyMatch[1].matchAll(/"([^"]+)"/g)].map((match) => match[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const match = args.match(/RequirePermission\("([^"]+)"\)/);
|
|
||||||
return match ? [match[1]] : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function toOpenApiPath(routePath) {
|
|
||||||
return routePath.replace(/:([A-Za-z0-9_]+)/g, "{$1}");
|
|
||||||
}
|
|
||||||
|
|
||||||
function lowerFirst(value) {
|
|
||||||
return value.charAt(0).toLowerCase() + value.slice(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function operationIdForHandler(moduleName, handlerName, routePath, method) {
|
|
||||||
if (moduleName === "appuser") {
|
|
||||||
return `app${handlerName}`;
|
|
||||||
}
|
|
||||||
if (moduleName === "countryregion" && handlerName === "DisableCountry" && method === "DELETE") {
|
|
||||||
return "deleteCountry";
|
|
||||||
}
|
|
||||||
if (moduleName === "hostorg" && handlerName === "SetBDStatus" && routePath.includes("bd-leaders")) {
|
|
||||||
return "setBDLeaderStatus";
|
|
||||||
}
|
|
||||||
if (moduleName === "resource" && handlerName === "UpdateResourceGroup" && routePath.includes("/items")) {
|
|
||||||
return "updateResourceGroupItems";
|
|
||||||
}
|
|
||||||
return lowerFirst(handlerName);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortObject(object, mapValue = (value) => value, preferredOrder = []) {
|
|
||||||
return Object.fromEntries(
|
|
||||||
Object.entries(object)
|
|
||||||
.sort(([left], [right]) => {
|
|
||||||
const leftIndex = preferredOrder.indexOf(left);
|
|
||||||
const rightIndex = preferredOrder.indexOf(right);
|
|
||||||
if (leftIndex >= 0 || rightIndex >= 0) {
|
|
||||||
return (leftIndex < 0 ? Number.MAX_SAFE_INTEGER : leftIndex) - (rightIndex < 0 ? Number.MAX_SAFE_INTEGER : rightIndex);
|
|
||||||
}
|
|
||||||
return left.localeCompare(right);
|
|
||||||
})
|
|
||||||
.map(([key, value]) => [key, mapValue(value)])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -172,9 +172,9 @@ export function Header({ activeLabel, isSidebarCollapsed, menuItems = [], onRefr
|
|||||||
</TextField>
|
</TextField>
|
||||||
<TextField
|
<TextField
|
||||||
className="timezone-switch"
|
className="timezone-switch"
|
||||||
inputProps={{ "aria-label": "显示时区" }}
|
|
||||||
select
|
select
|
||||||
size="small"
|
size="small"
|
||||||
|
slotProps={{ htmlInput: { "aria-label": "显示时区" } }}
|
||||||
value={timeZone}
|
value={timeZone}
|
||||||
onChange={(event) => setTimeZone(event.target.value)}
|
onChange={(event) => setTimeZone(event.target.value)}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -106,10 +106,9 @@ export const PERMISSIONS = {
|
|||||||
sevenDayCheckInUpdate: "seven-day-checkin:update",
|
sevenDayCheckInUpdate: "seven-day-checkin:update",
|
||||||
luckyGiftView: "lucky-gift:view",
|
luckyGiftView: "lucky-gift:view",
|
||||||
luckyGiftUpdate: "lucky-gift:update",
|
luckyGiftUpdate: "lucky-gift:update",
|
||||||
|
roomTreasureView: "room-treasure:view",
|
||||||
|
roomTreasureUpdate: "room-treasure:update",
|
||||||
uploadCreate: "upload:create",
|
uploadCreate: "upload:create",
|
||||||
jobView: "job:view",
|
|
||||||
jobCancel: "job:cancel",
|
|
||||||
exportCreate: "export:create",
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type PermissionCode = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
|
export type PermissionCode = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
|
||||||
@ -154,6 +153,7 @@ export const MENU_CODES = {
|
|||||||
registrationReward: "registration-reward",
|
registrationReward: "registration-reward",
|
||||||
achievementConfig: "achievement-config",
|
achievementConfig: "achievement-config",
|
||||||
sevenDayCheckIn: "seven-day-checkin",
|
sevenDayCheckIn: "seven-day-checkin",
|
||||||
|
roomTreasure: "room-treasure",
|
||||||
geo: "geo",
|
geo: "geo",
|
||||||
hostOrg: "host-org",
|
hostOrg: "host-org",
|
||||||
hostOrgCountries: "host-org-countries",
|
hostOrgCountries: "host-org-countries",
|
||||||
@ -169,7 +169,6 @@ export const MENU_CODES = {
|
|||||||
paymentRechargeProducts: "payment-recharge-products",
|
paymentRechargeProducts: "payment-recharge-products",
|
||||||
games: "games",
|
games: "games",
|
||||||
gameList: "game-list",
|
gameList: "game-list",
|
||||||
jobs: "jobs",
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type MenuCode = (typeof MENU_CODES)[keyof typeof MENU_CODES];
|
export type MenuCode = (typeof MENU_CODES)[keyof typeof MENU_CODES];
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import { regionBlockRoutes } from "@/features/region-blocks/routes.js";
|
|||||||
import { resourceRoutes } from "@/features/resources/routes.js";
|
import { resourceRoutes } from "@/features/resources/routes.js";
|
||||||
import { rolesRoutes } from "@/features/roles/routes.js";
|
import { rolesRoutes } from "@/features/roles/routes.js";
|
||||||
import { roomRoutes } from "@/features/rooms/routes.js";
|
import { roomRoutes } from "@/features/rooms/routes.js";
|
||||||
|
import { roomTreasureRoutes } from "@/features/room-treasure/routes.js";
|
||||||
import { sevenDayCheckInRoutes } from "@/features/seven-day-checkin/routes.js";
|
import { sevenDayCheckInRoutes } from "@/features/seven-day-checkin/routes.js";
|
||||||
import { usersRoutes } from "@/features/users/routes.js";
|
import { usersRoutes } from "@/features/users/routes.js";
|
||||||
import type { MenuCode } from "@/app/permissions";
|
import type { MenuCode } from "@/app/permissions";
|
||||||
@ -35,6 +36,7 @@ export const adminRoutes: AdminRoute[] = [
|
|||||||
...achievementRoutes,
|
...achievementRoutes,
|
||||||
...registrationRewardRoutes,
|
...registrationRewardRoutes,
|
||||||
...sevenDayCheckInRoutes,
|
...sevenDayCheckInRoutes,
|
||||||
|
...roomTreasureRoutes,
|
||||||
...resourceRoutes,
|
...resourceRoutes,
|
||||||
...operationsRoutes,
|
...operationsRoutes,
|
||||||
...luckyGiftRoutes,
|
...luckyGiftRoutes,
|
||||||
|
|||||||
@ -142,6 +142,19 @@ export function useRechargeProductsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleProductEnabled = async (item, enabled) => {
|
||||||
|
setLoadingAction(`status:${item.productId}`);
|
||||||
|
try {
|
||||||
|
await updateRechargeProduct(item.productId, productPayloadFromItem(item, enabled));
|
||||||
|
await result.reload();
|
||||||
|
showToast(enabled ? "内购配置已上架" : "内购配置已下架", "success");
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "状态更新失败", "error");
|
||||||
|
} finally {
|
||||||
|
setLoadingAction("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
abilities,
|
abilities,
|
||||||
activeAction,
|
activeAction,
|
||||||
@ -172,6 +185,7 @@ export function useRechargeProductsPage() {
|
|||||||
setStatus,
|
setStatus,
|
||||||
status,
|
status,
|
||||||
submitProduct,
|
submitProduct,
|
||||||
|
toggleProductEnabled,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -195,3 +209,23 @@ function formFromProduct(item) {
|
|||||||
regionIds: (item.regionIds || []).map(String),
|
regionIds: (item.regionIds || []).map(String),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function productPayloadFromItem(item, enabled) {
|
||||||
|
return {
|
||||||
|
amountUsdt: item.amountUsdt || formatMicroAmount(item.amountUsdtMicro),
|
||||||
|
coinAmount: item.coinAmount,
|
||||||
|
description: item.description || "",
|
||||||
|
enabled,
|
||||||
|
platform: item.platform === "ios" ? "ios" : "android",
|
||||||
|
productName: item.productName || "",
|
||||||
|
regionIds: item.regionIds || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMicroAmount(value) {
|
||||||
|
const numberValue = Number(value || 0);
|
||||||
|
if (!Number.isFinite(numberValue) || numberValue <= 0) {
|
||||||
|
return "0";
|
||||||
|
}
|
||||||
|
return String(numberValue / 1_000_000).replace(/\.?0+$/, "");
|
||||||
|
}
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import {
|
|||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
import { MultiValueAutocomplete } from "@/shared/ui/MultiValueAutocomplete.jsx";
|
import { MultiValueAutocomplete } from "@/shared/ui/MultiValueAutocomplete.jsx";
|
||||||
|
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||||
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
|
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
|
||||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
@ -219,7 +220,7 @@ function productColumns(page, regionLabels) {
|
|||||||
{
|
{
|
||||||
key: "status",
|
key: "status",
|
||||||
label: "状态",
|
label: "状态",
|
||||||
render: (item) => <StatusBadge enabled={item.enabled} />,
|
render: (item) => <ProductStatusSwitch item={item} page={page} />,
|
||||||
filter: createOptionsColumnFilter({
|
filter: createOptionsColumnFilter({
|
||||||
options: statusOptions,
|
options: statusOptions,
|
||||||
placeholder: "搜索状态",
|
placeholder: "搜索状态",
|
||||||
@ -257,6 +258,21 @@ function productColumns(page, regionLabels) {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ProductStatusSwitch({ item, page }) {
|
||||||
|
const checked = Boolean(item.enabled);
|
||||||
|
const disabled = !page.abilities.canUpdateProduct || page.loadingAction === `status:${item.productId}`;
|
||||||
|
return (
|
||||||
|
<AdminSwitch
|
||||||
|
checked={checked}
|
||||||
|
checkedLabel="上架"
|
||||||
|
disabled={disabled}
|
||||||
|
inputProps={{ "aria-label": checked ? "下架内购" : "上架内购" }}
|
||||||
|
uncheckedLabel="下架"
|
||||||
|
onChange={(event) => page.toggleProductEnabled(item, event.target.checked)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ProductActions({ item, page }) {
|
function ProductActions({ item, page }) {
|
||||||
const deleting = page.loadingAction === `delete:${item.productId}`;
|
const deleting = page.loadingAction === `delete:${item.productId}`;
|
||||||
return (
|
return (
|
||||||
@ -292,16 +308,6 @@ function Stack({ primary, secondary }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusBadge({ enabled }) {
|
|
||||||
const tone = enabled ? "succeeded" : "stopped";
|
|
||||||
return (
|
|
||||||
<span className={`status-badge status-badge--${tone}`}>
|
|
||||||
<span className="status-point" />
|
|
||||||
{enabled ? "上架" : "下架"}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function platformLabel(value) {
|
function platformLabel(value) {
|
||||||
if (value === "android") {
|
if (value === "android") {
|
||||||
return "Android";
|
return "Android";
|
||||||
@ -314,7 +320,7 @@ function platformLabel(value) {
|
|||||||
|
|
||||||
function regionLabelMap(options) {
|
function regionLabelMap(options) {
|
||||||
return options.reduce((labels, option) => {
|
return options.reduce((labels, option) => {
|
||||||
labels[String(option.value)] = option.label;
|
labels[String(option.value)] = option.name || option.label;
|
||||||
return labels;
|
return labels;
|
||||||
}, {});
|
}, {});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,7 +30,6 @@ const permissionGroupDefinitions = [
|
|||||||
{ key: "hosts", prefixes: ["host"], title: "主播列表" },
|
{ key: "hosts", prefixes: ["host"], title: "主播列表" },
|
||||||
{ key: "coin-sellers", prefixes: ["coin-seller"], title: "币商列表" },
|
{ key: "coin-sellers", prefixes: ["coin-seller"], title: "币商列表" },
|
||||||
{ key: "logs", prefixes: ["log"], title: "日志审计" },
|
{ key: "logs", prefixes: ["log"], title: "日志审计" },
|
||||||
{ key: "jobs", prefixes: ["job", "export"], title: "任务中心" },
|
|
||||||
{ key: "common", prefixes: ["upload"], title: "通用能力" },
|
{ key: "common", prefixes: ["upload"], title: "通用能力" },
|
||||||
];
|
];
|
||||||
const fallbackPermissionGroup = { key: "other", title: "其他权限" };
|
const fallbackPermissionGroup = { key: "other", title: "其他权限" };
|
||||||
|
|||||||
188
src/features/room-treasure/api.ts
Normal file
188
src/features/room-treasure/api.ts
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||||
|
import { apiRequest } from "@/shared/api/request";
|
||||||
|
|
||||||
|
export interface RoomTreasureRewardItemDto {
|
||||||
|
rewardItemId: string;
|
||||||
|
resourceGroupId: number;
|
||||||
|
weight: number;
|
||||||
|
displayName?: string;
|
||||||
|
iconUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoomTreasureLevelDto {
|
||||||
|
level: number;
|
||||||
|
energyThreshold: number;
|
||||||
|
coverUrl?: string;
|
||||||
|
animationUrl?: string;
|
||||||
|
openingAnimationUrl?: string;
|
||||||
|
openedImageUrl?: string;
|
||||||
|
inRoomRewards: RoomTreasureRewardItemDto[];
|
||||||
|
top1Rewards: RoomTreasureRewardItemDto[];
|
||||||
|
igniterRewards: RoomTreasureRewardItemDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GiftEnergyRuleDto {
|
||||||
|
ruleId: string;
|
||||||
|
giftId?: string;
|
||||||
|
giftTypeCode?: string;
|
||||||
|
multiplierPpm: number;
|
||||||
|
fixedEnergy: number;
|
||||||
|
excluded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoomTreasureConfigDto {
|
||||||
|
appCode?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
configVersion: number;
|
||||||
|
energySource: string;
|
||||||
|
openDelayMs: number;
|
||||||
|
broadcastEnabled: boolean;
|
||||||
|
broadcastScope: string;
|
||||||
|
broadcastDelayMs: number;
|
||||||
|
rewardStackPolicy: string;
|
||||||
|
levels: RoomTreasureLevelDto[];
|
||||||
|
giftEnergyRules: GiftEnergyRuleDto[];
|
||||||
|
updatedByAdminId?: number;
|
||||||
|
createdAtMs?: number;
|
||||||
|
updatedAtMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RoomTreasureConfigPayload = Pick<
|
||||||
|
RoomTreasureConfigDto,
|
||||||
|
| "broadcastDelayMs"
|
||||||
|
| "broadcastEnabled"
|
||||||
|
| "broadcastScope"
|
||||||
|
| "enabled"
|
||||||
|
| "energySource"
|
||||||
|
| "giftEnergyRules"
|
||||||
|
| "levels"
|
||||||
|
| "openDelayMs"
|
||||||
|
| "rewardStackPolicy"
|
||||||
|
>;
|
||||||
|
|
||||||
|
type RawReward = RoomTreasureRewardItemDto & Record<string, unknown>;
|
||||||
|
type RawLevel = Omit<
|
||||||
|
RoomTreasureLevelDto,
|
||||||
|
"igniterRewards" | "inRoomRewards" | "top1Rewards"
|
||||||
|
> & {
|
||||||
|
igniterRewards?: RawReward[];
|
||||||
|
igniter_rewards?: RawReward[];
|
||||||
|
inRoomRewards?: RawReward[];
|
||||||
|
in_room_rewards?: RawReward[];
|
||||||
|
top1Rewards?: RawReward[];
|
||||||
|
top1_rewards?: RawReward[];
|
||||||
|
} & Record<string, unknown>;
|
||||||
|
type RawGiftEnergyRule = GiftEnergyRuleDto & Record<string, unknown>;
|
||||||
|
type RawConfig = Omit<RoomTreasureConfigDto, "giftEnergyRules" | "levels"> & {
|
||||||
|
giftEnergyRules?: RawGiftEnergyRule[];
|
||||||
|
gift_energy_rules?: RawGiftEnergyRule[];
|
||||||
|
levels?: RawLevel[];
|
||||||
|
} & Record<string, unknown>;
|
||||||
|
|
||||||
|
export function getRoomTreasureConfig(): Promise<RoomTreasureConfigDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.getRoomTreasureConfig;
|
||||||
|
return apiRequest<RawConfig>(apiEndpointPath(API_OPERATIONS.getRoomTreasureConfig), {
|
||||||
|
method: endpoint.method,
|
||||||
|
}).then(normalizeConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateRoomTreasureConfig(payload: RoomTreasureConfigPayload): Promise<RoomTreasureConfigDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.updateRoomTreasureConfig;
|
||||||
|
return apiRequest<RawConfig, RoomTreasureConfigPayload>(apiEndpointPath(API_OPERATIONS.updateRoomTreasureConfig), {
|
||||||
|
body: payload,
|
||||||
|
method: endpoint.method,
|
||||||
|
}).then(normalizeConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeConfig(item: RawConfig): RoomTreasureConfigDto {
|
||||||
|
return {
|
||||||
|
appCode: stringValue(item.appCode ?? item.app_code),
|
||||||
|
enabled: Boolean(item.enabled),
|
||||||
|
configVersion: numberValue(item.configVersion ?? item.config_version),
|
||||||
|
energySource: stringValue(item.energySource ?? item.energy_source) || "gift_point_added",
|
||||||
|
openDelayMs: numberValue(item.openDelayMs ?? item.open_delay_ms),
|
||||||
|
broadcastEnabled: Boolean(item.broadcastEnabled ?? item.broadcast_enabled),
|
||||||
|
broadcastScope: stringValue(item.broadcastScope ?? item.broadcast_scope) || "region",
|
||||||
|
broadcastDelayMs: numberValue(item.broadcastDelayMs ?? item.broadcast_delay_ms),
|
||||||
|
rewardStackPolicy: stringValue(item.rewardStackPolicy ?? item.reward_stack_policy) || "allow_stack",
|
||||||
|
levels: normalizeLevels(item.levels),
|
||||||
|
giftEnergyRules: normalizeGiftEnergyRules(item.giftEnergyRules ?? item.gift_energy_rules),
|
||||||
|
updatedByAdminId: numberValue(item.updatedByAdminId ?? item.updated_by_admin_id),
|
||||||
|
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
||||||
|
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLevels(levels?: RawLevel[]): RoomTreasureLevelDto[] {
|
||||||
|
const byLevel = new Map<number, RoomTreasureLevelDto>();
|
||||||
|
for (const level of levels || []) {
|
||||||
|
const levelNo = numberValue(level.level);
|
||||||
|
if (levelNo < 1 || levelNo > 7) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
byLevel.set(levelNo, {
|
||||||
|
level: levelNo,
|
||||||
|
energyThreshold: numberValue(level.energyThreshold ?? level.energy_threshold),
|
||||||
|
coverUrl: stringValue(level.coverUrl ?? level.cover_url),
|
||||||
|
animationUrl: stringValue(level.animationUrl ?? level.animation_url),
|
||||||
|
openingAnimationUrl: stringValue(level.openingAnimationUrl ?? level.opening_animation_url),
|
||||||
|
openedImageUrl: stringValue(level.openedImageUrl ?? level.opened_image_url),
|
||||||
|
inRoomRewards: normalizeRewards(level.inRoomRewards ?? level.in_room_rewards),
|
||||||
|
top1Rewards: normalizeRewards(level.top1Rewards ?? level.top1_rewards),
|
||||||
|
igniterRewards: normalizeRewards(level.igniterRewards ?? level.igniter_rewards),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Array.from({ length: 7 }, (_, index) => {
|
||||||
|
const level = index + 1;
|
||||||
|
return byLevel.get(level) || defaultLevel(level);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultLevel(level: number): RoomTreasureLevelDto {
|
||||||
|
const thresholds = [1000, 3000, 6000, 10000, 15000, 21000, 28000];
|
||||||
|
return {
|
||||||
|
level,
|
||||||
|
energyThreshold: thresholds[level - 1] || 0,
|
||||||
|
coverUrl: "",
|
||||||
|
animationUrl: "",
|
||||||
|
openingAnimationUrl: "",
|
||||||
|
openedImageUrl: "",
|
||||||
|
inRoomRewards: [],
|
||||||
|
top1Rewards: [],
|
||||||
|
igniterRewards: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRewards(rewards?: RawReward[]): RoomTreasureRewardItemDto[] {
|
||||||
|
return (rewards || [])
|
||||||
|
.map((reward, index) => ({
|
||||||
|
rewardItemId: stringValue(reward.rewardItemId ?? reward.reward_item_id) || `reward_${index + 1}`,
|
||||||
|
resourceGroupId: numberValue(reward.resourceGroupId ?? reward.resource_group_id),
|
||||||
|
weight: numberValue(reward.weight),
|
||||||
|
displayName: stringValue(reward.displayName ?? reward.display_name),
|
||||||
|
iconUrl: stringValue(reward.iconUrl ?? reward.icon_url),
|
||||||
|
}))
|
||||||
|
.filter((reward) => reward.resourceGroupId > 0 || reward.weight > 0 || reward.displayName || reward.iconUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGiftEnergyRules(rules?: RawGiftEnergyRule[]): GiftEnergyRuleDto[] {
|
||||||
|
return (rules || [])
|
||||||
|
.map((rule, index) => ({
|
||||||
|
ruleId: stringValue(rule.ruleId ?? rule.rule_id) || `gift_energy_rule_${index + 1}`,
|
||||||
|
giftId: stringValue(rule.giftId ?? rule.gift_id),
|
||||||
|
giftTypeCode: stringValue(rule.giftTypeCode ?? rule.gift_type_code),
|
||||||
|
multiplierPpm: numberValue(rule.multiplierPpm ?? rule.multiplier_ppm),
|
||||||
|
fixedEnergy: numberValue(rule.fixedEnergy ?? rule.fixed_energy),
|
||||||
|
excluded: Boolean(rule.excluded),
|
||||||
|
}))
|
||||||
|
.filter((rule) => rule.giftId || rule.giftTypeCode || rule.multiplierPpm > 0 || rule.fixedEnergy > 0 || rule.excluded);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value: unknown) {
|
||||||
|
return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberValue(value: unknown) {
|
||||||
|
const number = Number(value || 0);
|
||||||
|
return Number.isFinite(number) ? number : 0;
|
||||||
|
}
|
||||||
@ -0,0 +1,540 @@
|
|||||||
|
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||||
|
import ArrowBackOutlined from "@mui/icons-material/ArrowBackOutlined";
|
||||||
|
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||||
|
import SaveOutlined from "@mui/icons-material/SaveOutlined";
|
||||||
|
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 Tooltip from "@mui/material/Tooltip";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||||
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
|
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
||||||
|
import { ResourceGroupSelectField } from "@/shared/ui/ResourceGroupSelectDrawer.jsx";
|
||||||
|
import { UploadField } from "@/shared/ui/UploadField.jsx";
|
||||||
|
import styles from "@/features/room-treasure/room-treasure.module.css";
|
||||||
|
|
||||||
|
const energySourceOptions = [
|
||||||
|
["gift_point_added", "送礼积分"],
|
||||||
|
["heat_value", "礼物热度"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const broadcastScopeOptions = [
|
||||||
|
["region", "区域广播"],
|
||||||
|
["global", "全局广播"],
|
||||||
|
["none", "不广播"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const rewardStackPolicyOptions = [
|
||||||
|
["allow_stack", "允许叠加"],
|
||||||
|
["priority_only", "优先级单份"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const rewardTabs = [
|
||||||
|
["inRoomRewards", "在房奖励"],
|
||||||
|
["top1Rewards", "贡献第一"],
|
||||||
|
["igniterRewards", "点火人"],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function RoomTreasureConfigEditor({
|
||||||
|
abilities,
|
||||||
|
configLoading,
|
||||||
|
configSaving,
|
||||||
|
form,
|
||||||
|
onCancel,
|
||||||
|
onSubmit,
|
||||||
|
resourceGroups,
|
||||||
|
setForm,
|
||||||
|
}) {
|
||||||
|
const disabled = !abilities.canUpdate || configLoading || configSaving;
|
||||||
|
const [activeLevel, setActiveLevel] = useState(1);
|
||||||
|
const [rewardTab, setRewardTab] = useState("inRoomRewards");
|
||||||
|
const [editorTab, setEditorTab] = useState("levels");
|
||||||
|
const activeLevelIndex = useMemo(
|
||||||
|
() => Math.max(0, form.levels.findIndex((level) => Number(level.level) === Number(activeLevel))),
|
||||||
|
[activeLevel, form.levels],
|
||||||
|
);
|
||||||
|
const level = form.levels[activeLevelIndex] || form.levels[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className={styles.editorPage} onSubmit={onSubmit}>
|
||||||
|
<div className={styles.editorActionBar}>
|
||||||
|
<div className={styles.editorTitleGroup}>
|
||||||
|
<Button startIcon={<ArrowBackOutlined fontSize="small" />} type="button" onClick={onCancel}>
|
||||||
|
返回
|
||||||
|
</Button>
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span className={styles.title}>房间宝箱配置</span>
|
||||||
|
<span className={styles.meta}>编辑 7 级宝箱物料、奖励池和礼物能量规则</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.editorActions}>
|
||||||
|
<Button disabled={configSaving} type="button" onClick={onCancel}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button disabled={disabled} startIcon={<SaveOutlined fontSize="small" />} type="submit" variant="primary">
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.editorScrollBody}>
|
||||||
|
<section className={styles.editorSection}>
|
||||||
|
<div className={styles.sectionTitle}>基础配置</div>
|
||||||
|
<div className={styles.basicGrid}>
|
||||||
|
<SwitchField
|
||||||
|
checked={form.enabled}
|
||||||
|
disabled={disabled}
|
||||||
|
label="宝箱状态"
|
||||||
|
onChange={(event) => updateRoot(setForm, "enabled", event.target.checked)}
|
||||||
|
/>
|
||||||
|
<SwitchField
|
||||||
|
checked={form.broadcastEnabled}
|
||||||
|
disabled={disabled}
|
||||||
|
label="开箱广播"
|
||||||
|
onChange={(event) => updateRoot(setForm, "broadcastEnabled", event.target.checked)}
|
||||||
|
/>
|
||||||
|
<SelectField
|
||||||
|
disabled={disabled}
|
||||||
|
label="能量来源"
|
||||||
|
options={energySourceOptions}
|
||||||
|
value={form.energySource}
|
||||||
|
onChange={(value) => updateRoot(setForm, "energySource", value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
disabled={disabled}
|
||||||
|
inputProps={{ min: 0, step: 1000 }}
|
||||||
|
label="开箱倒计时 ms"
|
||||||
|
type="number"
|
||||||
|
value={form.openDelayMs}
|
||||||
|
onChange={(event) => updateRoot(setForm, "openDelayMs", event.target.value)}
|
||||||
|
/>
|
||||||
|
<SelectField
|
||||||
|
disabled={disabled}
|
||||||
|
label="广播范围"
|
||||||
|
options={broadcastScopeOptions}
|
||||||
|
value={form.broadcastScope}
|
||||||
|
onChange={(value) => updateRoot(setForm, "broadcastScope", value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
disabled={disabled}
|
||||||
|
inputProps={{ min: 0, step: 1000 }}
|
||||||
|
label="广播延迟 ms"
|
||||||
|
type="number"
|
||||||
|
value={form.broadcastDelayMs}
|
||||||
|
onChange={(event) => updateRoot(setForm, "broadcastDelayMs", event.target.value)}
|
||||||
|
/>
|
||||||
|
<SelectField
|
||||||
|
disabled={disabled}
|
||||||
|
label="角色奖励策略"
|
||||||
|
options={rewardStackPolicyOptions}
|
||||||
|
value={form.rewardStackPolicy}
|
||||||
|
onChange={(value) => updateRoot(setForm, "rewardStackPolicy", value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={styles.editorSection}>
|
||||||
|
<Tabs value={editorTab} onChange={(_, value) => setEditorTab(value)}>
|
||||||
|
<Tab label="等级配置" value="levels" />
|
||||||
|
<Tab label="礼物能量规则" value="energyRules" />
|
||||||
|
</Tabs>
|
||||||
|
{editorTab === "levels" ? (
|
||||||
|
<div className={styles.levelWorkspace}>
|
||||||
|
<aside className={styles.levelNav}>
|
||||||
|
{form.levels.map((item) => (
|
||||||
|
<LevelNavItem
|
||||||
|
active={Number(item.level) === Number(level?.level)}
|
||||||
|
key={item.level}
|
||||||
|
level={item}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveLevel(item.level);
|
||||||
|
setRewardTab("inRoomRewards");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</aside>
|
||||||
|
{level ? (
|
||||||
|
<div className={styles.levelDetail}>
|
||||||
|
<div className={styles.levelDetailHeader}>
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span className={styles.title}>L{level.level}</span>
|
||||||
|
<span className={styles.meta}>当前等级送礼溢出不进入下一级,倒计时期间送礼不累计能量</span>
|
||||||
|
</div>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
disabled={disabled}
|
||||||
|
inputProps={{ min: 1, step: 1 }}
|
||||||
|
label="能量阈值"
|
||||||
|
type="number"
|
||||||
|
value={level.energyThreshold}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateLevel(setForm, activeLevelIndex, { energyThreshold: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.materialGrid}>
|
||||||
|
<UploadField
|
||||||
|
disabled={disabled}
|
||||||
|
kind="image"
|
||||||
|
label="封面图"
|
||||||
|
value={level.coverUrl}
|
||||||
|
onChange={(value) => updateLevel(setForm, activeLevelIndex, { coverUrl: value })}
|
||||||
|
/>
|
||||||
|
<UploadField
|
||||||
|
disabled={disabled}
|
||||||
|
kind="file"
|
||||||
|
label="宝箱动效"
|
||||||
|
uploadKind="file"
|
||||||
|
value={level.animationUrl}
|
||||||
|
onChange={(value) => updateLevel(setForm, activeLevelIndex, { animationUrl: value })}
|
||||||
|
/>
|
||||||
|
<UploadField
|
||||||
|
disabled={disabled}
|
||||||
|
kind="file"
|
||||||
|
label="开箱动效"
|
||||||
|
uploadKind="file"
|
||||||
|
value={level.openingAnimationUrl}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateLevel(setForm, activeLevelIndex, { openingAnimationUrl: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<UploadField
|
||||||
|
disabled={disabled}
|
||||||
|
kind="image"
|
||||||
|
label="打开图"
|
||||||
|
value={level.openedImageUrl}
|
||||||
|
onChange={(value) => updateLevel(setForm, activeLevelIndex, { openedImageUrl: value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.rewardPanel}>
|
||||||
|
<div className={styles.rewardPanelHead}>
|
||||||
|
<Tabs
|
||||||
|
value={rewardTab}
|
||||||
|
variant="scrollable"
|
||||||
|
onChange={(_, value) => setRewardTab(value)}
|
||||||
|
>
|
||||||
|
{rewardTabs.map(([key, label]) => (
|
||||||
|
<Tab key={key} label={label} value={key} />
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
<Button
|
||||||
|
disabled={disabled}
|
||||||
|
startIcon={<AddOutlined fontSize="small" />}
|
||||||
|
type="button"
|
||||||
|
onClick={() => addReward(setForm, activeLevelIndex, rewardTab)}
|
||||||
|
>
|
||||||
|
添加奖项
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<RewardTable
|
||||||
|
disabled={disabled}
|
||||||
|
levelIndex={activeLevelIndex}
|
||||||
|
poolKey={rewardTab}
|
||||||
|
resourceGroups={resourceGroups}
|
||||||
|
rewards={level[rewardTab] || []}
|
||||||
|
setForm={setForm}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EnergyRuleEditor disabled={disabled} form={form} setForm={setForm} />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LevelNavItem({ active, level, onClick }) {
|
||||||
|
const materialCount = [
|
||||||
|
level.coverUrl,
|
||||||
|
level.animationUrl,
|
||||||
|
level.openingAnimationUrl,
|
||||||
|
level.openedImageUrl,
|
||||||
|
].filter(Boolean).length;
|
||||||
|
const rewardCount = rewardTabs.filter(([key]) => (level[key] || []).length > 0).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button className={[styles.levelNavItem, active ? styles.levelNavItemActive : ""].join(" ")} type="button" onClick={onClick}>
|
||||||
|
<span className={styles.levelNavMain}>L{level.level}</span>
|
||||||
|
<span className={styles.levelNavMeta}>阈值 {formatNumber(level.energyThreshold)}</span>
|
||||||
|
<span className={styles.levelNavMeta}>物料 {materialCount}/4 · 奖励 {rewardCount}/3</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RewardTable({ disabled, levelIndex, poolKey, resourceGroups, rewards, setForm }) {
|
||||||
|
if (!rewards.length) {
|
||||||
|
return <div className={styles.emptyState}>未配置奖项</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.rewardTable}>
|
||||||
|
<div className={styles.rewardTableHead}>
|
||||||
|
<span>资源组</span>
|
||||||
|
<span>权重</span>
|
||||||
|
<span>展示名</span>
|
||||||
|
<span>图标</span>
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
{rewards.map((reward, rewardIndex) => (
|
||||||
|
<div className={styles.rewardTableRow} key={reward.rewardItemId || rewardIndex}>
|
||||||
|
<ResourceGroupSelectField
|
||||||
|
disabled={disabled}
|
||||||
|
groups={resourceGroups}
|
||||||
|
label="资源组"
|
||||||
|
value={reward.resourceGroupId}
|
||||||
|
onChange={(value) => updateReward(setForm, levelIndex, poolKey, rewardIndex, { resourceGroupId: value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
disabled={disabled}
|
||||||
|
inputProps={{ min: 1, step: 1 }}
|
||||||
|
label="权重"
|
||||||
|
type="number"
|
||||||
|
value={reward.weight}
|
||||||
|
onChange={(event) => updateReward(setForm, levelIndex, poolKey, rewardIndex, { weight: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
label="展示名"
|
||||||
|
value={reward.displayName}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateReward(setForm, levelIndex, poolKey, rewardIndex, { displayName: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<UploadField
|
||||||
|
disabled={disabled}
|
||||||
|
kind="image"
|
||||||
|
label="奖励图标"
|
||||||
|
value={reward.iconUrl}
|
||||||
|
onChange={(value) => updateReward(setForm, levelIndex, poolKey, rewardIndex, { iconUrl: value })}
|
||||||
|
/>
|
||||||
|
<Tooltip arrow title="删除奖项">
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
disabled={disabled}
|
||||||
|
label="删除奖项"
|
||||||
|
tone="danger"
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeReward(setForm, levelIndex, poolKey, rewardIndex)}
|
||||||
|
>
|
||||||
|
<DeleteOutlineOutlined fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EnergyRuleEditor({ disabled, form, setForm }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.energyRuleEditor}>
|
||||||
|
<div className={styles.energyRuleToolbar}>
|
||||||
|
<span className={styles.meta}>未命中特殊规则的礼物按基础能量来源计算</span>
|
||||||
|
<Button disabled={disabled} startIcon={<AddOutlined fontSize="small" />} type="button" onClick={() => addEnergyRule(setForm)}>
|
||||||
|
添加规则
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{form.giftEnergyRules.length ? (
|
||||||
|
<div className={styles.energyRuleTable}>
|
||||||
|
<div className={styles.energyRuleTableHead}>
|
||||||
|
<span>礼物 ID</span>
|
||||||
|
<span>礼物类型</span>
|
||||||
|
<span>倍率 ppm</span>
|
||||||
|
<span>固定能量</span>
|
||||||
|
<span>排除</span>
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
{form.giftEnergyRules.map((rule, index) => (
|
||||||
|
<div className={styles.energyRuleTableRow} key={rule.ruleId || index}>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
label="礼物 ID"
|
||||||
|
value={rule.giftId}
|
||||||
|
onChange={(event) => updateEnergyRule(setForm, index, { giftId: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
label="礼物类型"
|
||||||
|
value={rule.giftTypeCode}
|
||||||
|
onChange={(event) => updateEnergyRule(setForm, index, { giftTypeCode: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
inputProps={{ min: 0, step: 1 }}
|
||||||
|
label="倍率 ppm"
|
||||||
|
type="number"
|
||||||
|
value={rule.multiplierPpm}
|
||||||
|
onChange={(event) => updateEnergyRule(setForm, index, { multiplierPpm: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
inputProps={{ min: 0, step: 1 }}
|
||||||
|
label="固定能量"
|
||||||
|
type="number"
|
||||||
|
value={rule.fixedEnergy}
|
||||||
|
onChange={(event) => updateEnergyRule(setForm, index, { fixedEnergy: event.target.value })}
|
||||||
|
/>
|
||||||
|
<SwitchField
|
||||||
|
checked={rule.excluded}
|
||||||
|
disabled={disabled}
|
||||||
|
label="排除"
|
||||||
|
onChange={(event) => updateEnergyRule(setForm, index, { excluded: event.target.checked })}
|
||||||
|
/>
|
||||||
|
<Tooltip arrow title="删除规则">
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
disabled={disabled}
|
||||||
|
label="删除规则"
|
||||||
|
tone="danger"
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeEnergyRule(setForm, index)}
|
||||||
|
>
|
||||||
|
<DeleteOutlineOutlined fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.emptyState}>未配置礼物特殊规则</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectField({ disabled, label, onChange, options, value }) {
|
||||||
|
return (
|
||||||
|
<TextField select disabled={disabled} label={label} value={value} onChange={(event) => onChange(event.target.value)}>
|
||||||
|
{options.map(([optionValue, optionLabel]) => (
|
||||||
|
<MenuItem key={optionValue} value={optionValue}>
|
||||||
|
{optionLabel}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SwitchField({ checked, disabled, label, onChange }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.switchField}>
|
||||||
|
<span>{label}</span>
|
||||||
|
<AdminSwitch
|
||||||
|
checked={checked}
|
||||||
|
checkedLabel="开启"
|
||||||
|
disabled={disabled}
|
||||||
|
label={label}
|
||||||
|
uncheckedLabel="关闭"
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRoot(setForm, key, value) {
|
||||||
|
setForm((current) => ({ ...current, [key]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLevel(setForm, levelIndex, patch) {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
levels: current.levels.map((level, index) => (index === levelIndex ? { ...level, ...patch } : level)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addReward(setForm, levelIndex, poolKey) {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
levels: current.levels.map((level, index) =>
|
||||||
|
index === levelIndex
|
||||||
|
? {
|
||||||
|
...level,
|
||||||
|
[poolKey]: [
|
||||||
|
...(level[poolKey] || []),
|
||||||
|
{
|
||||||
|
displayName: "",
|
||||||
|
iconUrl: "",
|
||||||
|
resourceGroupId: "",
|
||||||
|
rewardItemId: `level_${level.level}_${poolKey}_${Date.now()}`,
|
||||||
|
weight: "100",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: level,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateReward(setForm, levelIndex, poolKey, rewardIndex, patch) {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
levels: current.levels.map((level, index) =>
|
||||||
|
index === levelIndex
|
||||||
|
? {
|
||||||
|
...level,
|
||||||
|
[poolKey]: (level[poolKey] || []).map((reward, itemIndex) =>
|
||||||
|
itemIndex === rewardIndex ? { ...reward, ...patch } : reward,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: level,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeReward(setForm, levelIndex, poolKey, rewardIndex) {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
levels: current.levels.map((level, index) =>
|
||||||
|
index === levelIndex
|
||||||
|
? { ...level, [poolKey]: (level[poolKey] || []).filter((_, itemIndex) => itemIndex !== rewardIndex) }
|
||||||
|
: level,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addEnergyRule(setForm) {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
giftEnergyRules: [
|
||||||
|
...current.giftEnergyRules,
|
||||||
|
{
|
||||||
|
excluded: false,
|
||||||
|
fixedEnergy: "0",
|
||||||
|
giftId: "",
|
||||||
|
giftTypeCode: "",
|
||||||
|
multiplierPpm: "1000000",
|
||||||
|
ruleId: `gift_energy_rule_${Date.now()}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEnergyRule(setForm, index, patch) {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
giftEnergyRules: current.giftEnergyRules.map((rule, itemIndex) =>
|
||||||
|
itemIndex === index ? { ...rule, ...patch } : rule,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeEnergyRule(setForm, index) {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
giftEnergyRules: current.giftEnergyRules.filter((_, itemIndex) => itemIndex !== index),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value) {
|
||||||
|
return Number(value || 0).toLocaleString("zh-CN");
|
||||||
|
}
|
||||||
@ -0,0 +1,112 @@
|
|||||||
|
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 { formatMillis } from "@/shared/utils/time.js";
|
||||||
|
import styles from "@/features/room-treasure/room-treasure.module.css";
|
||||||
|
|
||||||
|
const broadcastScopeLabels = {
|
||||||
|
global: "全局广播",
|
||||||
|
none: "不广播",
|
||||||
|
region: "区域广播",
|
||||||
|
};
|
||||||
|
|
||||||
|
const rewardStackPolicyLabels = {
|
||||||
|
allow_stack: "允许叠加",
|
||||||
|
priority_only: "优先级单份",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RoomTreasureConfigSummary({ canUpdate, config, configLoading, onEdit, onRefresh }) {
|
||||||
|
return (
|
||||||
|
<section className={styles.summaryPanel}>
|
||||||
|
<div className={styles.summaryScrollArea}>
|
||||||
|
<div className={styles.summaryContent}>
|
||||||
|
<div className={styles.summaryHeader}>
|
||||||
|
<span className={styles.title}>房间宝箱配置</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.summaryItems}>
|
||||||
|
<ConfigStatus config={config} loading={configLoading} />
|
||||||
|
<SummaryItem label="配置版本">{config?.configVersion || "-"}</SummaryItem>
|
||||||
|
<SummaryItem label="开箱倒计时">{formatSeconds(config?.openDelayMs)}</SummaryItem>
|
||||||
|
<SummaryItem label="广播">
|
||||||
|
{config?.broadcastEnabled ? broadcastScopeLabels[config.broadcastScope] || config.broadcastScope : "关闭"}
|
||||||
|
</SummaryItem>
|
||||||
|
<SummaryItem label="角色奖励">{rewardStackPolicyLabels[config?.rewardStackPolicy] || "-"}</SummaryItem>
|
||||||
|
<SummaryItem label="更新时间">
|
||||||
|
{config?.updatedAtMs ? formatMillis(config.updatedAtMs) : configLoading ? "加载中" : "-"}
|
||||||
|
</SummaryItem>
|
||||||
|
</div>
|
||||||
|
<LevelPreview config={config} loading={configLoading} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.summaryActions}>
|
||||||
|
<AdminActionIconButton disabled={configLoading} label="刷新配置" type="button" onClick={onRefresh}>
|
||||||
|
<RefreshOutlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
|
<Button
|
||||||
|
disabled={!canUpdate || configLoading}
|
||||||
|
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 LevelPreview({ config, loading }) {
|
||||||
|
if (loading && !config) {
|
||||||
|
return <div className={styles.levelPreview}>加载中</div>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={styles.levelPreview}>
|
||||||
|
{(config?.levels || []).map((level) => (
|
||||||
|
<span className={styles.levelPill} key={level.level}>
|
||||||
|
<span>L{level.level}</span>
|
||||||
|
<span>{formatNumber(level.energyThreshold)}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSeconds(ms) {
|
||||||
|
if (ms === undefined || ms === null) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return `${Math.round(Number(ms || 0) / 1000)} 秒`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value) {
|
||||||
|
return Number(value || 0).toLocaleString("zh-CN");
|
||||||
|
}
|
||||||
196
src/features/room-treasure/hooks/useRoomTreasurePage.js
Normal file
196
src/features/room-treasure/hooks/useRoomTreasurePage.js
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { parseForm } from "@/shared/forms/validation";
|
||||||
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
|
import { listResourceGroups } from "@/features/resources/api";
|
||||||
|
import { getRoomTreasureConfig, updateRoomTreasureConfig } from "@/features/room-treasure/api";
|
||||||
|
import { useRoomTreasureAbilities } from "@/features/room-treasure/permissions.js";
|
||||||
|
import { roomTreasureConfigFormSchema } from "@/features/room-treasure/schema";
|
||||||
|
|
||||||
|
const defaultThresholds = [1000, 3000, 6000, 10000, 15000, 21000, 28000];
|
||||||
|
|
||||||
|
export function useRoomTreasurePage() {
|
||||||
|
const abilities = useRoomTreasureAbilities();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const [config, setConfig] = useState(null);
|
||||||
|
const [configError, setConfigError] = useState("");
|
||||||
|
const [configLoading, setConfigLoading] = useState(false);
|
||||||
|
const [configSaving, setConfigSaving] = useState(false);
|
||||||
|
const [form, setForm] = useState(emptyRoomTreasureForm());
|
||||||
|
const [resourceGroups, setResourceGroups] = useState([]);
|
||||||
|
|
||||||
|
const reloadConfig = useCallback(async () => {
|
||||||
|
setConfigLoading(true);
|
||||||
|
setConfigError("");
|
||||||
|
try {
|
||||||
|
const [nextConfig, groups] = await Promise.all([
|
||||||
|
getRoomTreasureConfig(),
|
||||||
|
listResourceGroups({ page: 1, page_size: 100, status: "active" }),
|
||||||
|
]);
|
||||||
|
setConfig(nextConfig);
|
||||||
|
setForm(formFromConfig(nextConfig));
|
||||||
|
setResourceGroups(groups.items || []);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err.message || "加载房间宝箱配置失败";
|
||||||
|
setConfigError(message);
|
||||||
|
showToast(message, "error");
|
||||||
|
} finally {
|
||||||
|
setConfigLoading(false);
|
||||||
|
}
|
||||||
|
}, [showToast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void reloadConfig();
|
||||||
|
}, [reloadConfig]);
|
||||||
|
|
||||||
|
const resetForm = useCallback(() => {
|
||||||
|
setForm(config ? formFromConfig(config) : emptyRoomTreasureForm());
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
const submitConfig = async (event, onSaved) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!abilities.canUpdate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConfigSaving(true);
|
||||||
|
try {
|
||||||
|
const parsed = parseForm(roomTreasureConfigFormSchema, form);
|
||||||
|
const saved = await updateRoomTreasureConfig(payloadFromForm(parsed));
|
||||||
|
setConfig(saved);
|
||||||
|
setForm(formFromConfig(saved));
|
||||||
|
showToast("房间宝箱配置已保存", "success");
|
||||||
|
onSaved?.(saved);
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "保存房间宝箱配置失败", "error");
|
||||||
|
} finally {
|
||||||
|
setConfigSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
abilities,
|
||||||
|
config,
|
||||||
|
configError,
|
||||||
|
configLoading,
|
||||||
|
configSaving,
|
||||||
|
form,
|
||||||
|
reloadConfig,
|
||||||
|
resetForm,
|
||||||
|
resourceGroups,
|
||||||
|
setForm,
|
||||||
|
submitConfig,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyRoomTreasureForm() {
|
||||||
|
return {
|
||||||
|
broadcastDelayMs: "0",
|
||||||
|
broadcastEnabled: true,
|
||||||
|
broadcastScope: "region",
|
||||||
|
enabled: false,
|
||||||
|
energySource: "gift_point_added",
|
||||||
|
giftEnergyRules: [],
|
||||||
|
levels: Array.from({ length: 7 }, (_, index) => ({
|
||||||
|
animationUrl: "",
|
||||||
|
coverUrl: "",
|
||||||
|
energyThreshold: String(defaultThresholds[index]),
|
||||||
|
igniterRewards: [],
|
||||||
|
inRoomRewards: [],
|
||||||
|
level: index + 1,
|
||||||
|
openedImageUrl: "",
|
||||||
|
openingAnimationUrl: "",
|
||||||
|
top1Rewards: [],
|
||||||
|
})),
|
||||||
|
openDelayMs: "30000",
|
||||||
|
rewardStackPolicy: "allow_stack",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formFromConfig(config) {
|
||||||
|
const fallback = emptyRoomTreasureForm();
|
||||||
|
return {
|
||||||
|
broadcastDelayMs: String(config?.broadcastDelayMs ?? fallback.broadcastDelayMs),
|
||||||
|
broadcastEnabled: Boolean(config?.broadcastEnabled),
|
||||||
|
broadcastScope: config?.broadcastScope || fallback.broadcastScope,
|
||||||
|
enabled: Boolean(config?.enabled),
|
||||||
|
energySource: config?.energySource || fallback.energySource,
|
||||||
|
giftEnergyRules: (config?.giftEnergyRules || []).map((rule, index) => ({
|
||||||
|
excluded: Boolean(rule.excluded),
|
||||||
|
fixedEnergy: String(rule.fixedEnergy || 0),
|
||||||
|
giftId: rule.giftId || "",
|
||||||
|
giftTypeCode: rule.giftTypeCode || "",
|
||||||
|
multiplierPpm: String(rule.multiplierPpm || 0),
|
||||||
|
ruleId: rule.ruleId || `gift_energy_rule_${index + 1}`,
|
||||||
|
})),
|
||||||
|
levels: fallback.levels.map((fallbackLevel) => {
|
||||||
|
const level = config?.levels?.find((item) => Number(item.level) === fallbackLevel.level) || fallbackLevel;
|
||||||
|
return {
|
||||||
|
animationUrl: level.animationUrl || "",
|
||||||
|
coverUrl: level.coverUrl || "",
|
||||||
|
energyThreshold: String(level.energyThreshold || fallbackLevel.energyThreshold),
|
||||||
|
igniterRewards: rewardsToForm(level.igniterRewards),
|
||||||
|
inRoomRewards: rewardsToForm(level.inRoomRewards),
|
||||||
|
level: fallbackLevel.level,
|
||||||
|
openedImageUrl: level.openedImageUrl || "",
|
||||||
|
openingAnimationUrl: level.openingAnimationUrl || "",
|
||||||
|
top1Rewards: rewardsToForm(level.top1Rewards),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
openDelayMs: String(config?.openDelayMs ?? fallback.openDelayMs),
|
||||||
|
rewardStackPolicy: config?.rewardStackPolicy || fallback.rewardStackPolicy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewardsToForm(rewards = []) {
|
||||||
|
return rewards.map((reward, index) => ({
|
||||||
|
displayName: reward.displayName || "",
|
||||||
|
iconUrl: reward.iconUrl || "",
|
||||||
|
resourceGroupId: reward.resourceGroupId ? String(reward.resourceGroupId) : "",
|
||||||
|
rewardItemId: reward.rewardItemId || `reward_${index + 1}`,
|
||||||
|
weight: String(reward.weight || 100),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function payloadFromForm(form) {
|
||||||
|
return {
|
||||||
|
broadcastDelayMs: Number(form.broadcastDelayMs || 0),
|
||||||
|
broadcastEnabled: Boolean(form.broadcastEnabled),
|
||||||
|
broadcastScope: form.broadcastScope,
|
||||||
|
enabled: Boolean(form.enabled),
|
||||||
|
energySource: form.energySource,
|
||||||
|
giftEnergyRules: form.giftEnergyRules.map((rule, index) => ({
|
||||||
|
excluded: Boolean(rule.excluded),
|
||||||
|
fixedEnergy: Number(rule.fixedEnergy || 0),
|
||||||
|
giftId: String(rule.giftId || "").trim(),
|
||||||
|
giftTypeCode: String(rule.giftTypeCode || "").trim(),
|
||||||
|
multiplierPpm: Number(rule.multiplierPpm || 0),
|
||||||
|
ruleId: String(rule.ruleId || "").trim() || `gift_energy_rule_${index + 1}`,
|
||||||
|
})),
|
||||||
|
levels: form.levels
|
||||||
|
.map((level) => ({
|
||||||
|
animationUrl: String(level.animationUrl || "").trim(),
|
||||||
|
coverUrl: String(level.coverUrl || "").trim(),
|
||||||
|
energyThreshold: Number(level.energyThreshold || 0),
|
||||||
|
igniterRewards: rewardsFromForm(level.igniterRewards, level.level, "igniter"),
|
||||||
|
inRoomRewards: rewardsFromForm(level.inRoomRewards, level.level, "in_room"),
|
||||||
|
level: Number(level.level),
|
||||||
|
openedImageUrl: String(level.openedImageUrl || "").trim(),
|
||||||
|
openingAnimationUrl: String(level.openingAnimationUrl || "").trim(),
|
||||||
|
top1Rewards: rewardsFromForm(level.top1Rewards, level.level, "top1"),
|
||||||
|
}))
|
||||||
|
.sort((left, right) => left.level - right.level),
|
||||||
|
openDelayMs: Number(form.openDelayMs || 0),
|
||||||
|
rewardStackPolicy: form.rewardStackPolicy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewardsFromForm(rewards = [], level, role) {
|
||||||
|
return rewards
|
||||||
|
.map((reward, index) => ({
|
||||||
|
displayName: String(reward.displayName || "").trim(),
|
||||||
|
iconUrl: String(reward.iconUrl || "").trim(),
|
||||||
|
resourceGroupId: Number(reward.resourceGroupId || 0),
|
||||||
|
rewardItemId: String(reward.rewardItemId || "").trim() || `level_${level}_${role}_${index + 1}`,
|
||||||
|
weight: Number(reward.weight || 0),
|
||||||
|
}))
|
||||||
|
.filter((reward) => reward.resourceGroupId || reward.weight || reward.displayName || reward.iconUrl);
|
||||||
|
}
|
||||||
148
src/features/room-treasure/pages/RoomTreasurePage.jsx
Normal file
148
src/features/room-treasure/pages/RoomTreasurePage.jsx
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
|
import { AdminListBody, AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
|
||||||
|
import { RoomTreasureConfigEditor } from "@/features/room-treasure/components/RoomTreasureConfigEditor.jsx";
|
||||||
|
import { RoomTreasureConfigSummary } from "@/features/room-treasure/components/RoomTreasureConfigSummary.jsx";
|
||||||
|
import { useRoomTreasurePage } from "@/features/room-treasure/hooks/useRoomTreasurePage.js";
|
||||||
|
import styles from "@/features/room-treasure/room-treasure.module.css";
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
key: "level",
|
||||||
|
label: "等级",
|
||||||
|
width: "minmax(160px, 0.7fr)",
|
||||||
|
render: (level) => (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span className={styles.name}>L{level.level}</span>
|
||||||
|
<span className={styles.meta}>阈值 {formatNumber(level.energyThreshold)}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "materials",
|
||||||
|
label: "物料",
|
||||||
|
width: "minmax(280px, 1.3fr)",
|
||||||
|
render: (level) => <MaterialState level={level} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "inRoomRewards",
|
||||||
|
label: "在房奖励",
|
||||||
|
width: "minmax(220px, 1fr)",
|
||||||
|
render: (level, _index, context) => (
|
||||||
|
<RewardSummary groups={context.resourceGroups} rewards={level.inRoomRewards} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "top1Rewards",
|
||||||
|
label: "贡献第一",
|
||||||
|
width: "minmax(220px, 1fr)",
|
||||||
|
render: (level, _index, context) => <RewardSummary groups={context.resourceGroups} rewards={level.top1Rewards} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "igniterRewards",
|
||||||
|
label: "点火人",
|
||||||
|
width: "minmax(220px, 1fr)",
|
||||||
|
render: (level, _index, context) => (
|
||||||
|
<RewardSummary groups={context.resourceGroups} rewards={level.igniterRewards} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function RoomTreasurePage() {
|
||||||
|
const page = useRoomTreasurePage();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const editing = searchParams.get("mode") === "edit";
|
||||||
|
|
||||||
|
const enterEdit = () => {
|
||||||
|
page.resetForm();
|
||||||
|
setSearchParams({ mode: "edit" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const exitEdit = () => {
|
||||||
|
page.resetForm();
|
||||||
|
setSearchParams({});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
return (
|
||||||
|
<AdminListPage>
|
||||||
|
<DataState error={page.configError} loading={page.configLoading && !page.config} onRetry={page.reloadConfig}>
|
||||||
|
<RoomTreasureConfigEditor
|
||||||
|
abilities={page.abilities}
|
||||||
|
configLoading={page.configLoading}
|
||||||
|
configSaving={page.configSaving}
|
||||||
|
form={page.form}
|
||||||
|
resourceGroups={page.resourceGroups}
|
||||||
|
setForm={page.setForm}
|
||||||
|
onCancel={exitEdit}
|
||||||
|
onSubmit={(event) => page.submitConfig(event, exitEdit)}
|
||||||
|
/>
|
||||||
|
</DataState>
|
||||||
|
</AdminListPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminListPage>
|
||||||
|
<RoomTreasureConfigSummary
|
||||||
|
canUpdate={page.abilities.canUpdate}
|
||||||
|
config={page.config}
|
||||||
|
configLoading={page.configLoading}
|
||||||
|
onEdit={enterEdit}
|
||||||
|
onRefresh={page.reloadConfig}
|
||||||
|
/>
|
||||||
|
<DataState error={page.configError} loading={page.configLoading && !page.config} onRetry={page.reloadConfig}>
|
||||||
|
<AdminListBody>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
context={{ resourceGroups: page.resourceGroups }}
|
||||||
|
items={page.config?.levels || []}
|
||||||
|
minWidth="1120px"
|
||||||
|
rowKey={(level) => level.level}
|
||||||
|
/>
|
||||||
|
</AdminListBody>
|
||||||
|
</DataState>
|
||||||
|
</AdminListPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MaterialState({ level }) {
|
||||||
|
const items = [
|
||||||
|
["封面", level.coverUrl],
|
||||||
|
["动效", level.animationUrl],
|
||||||
|
["开箱", level.openingAnimationUrl],
|
||||||
|
["打开图", level.openedImageUrl],
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div className={styles.assetBadges}>
|
||||||
|
{items.map(([label, url]) => (
|
||||||
|
<span className={["status-badge", url ? "status-badge--running" : "status-badge--stopped"].join(" ")} key={label}>
|
||||||
|
<span className="status-point" />
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RewardSummary({ groups, rewards = [] }) {
|
||||||
|
if (!rewards.length) {
|
||||||
|
return <span className={styles.meta}>未配置</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span className={styles.name}>{rewards.length} 个奖项</span>
|
||||||
|
<span className={styles.meta}>{rewards.slice(0, 2).map((reward) => resourceGroupName(groups, reward.resourceGroupId)).join("、")}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resourceGroupName(groups, id) {
|
||||||
|
const group = groups.find((item) => Number(item.groupId) === Number(id));
|
||||||
|
return group?.name || group?.groupCode || (id ? `#${id}` : "-");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value) {
|
||||||
|
return Number(value || 0).toLocaleString("zh-CN");
|
||||||
|
}
|
||||||
11
src/features/room-treasure/permissions.js
Normal file
11
src/features/room-treasure/permissions.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||||
|
import { PERMISSIONS } from "@/app/permissions";
|
||||||
|
|
||||||
|
export function useRoomTreasureAbilities() {
|
||||||
|
const { can } = useAuth();
|
||||||
|
|
||||||
|
return {
|
||||||
|
canUpdate: can(PERMISSIONS.roomTreasureUpdate),
|
||||||
|
canView: can(PERMISSIONS.roomTreasureView),
|
||||||
|
};
|
||||||
|
}
|
||||||
422
src/features/room-treasure/room-treasure.module.css
Normal file
422
src/features/room-treasure/room-treasure.module.css
Normal file
@ -0,0 +1,422 @@
|
|||||||
|
.summaryPanel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 22px 28px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryScrollArea {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryContent {
|
||||||
|
display: flex;
|
||||||
|
min-width: 980px;
|
||||||
|
gap: 22px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 126px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryItems {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, minmax(120px, auto));
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryItem,
|
||||||
|
.stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryLabel,
|
||||||
|
.meta {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryValue,
|
||||||
|
.name {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelPreview {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelPill {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-card-strong);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assetBadges {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorPage {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorActionBar {
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 28px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorScrollBody {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorTitleGroup,
|
||||||
|
.editorActions {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorActions {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorSection {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 22px 28px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.basicGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(180px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switchField {
|
||||||
|
display: flex;
|
||||||
|
min-height: var(--control-height);
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelWorkspace {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
grid-template-columns: 220px minmax(0, 1fr);
|
||||||
|
gap: 22px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelNav {
|
||||||
|
position: sticky;
|
||||||
|
top: 16px;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelNavItem {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelNavItemActive {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelNavMain {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelNavMeta {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelDetail {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelDetailHeader {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(220px, 280px);
|
||||||
|
gap: 18px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.materialGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(170px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rewardPanel {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rewardPanelHead,
|
||||||
|
.energyRuleToolbar {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rewardTable,
|
||||||
|
.energyRuleEditor,
|
||||||
|
.energyRuleTable {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rewardTableHead,
|
||||||
|
.rewardTableRow {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
grid-template-columns: minmax(220px, 1.2fr) 120px minmax(180px, 1fr) minmax(220px, 1fr) var(--control-height);
|
||||||
|
gap: 12px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rewardTableHead,
|
||||||
|
.energyRuleTableHead {
|
||||||
|
align-items: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rewardTableRow,
|
||||||
|
.energyRuleTableRow {
|
||||||
|
padding: 12px 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.energyRuleTableHead,
|
||||||
|
.energyRuleTableRow {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
grid-template-columns: minmax(140px, 1fr) minmax(140px, 1fr) 130px 130px 120px var(--control-height);
|
||||||
|
gap: 12px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyState {
|
||||||
|
min-height: var(--control-height);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionHeader {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelEditor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelCard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelHeader {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 72px minmax(180px, 260px);
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assetGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rewardBlock {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rewardTitle {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rewardRows,
|
||||||
|
.ruleEditor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rewardRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(180px, 1.2fr) minmax(160px, 1fr) 110px auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ruleRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(120px, 1fr)) 120px auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.summaryPanel {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryActions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorActionBar,
|
||||||
|
.editorTitleGroup,
|
||||||
|
.editorActions,
|
||||||
|
.rewardPanelHead,
|
||||||
|
.energyRuleToolbar {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorActions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorActions > * {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.basicGrid,
|
||||||
|
.levelWorkspace,
|
||||||
|
.levelDetailHeader,
|
||||||
|
.materialGrid,
|
||||||
|
.rewardTableHead,
|
||||||
|
.rewardTableRow,
|
||||||
|
.energyRuleTableHead,
|
||||||
|
.energyRuleTableRow {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelNav {
|
||||||
|
position: static;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.assetGrid,
|
||||||
|
.levelHeader,
|
||||||
|
.rewardRow,
|
||||||
|
.ruleRow {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/features/room-treasure/routes.js
Normal file
12
src/features/room-treasure/routes.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
|
||||||
|
|
||||||
|
export const roomTreasureRoutes = [
|
||||||
|
{
|
||||||
|
label: "房间宝箱",
|
||||||
|
loader: () => import("./pages/RoomTreasurePage.jsx").then((module) => module.RoomTreasurePage),
|
||||||
|
menuCode: MENU_CODES.roomTreasure,
|
||||||
|
pageKey: "room-treasure",
|
||||||
|
path: "/activities/room-treasure",
|
||||||
|
permission: PERMISSIONS.roomTreasureView,
|
||||||
|
},
|
||||||
|
];
|
||||||
138
src/features/room-treasure/schema.ts
Normal file
138
src/features/room-treasure/schema.ts
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const numberInput = z.union([z.string(), z.number()]);
|
||||||
|
|
||||||
|
const rewardSchema = z.object({
|
||||||
|
displayName: z.string().optional(),
|
||||||
|
iconUrl: z.string().optional(),
|
||||||
|
resourceGroupId: numberInput,
|
||||||
|
rewardItemId: z.string().optional(),
|
||||||
|
weight: numberInput,
|
||||||
|
});
|
||||||
|
|
||||||
|
const levelSchema = z.object({
|
||||||
|
animationUrl: z.string().optional(),
|
||||||
|
coverUrl: z.string().optional(),
|
||||||
|
energyThreshold: numberInput,
|
||||||
|
igniterRewards: z.array(rewardSchema),
|
||||||
|
inRoomRewards: z.array(rewardSchema),
|
||||||
|
level: z.number().int().min(1).max(7),
|
||||||
|
openedImageUrl: z.string().optional(),
|
||||||
|
openingAnimationUrl: z.string().optional(),
|
||||||
|
top1Rewards: z.array(rewardSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const giftEnergyRuleSchema = z.object({
|
||||||
|
excluded: z.boolean(),
|
||||||
|
fixedEnergy: numberInput,
|
||||||
|
giftId: z.string().optional(),
|
||||||
|
giftTypeCode: z.string().optional(),
|
||||||
|
multiplierPpm: numberInput,
|
||||||
|
ruleId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const roomTreasureConfigFormSchema = z
|
||||||
|
.object({
|
||||||
|
broadcastDelayMs: numberInput,
|
||||||
|
broadcastEnabled: z.boolean(),
|
||||||
|
broadcastScope: z.enum(["none", "region", "global"]),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
energySource: z.enum(["gift_point_added", "heat_value"]),
|
||||||
|
giftEnergyRules: z.array(giftEnergyRuleSchema),
|
||||||
|
levels: z.array(levelSchema).length(7, "必须配置 7 级宝箱"),
|
||||||
|
openDelayMs: numberInput,
|
||||||
|
rewardStackPolicy: z.enum(["allow_stack", "priority_only"]),
|
||||||
|
})
|
||||||
|
.superRefine((value, context) => {
|
||||||
|
if (!validNonNegativeInteger(value.openDelayMs)) {
|
||||||
|
context.addIssue({ code: "custom", message: "开箱倒计时必须大于等于 0", path: ["openDelayMs"] });
|
||||||
|
}
|
||||||
|
if (!validNonNegativeInteger(value.broadcastDelayMs)) {
|
||||||
|
context.addIssue({ code: "custom", message: "广播延迟必须大于等于 0", path: ["broadcastDelayMs"] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set<number>();
|
||||||
|
for (const level of value.levels) {
|
||||||
|
if (seen.has(level.level)) {
|
||||||
|
context.addIssue({ code: "custom", message: "宝箱等级不能重复", path: ["levels"] });
|
||||||
|
}
|
||||||
|
seen.add(level.level);
|
||||||
|
if (!positiveInteger(level.energyThreshold)) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: `第 ${level.level} 级能量阈值必须大于 0`,
|
||||||
|
path: ["levels", level.level - 1, "energyThreshold"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
validateRewards(context, level.level, "inRoomRewards", level.inRoomRewards);
|
||||||
|
validateRewards(context, level.level, "top1Rewards", level.top1Rewards);
|
||||||
|
validateRewards(context, level.level, "igniterRewards", level.igniterRewards);
|
||||||
|
}
|
||||||
|
|
||||||
|
value.giftEnergyRules.forEach((rule, index) => {
|
||||||
|
const giftId = String(rule.giftId || "").trim();
|
||||||
|
const giftTypeCode = String(rule.giftTypeCode || "").trim();
|
||||||
|
const multiplierPpm = Number(rule.multiplierPpm || 0);
|
||||||
|
const fixedEnergy = Number(rule.fixedEnergy || 0);
|
||||||
|
if (!giftId && !giftTypeCode) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "礼物能量规则必须填写礼物 ID 或礼物类型",
|
||||||
|
path: ["giftEnergyRules", index, "giftId"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(multiplierPpm) || multiplierPpm < 0 || !Number.isInteger(fixedEnergy) || fixedEnergy < 0) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "礼物能量规则数值必须大于等于 0",
|
||||||
|
path: ["giftEnergyRules", index, "multiplierPpm"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!rule.excluded && multiplierPpm === 0 && fixedEnergy === 0) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "非排除规则必须配置倍率或固定能量",
|
||||||
|
path: ["giftEnergyRules", index, "multiplierPpm"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RoomTreasureConfigForm = z.infer<typeof roomTreasureConfigFormSchema>;
|
||||||
|
|
||||||
|
function validateRewards(context: z.RefinementCtx, level: number, key: string, rewards: z.infer<typeof rewardSchema>[]) {
|
||||||
|
rewards.forEach((reward, index) => {
|
||||||
|
const empty =
|
||||||
|
!Number(reward.resourceGroupId || 0) &&
|
||||||
|
!Number(reward.weight || 0) &&
|
||||||
|
!String(reward.displayName || "").trim() &&
|
||||||
|
!String(reward.iconUrl || "").trim();
|
||||||
|
if (empty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!positiveInteger(reward.resourceGroupId)) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: `第 ${level} 级奖励必须选择资源组`,
|
||||||
|
path: ["levels", level - 1, key, index, "resourceGroupId"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!positiveInteger(reward.weight)) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: `第 ${level} 级奖励权重必须大于 0`,
|
||||||
|
path: ["levels", level - 1, key, index, "weight"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validNonNegativeInteger(value: unknown) {
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isInteger(number) && number >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function positiveInteger(value: unknown) {
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isInteger(number) && number > 0;
|
||||||
|
}
|
||||||
@ -23,7 +23,6 @@ export const API_OPERATIONS = {
|
|||||||
appUnbanUser: "appUnbanUser",
|
appUnbanUser: "appUnbanUser",
|
||||||
appUpdateUser: "appUpdateUser",
|
appUpdateUser: "appUpdateUser",
|
||||||
batchUpdateUserStatus: "batchUpdateUserStatus",
|
batchUpdateUserStatus: "batchUpdateUserStatus",
|
||||||
cancelJob: "cancelJob",
|
|
||||||
cancelRoomPin: "cancelRoomPin",
|
cancelRoomPin: "cancelRoomPin",
|
||||||
changePassword: "changePassword",
|
changePassword: "changePassword",
|
||||||
closeAgency: "closeAgency",
|
closeAgency: "closeAgency",
|
||||||
@ -51,7 +50,6 @@ export const API_OPERATIONS = {
|
|||||||
createTaskDefinition: "createTaskDefinition",
|
createTaskDefinition: "createTaskDefinition",
|
||||||
createTier: "createTier",
|
createTier: "createTier",
|
||||||
createUser: "createUser",
|
createUser: "createUser",
|
||||||
createUserExportJob: "createUserExportJob",
|
|
||||||
creditCoinSellerStock: "creditCoinSellerStock",
|
creditCoinSellerStock: "creditCoinSellerStock",
|
||||||
dashboardOverview: "dashboardOverview",
|
dashboardOverview: "dashboardOverview",
|
||||||
deleteAppVersion: "deleteAppVersion",
|
deleteAppVersion: "deleteAppVersion",
|
||||||
@ -68,7 +66,6 @@ export const API_OPERATIONS = {
|
|||||||
disableRegion: "disableRegion",
|
disableRegion: "disableRegion",
|
||||||
disableResource: "disableResource",
|
disableResource: "disableResource",
|
||||||
disableResourceGroup: "disableResourceGroup",
|
disableResourceGroup: "disableResourceGroup",
|
||||||
downloadJobArtifact: "downloadJobArtifact",
|
|
||||||
enableCountry: "enableCountry",
|
enableCountry: "enableCountry",
|
||||||
enableGift: "enableGift",
|
enableGift: "enableGift",
|
||||||
enableRegion: "enableRegion",
|
enableRegion: "enableRegion",
|
||||||
@ -85,6 +82,7 @@ export const API_OPERATIONS = {
|
|||||||
getResourceGroup: "getResourceGroup",
|
getResourceGroup: "getResourceGroup",
|
||||||
getRoleDataScopes: "getRoleDataScopes",
|
getRoleDataScopes: "getRoleDataScopes",
|
||||||
getRoomConfig: "getRoomConfig",
|
getRoomConfig: "getRoomConfig",
|
||||||
|
getRoomTreasureConfig: "getRoomTreasureConfig",
|
||||||
getSevenDayCheckInConfig: "getSevenDayCheckInConfig",
|
getSevenDayCheckInConfig: "getSevenDayCheckInConfig",
|
||||||
getUser: "getUser",
|
getUser: "getUser",
|
||||||
grantResource: "grantResource",
|
grantResource: "grantResource",
|
||||||
@ -107,7 +105,6 @@ export const API_OPERATIONS = {
|
|||||||
listGiftTypes: "listGiftTypes",
|
listGiftTypes: "listGiftTypes",
|
||||||
listH5Links: "listH5Links",
|
listH5Links: "listH5Links",
|
||||||
listHosts: "listHosts",
|
listHosts: "listHosts",
|
||||||
listJobs: "listJobs",
|
|
||||||
listLevelConfig: "listLevelConfig",
|
listLevelConfig: "listLevelConfig",
|
||||||
listLoginLogs: "listLoginLogs",
|
listLoginLogs: "listLoginLogs",
|
||||||
listLuckyGiftDraws: "listLuckyGiftDraws",
|
listLuckyGiftDraws: "listLuckyGiftDraws",
|
||||||
@ -170,6 +167,7 @@ export const API_OPERATIONS = {
|
|||||||
updateRole: "updateRole",
|
updateRole: "updateRole",
|
||||||
updateRoom: "updateRoom",
|
updateRoom: "updateRoom",
|
||||||
updateRoomConfig: "updateRoomConfig",
|
updateRoomConfig: "updateRoomConfig",
|
||||||
|
updateRoomTreasureConfig: "updateRoomTreasureConfig",
|
||||||
updateSevenDayCheckInConfig: "updateSevenDayCheckInConfig",
|
updateSevenDayCheckInConfig: "updateSevenDayCheckInConfig",
|
||||||
updateTaskDefinition: "updateTaskDefinition",
|
updateTaskDefinition: "updateTaskDefinition",
|
||||||
updateTier: "updateTier",
|
updateTier: "updateTier",
|
||||||
@ -255,13 +253,6 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "user:status",
|
permission: "user:status",
|
||||||
permissions: ["user:status"]
|
permissions: ["user:status"]
|
||||||
},
|
},
|
||||||
cancelJob: {
|
|
||||||
method: "POST",
|
|
||||||
operationId: API_OPERATIONS.cancelJob,
|
|
||||||
path: "/v1/jobs/{id}/cancel",
|
|
||||||
permission: "job:cancel",
|
|
||||||
permissions: ["job:cancel"]
|
|
||||||
},
|
|
||||||
cancelRoomPin: {
|
cancelRoomPin: {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
operationId: API_OPERATIONS.cancelRoomPin,
|
operationId: API_OPERATIONS.cancelRoomPin,
|
||||||
@ -449,13 +440,6 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "user:create",
|
permission: "user:create",
|
||||||
permissions: ["user:create"]
|
permissions: ["user:create"]
|
||||||
},
|
},
|
||||||
createUserExportJob: {
|
|
||||||
method: "POST",
|
|
||||||
operationId: API_OPERATIONS.createUserExportJob,
|
|
||||||
path: "/v1/exports/users",
|
|
||||||
permission: "export:create",
|
|
||||||
permissions: ["export:create"]
|
|
||||||
},
|
|
||||||
creditCoinSellerStock: {
|
creditCoinSellerStock: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
operationId: API_OPERATIONS.creditCoinSellerStock,
|
operationId: API_OPERATIONS.creditCoinSellerStock,
|
||||||
@ -568,13 +552,6 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "resource-group:update",
|
permission: "resource-group:update",
|
||||||
permissions: ["resource-group:update"]
|
permissions: ["resource-group:update"]
|
||||||
},
|
},
|
||||||
downloadJobArtifact: {
|
|
||||||
method: "GET",
|
|
||||||
operationId: API_OPERATIONS.downloadJobArtifact,
|
|
||||||
path: "/v1/jobs/{id}/artifact",
|
|
||||||
permission: "job:view",
|
|
||||||
permissions: ["job:view"]
|
|
||||||
},
|
|
||||||
enableCountry: {
|
enableCountry: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
operationId: API_OPERATIONS.enableCountry,
|
operationId: API_OPERATIONS.enableCountry,
|
||||||
@ -687,6 +664,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "room-config:view",
|
permission: "room-config:view",
|
||||||
permissions: ["room-config:view"]
|
permissions: ["room-config:view"]
|
||||||
},
|
},
|
||||||
|
getRoomTreasureConfig: {
|
||||||
|
method: "GET",
|
||||||
|
operationId: API_OPERATIONS.getRoomTreasureConfig,
|
||||||
|
path: "/v1/admin/activity/room-treasure/config",
|
||||||
|
permission: "room-treasure:view",
|
||||||
|
permissions: ["room-treasure:view"]
|
||||||
|
},
|
||||||
getSevenDayCheckInConfig: {
|
getSevenDayCheckInConfig: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.getSevenDayCheckInConfig,
|
operationId: API_OPERATIONS.getSevenDayCheckInConfig,
|
||||||
@ -839,13 +823,6 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "host:view",
|
permission: "host:view",
|
||||||
permissions: ["host:view"]
|
permissions: ["host:view"]
|
||||||
},
|
},
|
||||||
listJobs: {
|
|
||||||
method: "GET",
|
|
||||||
operationId: API_OPERATIONS.listJobs,
|
|
||||||
path: "/v1/jobs",
|
|
||||||
permission: "job:view",
|
|
||||||
permissions: ["job:view"]
|
|
||||||
},
|
|
||||||
listLevelConfig: {
|
listLevelConfig: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.listLevelConfig,
|
operationId: API_OPERATIONS.listLevelConfig,
|
||||||
@ -1268,6 +1245,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "room-config:update",
|
permission: "room-config:update",
|
||||||
permissions: ["room-config:update"]
|
permissions: ["room-config:update"]
|
||||||
},
|
},
|
||||||
|
updateRoomTreasureConfig: {
|
||||||
|
method: "PUT",
|
||||||
|
operationId: API_OPERATIONS.updateRoomTreasureConfig,
|
||||||
|
path: "/v1/admin/activity/room-treasure/config",
|
||||||
|
permission: "room-treasure:update",
|
||||||
|
permissions: ["room-treasure:update"]
|
||||||
|
},
|
||||||
updateSevenDayCheckInConfig: {
|
updateSevenDayCheckInConfig: {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
operationId: API_OPERATIONS.updateSevenDayCheckInConfig,
|
operationId: API_OPERATIONS.updateSevenDayCheckInConfig,
|
||||||
|
|||||||
156
src/shared/api/generated/schema.d.ts
vendored
156
src/shared/api/generated/schema.d.ts
vendored
@ -148,6 +148,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/admin/activity/room-treasure/config": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getRoomTreasureConfig"];
|
||||||
|
put: operations["updateRoomTreasureConfig"];
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/admin/activity/seven-day-checkin/claims": {
|
"/admin/activity/seven-day-checkin/claims": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -1524,70 +1540,6 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/exports/users": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get?: never;
|
|
||||||
put?: never;
|
|
||||||
post: operations["createUserExportJob"];
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/jobs": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get: operations["listJobs"];
|
|
||||||
put?: never;
|
|
||||||
post?: never;
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/jobs/{id}/artifact": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get: operations["downloadJobArtifact"];
|
|
||||||
put?: never;
|
|
||||||
post?: never;
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/jobs/{id}/cancel": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get?: never;
|
|
||||||
put?: never;
|
|
||||||
post: operations["cancelJob"];
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/logs/login": {
|
"/logs/login": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -2552,6 +2504,30 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
getRoomTreasureConfig: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
updateRoomTreasureConfig: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
listSevenDayCheckInClaims: {
|
listSevenDayCheckInClaims: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -4098,58 +4074,6 @@ export interface operations {
|
|||||||
200: components["responses"]["DashboardOverviewResponse"];
|
200: components["responses"]["DashboardOverviewResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
createUserExportJob: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
200: components["responses"]["EmptyResponse"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
listJobs: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
200: components["responses"]["EmptyResponse"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
downloadJobArtifact: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path: {
|
|
||||||
id: components["parameters"]["Id"];
|
|
||||||
};
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
200: components["responses"]["EmptyResponse"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
cancelJob: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path: {
|
|
||||||
id: components["parameters"]["Id"];
|
|
||||||
};
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
200: components["responses"]["EmptyResponse"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
listLoginLogs: {
|
listLoginLogs: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
|
|||||||
@ -359,7 +359,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
height: var(--control-height);
|
height: var(--control-height);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: 0;
|
||||||
padding: 0 var(--space-3);
|
padding: 0 var(--space-3);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-control);
|
border-radius: var(--radius-control);
|
||||||
@ -470,7 +470,7 @@
|
|||||||
.nav-list {
|
.nav-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
gap: var(--space-3);
|
gap: var(--space-2);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
|||||||
@ -11,7 +11,23 @@ export default defineConfig({
|
|||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": new URL("./src", import.meta.url).pathname
|
"@": new URL("./src", import.meta.url).pathname
|
||||||
}
|
},
|
||||||
|
dedupe: ["react", "react-dom", "@emotion/react", "@emotion/styled"]
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
include: [
|
||||||
|
"@emotion/react",
|
||||||
|
"@emotion/styled",
|
||||||
|
"@mui/material/MenuItem",
|
||||||
|
"@mui/material/Select",
|
||||||
|
"@mui/material/TextField",
|
||||||
|
"@mui/material/styles",
|
||||||
|
"react",
|
||||||
|
"react-dom",
|
||||||
|
"react-dom/client",
|
||||||
|
"react/jsx-dev-runtime",
|
||||||
|
"react/jsx-runtime"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user