diff --git a/AGENTS.md b/AGENTS.md index 0944fb9..8abc3c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ - CSS token:`src/styles/tokens.css`。 - 样式入口:`src/styles/app.css`,实际样式按 `src/styles/*.css`、`src/features/*/*.css` 和 `*.module.css` 拆分。 - 服务端状态:使用 `@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/features//`:业务模块,内部放 `api.ts`、`routes.js`、`permissions.js`、`schema.ts`、`hooks/`、`components/`、`pages/`。 - 新增后台业务模块必须优先落在 `src/features/`,不要重新创建横向 `pages/`、`api/`、`components/` 目录。 -- 新增模块优先复制 `admin-module.example.json`,改成业务配置后运行 `pnpm gen:module `;它会生成 feature、权限常量、路由接入、OpenAPI 契约和后端 seed/routes 片段。 +- 新增模块优先复制 `admin-module.example.json`,改成业务配置后运行 `pnpm gen:module `;它会生成 feature、权限常量、路由接入和 OpenAPI 契约。 - 只需要预览生成内容时运行 `pnpm gen:module --dry-run`。 - `pnpm scaffold:feature ` 是低层级骨架命令,只有在不需要权限/菜单/契约自动接入时使用。 -- 后端路由或权限变更后先运行 `pnpm gen:api`,只需要同步契约时运行 `pnpm sync:contract`。 +- API 契约或权限变更后运行 `pnpm gen:api`。 - 前端 API 路径必须从 `src/shared/api/generated/endpoints.ts` 取,不要在 feature API 中重新手写路径。 - 表单提交前必须走模块内 `schema.ts` 或共享 schema 校验。 diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index 6fa36a6..06035bf 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -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": { "get": { "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": { "get": { "operationId": "listLoginLogs", diff --git a/package.json b/package.json index 1726df6..ffdb0c5 100644 --- a/package.json +++ b/package.json @@ -8,12 +8,11 @@ "dev": "vite --host 0.0.0.0", "build": "vite build", "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", "lint": "eslint .", "format": "prettier --write .", "scaffold:feature": "node scripts/scaffold-feature.mjs", - "sync:contract": "node scripts/sync-openapi-from-backend.mjs", "test": "vitest run", "typecheck": "tsc --noEmit", "preview": "vite preview --host 0.0.0.0" diff --git a/scripts/generate-admin-module.mjs b/scripts/generate-admin-module.mjs index 21a7dc8..8bb5578 100644 --- a/scripts/generate-admin-module.mjs +++ b/scripts/generate-admin-module.mjs @@ -48,7 +48,7 @@ if (!skipApiGenerate) { 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) { const name = toKebabCase(required(config.name, "name")); @@ -119,8 +119,6 @@ function queueFileWrites(config) { } 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 }); } @@ -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) { const rows = config.permissions @@ -390,7 +365,7 @@ function renderPermissionChecklist(config) { | --- | --- | --- | ${rows} -## 后端路由 +## API 路由 | Method | Path | Permission | Handler | | --- | --- | --- | --- | @@ -527,9 +502,6 @@ function toOpenApiPath(routePath) { return routePath.replace(/:([A-Za-z0-9_]+)/g, "{$1}"); } -function toGinPath(routePath) { - return routePath.replace(/\{([^}]+)\}/g, ":$1"); -} function toKebabCase(value) { return value diff --git a/scripts/sync-openapi-from-backend.mjs b/scripts/sync-openapi-from-backend.mjs deleted file mode 100644 index a1f418c..0000000 --- a/scripts/sync-openapi-from-backend.mjs +++ /dev/null @@ -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)]) - ); -} diff --git a/src/app/layout/Header.jsx b/src/app/layout/Header.jsx index 36d09d9..58b43e3 100644 --- a/src/app/layout/Header.jsx +++ b/src/app/layout/Header.jsx @@ -172,9 +172,9 @@ export function Header({ activeLabel, isSidebarCollapsed, menuItems = [], onRefr setTimeZone(event.target.value)} > diff --git a/src/app/permissions.ts b/src/app/permissions.ts index 4b84f6f..86176a6 100644 --- a/src/app/permissions.ts +++ b/src/app/permissions.ts @@ -106,10 +106,9 @@ export const PERMISSIONS = { sevenDayCheckInUpdate: "seven-day-checkin:update", luckyGiftView: "lucky-gift:view", luckyGiftUpdate: "lucky-gift:update", + roomTreasureView: "room-treasure:view", + roomTreasureUpdate: "room-treasure:update", uploadCreate: "upload:create", - jobView: "job:view", - jobCancel: "job:cancel", - exportCreate: "export:create", } as const; export type PermissionCode = (typeof PERMISSIONS)[keyof typeof PERMISSIONS]; @@ -154,6 +153,7 @@ export const MENU_CODES = { registrationReward: "registration-reward", achievementConfig: "achievement-config", sevenDayCheckIn: "seven-day-checkin", + roomTreasure: "room-treasure", geo: "geo", hostOrg: "host-org", hostOrgCountries: "host-org-countries", @@ -169,7 +169,6 @@ export const MENU_CODES = { paymentRechargeProducts: "payment-recharge-products", games: "games", gameList: "game-list", - jobs: "jobs", } as const; export type MenuCode = (typeof MENU_CODES)[keyof typeof MENU_CODES]; diff --git a/src/app/router/routeConfig.ts b/src/app/router/routeConfig.ts index 1606ade..b3cef8f 100644 --- a/src/app/router/routeConfig.ts +++ b/src/app/router/routeConfig.ts @@ -17,6 +17,7 @@ import { regionBlockRoutes } from "@/features/region-blocks/routes.js"; import { resourceRoutes } from "@/features/resources/routes.js"; import { rolesRoutes } from "@/features/roles/routes.js"; import { roomRoutes } from "@/features/rooms/routes.js"; +import { roomTreasureRoutes } from "@/features/room-treasure/routes.js"; import { sevenDayCheckInRoutes } from "@/features/seven-day-checkin/routes.js"; import { usersRoutes } from "@/features/users/routes.js"; import type { MenuCode } from "@/app/permissions"; @@ -35,6 +36,7 @@ export const adminRoutes: AdminRoute[] = [ ...achievementRoutes, ...registrationRewardRoutes, ...sevenDayCheckInRoutes, + ...roomTreasureRoutes, ...resourceRoutes, ...operationsRoutes, ...luckyGiftRoutes, diff --git a/src/features/payment/hooks/useRechargeProductsPage.js b/src/features/payment/hooks/useRechargeProductsPage.js index c5801f9..833c92f 100644 --- a/src/features/payment/hooks/useRechargeProductsPage.js +++ b/src/features/payment/hooks/useRechargeProductsPage.js @@ -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 { abilities, activeAction, @@ -172,6 +185,7 @@ export function useRechargeProductsPage() { setStatus, status, submitProduct, + toggleProductEnabled, }; } @@ -195,3 +209,23 @@ function formFromProduct(item) { 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+$/, ""); +} diff --git a/src/features/payment/pages/RechargeProductConfigPage.jsx b/src/features/payment/pages/RechargeProductConfigPage.jsx index 8ec5fc2..5980d1c 100644 --- a/src/features/payment/pages/RechargeProductConfigPage.jsx +++ b/src/features/payment/pages/RechargeProductConfigPage.jsx @@ -23,6 +23,7 @@ import { import { DataState } from "@/shared/ui/DataState.jsx"; import { DataTable } from "@/shared/ui/DataTable.jsx"; import { MultiValueAutocomplete } from "@/shared/ui/MultiValueAutocomplete.jsx"; +import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx"; import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js"; import { formatMillis } from "@/shared/utils/time.js"; @@ -219,7 +220,7 @@ function productColumns(page, regionLabels) { { key: "status", label: "状态", - render: (item) => , + render: (item) => , filter: createOptionsColumnFilter({ options: statusOptions, 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 ( + page.toggleProductEnabled(item, event.target.checked)} + /> + ); +} + function ProductActions({ item, page }) { const deleting = page.loadingAction === `delete:${item.productId}`; return ( @@ -292,16 +308,6 @@ function Stack({ primary, secondary }) { ); } -function StatusBadge({ enabled }) { - const tone = enabled ? "succeeded" : "stopped"; - return ( - - - {enabled ? "上架" : "下架"} - - ); -} - function platformLabel(value) { if (value === "android") { return "Android"; @@ -314,7 +320,7 @@ function platformLabel(value) { function regionLabelMap(options) { return options.reduce((labels, option) => { - labels[String(option.value)] = option.label; + labels[String(option.value)] = option.name || option.label; return labels; }, {}); } diff --git a/src/features/roles/hooks/useRolesPage.js b/src/features/roles/hooks/useRolesPage.js index 1c0281f..c9dc713 100644 --- a/src/features/roles/hooks/useRolesPage.js +++ b/src/features/roles/hooks/useRolesPage.js @@ -30,7 +30,6 @@ const permissionGroupDefinitions = [ { key: "hosts", prefixes: ["host"], title: "主播列表" }, { key: "coin-sellers", prefixes: ["coin-seller"], title: "币商列表" }, { key: "logs", prefixes: ["log"], title: "日志审计" }, - { key: "jobs", prefixes: ["job", "export"], title: "任务中心" }, { key: "common", prefixes: ["upload"], title: "通用能力" }, ]; const fallbackPermissionGroup = { key: "other", title: "其他权限" }; diff --git a/src/features/room-treasure/api.ts b/src/features/room-treasure/api.ts new file mode 100644 index 0000000..2fe55e7 --- /dev/null +++ b/src/features/room-treasure/api.ts @@ -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; +type RawLevel = Omit< + RoomTreasureLevelDto, + "igniterRewards" | "inRoomRewards" | "top1Rewards" +> & { + igniterRewards?: RawReward[]; + igniter_rewards?: RawReward[]; + inRoomRewards?: RawReward[]; + in_room_rewards?: RawReward[]; + top1Rewards?: RawReward[]; + top1_rewards?: RawReward[]; +} & Record; +type RawGiftEnergyRule = GiftEnergyRuleDto & Record; +type RawConfig = Omit & { + giftEnergyRules?: RawGiftEnergyRule[]; + gift_energy_rules?: RawGiftEnergyRule[]; + levels?: RawLevel[]; +} & Record; + +export function getRoomTreasureConfig(): Promise { + const endpoint = API_ENDPOINTS.getRoomTreasureConfig; + return apiRequest(apiEndpointPath(API_OPERATIONS.getRoomTreasureConfig), { + method: endpoint.method, + }).then(normalizeConfig); +} + +export function updateRoomTreasureConfig(payload: RoomTreasureConfigPayload): Promise { + const endpoint = API_ENDPOINTS.updateRoomTreasureConfig; + return apiRequest(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(); + 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; +} diff --git a/src/features/room-treasure/components/RoomTreasureConfigEditor.jsx b/src/features/room-treasure/components/RoomTreasureConfigEditor.jsx new file mode 100644 index 0000000..b1719ad --- /dev/null +++ b/src/features/room-treasure/components/RoomTreasureConfigEditor.jsx @@ -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 ( +
+
+
+ +
+ 房间宝箱配置 + 编辑 7 级宝箱物料、奖励池和礼物能量规则 +
+
+
+ + +
+
+ +
+
+
基础配置
+
+ updateRoot(setForm, "enabled", event.target.checked)} + /> + updateRoot(setForm, "broadcastEnabled", event.target.checked)} + /> + updateRoot(setForm, "energySource", value)} + /> + updateRoot(setForm, "openDelayMs", event.target.value)} + /> + updateRoot(setForm, "broadcastScope", value)} + /> + updateRoot(setForm, "broadcastDelayMs", event.target.value)} + /> + updateRoot(setForm, "rewardStackPolicy", value)} + /> +
+
+ +
+ setEditorTab(value)}> + + + + {editorTab === "levels" ? ( +
+ + {level ? ( +
+
+
+ L{level.level} + 当前等级送礼溢出不进入下一级,倒计时期间送礼不累计能量 +
+ + updateLevel(setForm, activeLevelIndex, { energyThreshold: event.target.value }) + } + /> +
+
+ updateLevel(setForm, activeLevelIndex, { coverUrl: value })} + /> + updateLevel(setForm, activeLevelIndex, { animationUrl: value })} + /> + + updateLevel(setForm, activeLevelIndex, { openingAnimationUrl: value }) + } + /> + updateLevel(setForm, activeLevelIndex, { openedImageUrl: value })} + /> +
+
+
+ setRewardTab(value)} + > + {rewardTabs.map(([key, label]) => ( + + ))} + + +
+ +
+
+ ) : null} +
+ ) : ( + + )} +
+
+
+ ); +} + +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 ( + + ); +} + +function RewardTable({ disabled, levelIndex, poolKey, resourceGroups, rewards, setForm }) { + if (!rewards.length) { + return
未配置奖项
; + } + + return ( +
+
+ 资源组 + 权重 + 展示名 + 图标 + +
+ {rewards.map((reward, rewardIndex) => ( +
+ updateReward(setForm, levelIndex, poolKey, rewardIndex, { resourceGroupId: value })} + /> + updateReward(setForm, levelIndex, poolKey, rewardIndex, { weight: event.target.value })} + /> + + updateReward(setForm, levelIndex, poolKey, rewardIndex, { displayName: event.target.value }) + } + /> + updateReward(setForm, levelIndex, poolKey, rewardIndex, { iconUrl: value })} + /> + + + removeReward(setForm, levelIndex, poolKey, rewardIndex)} + > + + + + +
+ ))} +
+ ); +} + +function EnergyRuleEditor({ disabled, form, setForm }) { + return ( +
+
+ 未命中特殊规则的礼物按基础能量来源计算 + +
+ {form.giftEnergyRules.length ? ( +
+
+ 礼物 ID + 礼物类型 + 倍率 ppm + 固定能量 + 排除 + +
+ {form.giftEnergyRules.map((rule, index) => ( +
+ updateEnergyRule(setForm, index, { giftId: event.target.value })} + /> + updateEnergyRule(setForm, index, { giftTypeCode: event.target.value })} + /> + updateEnergyRule(setForm, index, { multiplierPpm: event.target.value })} + /> + updateEnergyRule(setForm, index, { fixedEnergy: event.target.value })} + /> + updateEnergyRule(setForm, index, { excluded: event.target.checked })} + /> + + + removeEnergyRule(setForm, index)} + > + + + + +
+ ))} +
+ ) : ( +
未配置礼物特殊规则
+ )} +
+ ); +} + +function SelectField({ disabled, label, onChange, options, value }) { + return ( + onChange(event.target.value)}> + {options.map(([optionValue, optionLabel]) => ( + + {optionLabel} + + ))} + + ); +} + +function SwitchField({ checked, disabled, label, onChange }) { + return ( +
+ {label} + +
+ ); +} + +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"); +} diff --git a/src/features/room-treasure/components/RoomTreasureConfigSummary.jsx b/src/features/room-treasure/components/RoomTreasureConfigSummary.jsx new file mode 100644 index 0000000..83afeee --- /dev/null +++ b/src/features/room-treasure/components/RoomTreasureConfigSummary.jsx @@ -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 ( +
+
+
+
+ 房间宝箱配置 +
+
+ + {config?.configVersion || "-"} + {formatSeconds(config?.openDelayMs)} + + {config?.broadcastEnabled ? broadcastScopeLabels[config.broadcastScope] || config.broadcastScope : "关闭"} + + {rewardStackPolicyLabels[config?.rewardStackPolicy] || "-"} + + {config?.updatedAtMs ? formatMillis(config.updatedAtMs) : configLoading ? "加载中" : "-"} + +
+ +
+
+
+ + + + +
+
+ ); +} + +function SummaryItem({ children, label }) { + return ( +
+ {label} + {children} +
+ ); +} + +function ConfigStatus({ config, loading }) { + if (!config) { + return ( + + + {loading ? "加载中" : "-"} + + ); + } + + return ( + + + {config.enabled ? "已开启" : "已关闭"} + + ); +} + +function LevelPreview({ config, loading }) { + if (loading && !config) { + return
加载中
; + } + return ( +
+ {(config?.levels || []).map((level) => ( + + L{level.level} + {formatNumber(level.energyThreshold)} + + ))} +
+ ); +} + +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"); +} diff --git a/src/features/room-treasure/hooks/useRoomTreasurePage.js b/src/features/room-treasure/hooks/useRoomTreasurePage.js new file mode 100644 index 0000000..aa8abc0 --- /dev/null +++ b/src/features/room-treasure/hooks/useRoomTreasurePage.js @@ -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); +} diff --git a/src/features/room-treasure/pages/RoomTreasurePage.jsx b/src/features/room-treasure/pages/RoomTreasurePage.jsx new file mode 100644 index 0000000..27ae6e8 --- /dev/null +++ b/src/features/room-treasure/pages/RoomTreasurePage.jsx @@ -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) => ( +
+ L{level.level} + 阈值 {formatNumber(level.energyThreshold)} +
+ ), + }, + { + key: "materials", + label: "物料", + width: "minmax(280px, 1.3fr)", + render: (level) => , + }, + { + key: "inRoomRewards", + label: "在房奖励", + width: "minmax(220px, 1fr)", + render: (level, _index, context) => ( + + ), + }, + { + key: "top1Rewards", + label: "贡献第一", + width: "minmax(220px, 1fr)", + render: (level, _index, context) => , + }, + { + key: "igniterRewards", + label: "点火人", + width: "minmax(220px, 1fr)", + render: (level, _index, context) => ( + + ), + }, +]; + +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 ( + + + page.submitConfig(event, exitEdit)} + /> + + + ); + } + + return ( + + + + + level.level} + /> + + + + ); +} + +function MaterialState({ level }) { + const items = [ + ["封面", level.coverUrl], + ["动效", level.animationUrl], + ["开箱", level.openingAnimationUrl], + ["打开图", level.openedImageUrl], + ]; + return ( +
+ {items.map(([label, url]) => ( + + + {label} + + ))} +
+ ); +} + +function RewardSummary({ groups, rewards = [] }) { + if (!rewards.length) { + return 未配置; + } + return ( +
+ {rewards.length} 个奖项 + {rewards.slice(0, 2).map((reward) => resourceGroupName(groups, reward.resourceGroupId)).join("、")} +
+ ); +} + +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"); +} diff --git a/src/features/room-treasure/permissions.js b/src/features/room-treasure/permissions.js new file mode 100644 index 0000000..7862e2d --- /dev/null +++ b/src/features/room-treasure/permissions.js @@ -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), + }; +} diff --git a/src/features/room-treasure/room-treasure.module.css b/src/features/room-treasure/room-treasure.module.css new file mode 100644 index 0000000..489880a --- /dev/null +++ b/src/features/room-treasure/room-treasure.module.css @@ -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; + } +} diff --git a/src/features/room-treasure/routes.js b/src/features/room-treasure/routes.js new file mode 100644 index 0000000..7ae393e --- /dev/null +++ b/src/features/room-treasure/routes.js @@ -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, + }, +]; diff --git a/src/features/room-treasure/schema.ts b/src/features/room-treasure/schema.ts new file mode 100644 index 0000000..d8cbfe5 --- /dev/null +++ b/src/features/room-treasure/schema.ts @@ -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(); + 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; + +function validateRewards(context: z.RefinementCtx, level: number, key: string, rewards: z.infer[]) { + 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; +} diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index 10bdbf7..fa0f432 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -23,7 +23,6 @@ export const API_OPERATIONS = { appUnbanUser: "appUnbanUser", appUpdateUser: "appUpdateUser", batchUpdateUserStatus: "batchUpdateUserStatus", - cancelJob: "cancelJob", cancelRoomPin: "cancelRoomPin", changePassword: "changePassword", closeAgency: "closeAgency", @@ -51,7 +50,6 @@ export const API_OPERATIONS = { createTaskDefinition: "createTaskDefinition", createTier: "createTier", createUser: "createUser", - createUserExportJob: "createUserExportJob", creditCoinSellerStock: "creditCoinSellerStock", dashboardOverview: "dashboardOverview", deleteAppVersion: "deleteAppVersion", @@ -68,7 +66,6 @@ export const API_OPERATIONS = { disableRegion: "disableRegion", disableResource: "disableResource", disableResourceGroup: "disableResourceGroup", - downloadJobArtifact: "downloadJobArtifact", enableCountry: "enableCountry", enableGift: "enableGift", enableRegion: "enableRegion", @@ -85,6 +82,7 @@ export const API_OPERATIONS = { getResourceGroup: "getResourceGroup", getRoleDataScopes: "getRoleDataScopes", getRoomConfig: "getRoomConfig", + getRoomTreasureConfig: "getRoomTreasureConfig", getSevenDayCheckInConfig: "getSevenDayCheckInConfig", getUser: "getUser", grantResource: "grantResource", @@ -107,7 +105,6 @@ export const API_OPERATIONS = { listGiftTypes: "listGiftTypes", listH5Links: "listH5Links", listHosts: "listHosts", - listJobs: "listJobs", listLevelConfig: "listLevelConfig", listLoginLogs: "listLoginLogs", listLuckyGiftDraws: "listLuckyGiftDraws", @@ -170,6 +167,7 @@ export const API_OPERATIONS = { updateRole: "updateRole", updateRoom: "updateRoom", updateRoomConfig: "updateRoomConfig", + updateRoomTreasureConfig: "updateRoomTreasureConfig", updateSevenDayCheckInConfig: "updateSevenDayCheckInConfig", updateTaskDefinition: "updateTaskDefinition", updateTier: "updateTier", @@ -255,13 +253,6 @@ export const API_ENDPOINTS: Record = { permission: "user:status", permissions: ["user:status"] }, - cancelJob: { - method: "POST", - operationId: API_OPERATIONS.cancelJob, - path: "/v1/jobs/{id}/cancel", - permission: "job:cancel", - permissions: ["job:cancel"] - }, cancelRoomPin: { method: "DELETE", operationId: API_OPERATIONS.cancelRoomPin, @@ -449,13 +440,6 @@ export const API_ENDPOINTS: Record = { permission: "user:create", permissions: ["user:create"] }, - createUserExportJob: { - method: "POST", - operationId: API_OPERATIONS.createUserExportJob, - path: "/v1/exports/users", - permission: "export:create", - permissions: ["export:create"] - }, creditCoinSellerStock: { method: "POST", operationId: API_OPERATIONS.creditCoinSellerStock, @@ -568,13 +552,6 @@ export const API_ENDPOINTS: Record = { permission: "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: { method: "POST", operationId: API_OPERATIONS.enableCountry, @@ -687,6 +664,13 @@ export const API_ENDPOINTS: Record = { permission: "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: { method: "GET", operationId: API_OPERATIONS.getSevenDayCheckInConfig, @@ -839,13 +823,6 @@ export const API_ENDPOINTS: Record = { permission: "host:view", permissions: ["host:view"] }, - listJobs: { - method: "GET", - operationId: API_OPERATIONS.listJobs, - path: "/v1/jobs", - permission: "job:view", - permissions: ["job:view"] - }, listLevelConfig: { method: "GET", operationId: API_OPERATIONS.listLevelConfig, @@ -1268,6 +1245,13 @@ export const API_ENDPOINTS: Record = { permission: "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: { method: "PUT", operationId: API_OPERATIONS.updateSevenDayCheckInConfig, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 32f1309..6c05cb8 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -148,6 +148,22 @@ export interface paths { patch?: 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": { parameters: { query?: never; @@ -1524,70 +1540,6 @@ export interface paths { patch?: 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": { parameters: { query?: never; @@ -2552,6 +2504,30 @@ export interface operations { 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: { parameters: { query?: never; @@ -4098,58 +4074,6 @@ export interface operations { 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: { parameters: { query?: { diff --git a/src/styles/layout.css b/src/styles/layout.css index 7c83f89..7c236d8 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -359,7 +359,7 @@ display: flex; height: var(--control-height); align-items: center; - gap: var(--space-2); + gap: 0; padding: 0 var(--space-3); border: 1px solid var(--border); border-radius: var(--radius-control); @@ -470,7 +470,7 @@ .nav-list { display: grid; flex: 1; - gap: var(--space-3); + gap: var(--space-2); min-height: 0; align-content: start; overflow-x: hidden; diff --git a/vite.config.js b/vite.config.js index f3105d3..548c739 100644 --- a/vite.config.js +++ b/vite.config.js @@ -11,7 +11,23 @@ export default defineConfig({ resolve: { alias: { "@": 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()], server: {