From bcc38541141b79eacc35033872c3da0700c5e659 Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 15 Jul 2026 16:05:28 +0800 Subject: [PATCH 01/15] feat(admin): add external management system --- contracts/admin-openapi.json | 261 ++++++++++++ deploy/nginx.conf | 9 + external-admin/index.html | 13 + external-admin/src/ExternalAdminApp.jsx | 53 +++ external-admin/src/api/auth.js | 94 +++++ external-admin/src/api/auth.test.js | 63 +++ external-admin/src/api/business.js | 321 +++++++++++++++ external-admin/src/api/business.test.js | 187 +++++++++ external-admin/src/api/client.js | 149 +++++++ external-admin/src/api/client.test.js | 74 ++++ external-admin/src/api/normalizers.js | 59 +++ .../src/auth/ExternalAuthProvider.jsx | 73 ++++ external-admin/src/auth/RouteGuards.jsx | 58 +++ .../src/components/BannerCreateDialog.jsx | 110 +++++ .../components/BannerCreateDialog.test.jsx | 20 + .../src/components/GrantDialogs.jsx | 60 +++ .../src/components/RoomEditDialog.jsx | 30 ++ external-admin/src/components/UserDialogs.jsx | 69 ++++ external-admin/src/config/capabilities.js | 31 ++ .../src/config/entry-routing.test.js | 43 ++ .../src/layout/ExternalAdminLayout.jsx | 128 ++++++ external-admin/src/main.jsx | 27 ++ external-admin/src/pages/BannersPage.jsx | 49 +++ external-admin/src/pages/BansPage.jsx | 61 +++ .../src/pages/ChangePasswordPage.jsx | 69 ++++ .../src/pages/ChangePasswordPage.test.jsx | 47 +++ external-admin/src/pages/GrantsPage.jsx | 106 +++++ external-admin/src/pages/LoginPage.jsx | 123 ++++++ external-admin/src/pages/OrganizationPage.jsx | 70 ++++ external-admin/src/pages/OverviewPage.jsx | 50 +++ .../src/pages/OverviewPage.test.jsx | 31 ++ external-admin/src/pages/RoomsPage.jsx | 63 +++ external-admin/src/pages/UsersPage.jsx | 77 ++++ external-admin/src/shared/ConfirmDialog.jsx | 16 + .../src/shared/ExternalImageUploadField.jsx | 64 +++ external-admin/src/shared/PageComponents.jsx | 117 ++++++ external-admin/src/shared/usePagedResource.js | 41 ++ external-admin/src/styles/index.css | 299 ++++++++++++++ src/app/navigation/menu.js | 1 + src/app/navigation/menu.test.jsx | 17 + src/app/permissions.ts | 6 + src/app/router/routeConfig.ts | 2 + src/features/external-admin-users/api.test.ts | 121 ++++++ src/features/external-admin-users/api.ts | 223 +++++++++++ .../ExternalAdminUserFormDialog.jsx | 113 ++++++ .../ExternalAdminUserPasswordDialog.jsx | 55 +++ .../external-admin-users.module.css | 61 +++ .../hooks/useExternalAdminUsersPage.js | 377 ++++++++++++++++++ .../hooks/useExternalAdminUsersPage.test.jsx | 191 +++++++++ .../pages/ExternalAdminUsersPage.jsx | 217 ++++++++++ .../external-admin-users/permissions.js | 13 + src/features/external-admin-users/routes.js | 13 + .../external-admin-users/schema.test.ts | 65 +++ src/features/external-admin-users/schema.ts | 63 +++ src/features/roles/hooks/useRolesPage.js | 9 +- src/shared/api/generated/endpoints.ts | 40 ++ src/shared/api/generated/schema.d.ts | 242 +++++++++++ vite.config.js | 15 +- 58 files changed, 5056 insertions(+), 3 deletions(-) create mode 100644 external-admin/index.html create mode 100644 external-admin/src/ExternalAdminApp.jsx create mode 100644 external-admin/src/api/auth.js create mode 100644 external-admin/src/api/auth.test.js create mode 100644 external-admin/src/api/business.js create mode 100644 external-admin/src/api/business.test.js create mode 100644 external-admin/src/api/client.js create mode 100644 external-admin/src/api/client.test.js create mode 100644 external-admin/src/api/normalizers.js create mode 100644 external-admin/src/auth/ExternalAuthProvider.jsx create mode 100644 external-admin/src/auth/RouteGuards.jsx create mode 100644 external-admin/src/components/BannerCreateDialog.jsx create mode 100644 external-admin/src/components/BannerCreateDialog.test.jsx create mode 100644 external-admin/src/components/GrantDialogs.jsx create mode 100644 external-admin/src/components/RoomEditDialog.jsx create mode 100644 external-admin/src/components/UserDialogs.jsx create mode 100644 external-admin/src/config/capabilities.js create mode 100644 external-admin/src/config/entry-routing.test.js create mode 100644 external-admin/src/layout/ExternalAdminLayout.jsx create mode 100644 external-admin/src/main.jsx create mode 100644 external-admin/src/pages/BannersPage.jsx create mode 100644 external-admin/src/pages/BansPage.jsx create mode 100644 external-admin/src/pages/ChangePasswordPage.jsx create mode 100644 external-admin/src/pages/ChangePasswordPage.test.jsx create mode 100644 external-admin/src/pages/GrantsPage.jsx create mode 100644 external-admin/src/pages/LoginPage.jsx create mode 100644 external-admin/src/pages/OrganizationPage.jsx create mode 100644 external-admin/src/pages/OverviewPage.jsx create mode 100644 external-admin/src/pages/OverviewPage.test.jsx create mode 100644 external-admin/src/pages/RoomsPage.jsx create mode 100644 external-admin/src/pages/UsersPage.jsx create mode 100644 external-admin/src/shared/ConfirmDialog.jsx create mode 100644 external-admin/src/shared/ExternalImageUploadField.jsx create mode 100644 external-admin/src/shared/PageComponents.jsx create mode 100644 external-admin/src/shared/usePagedResource.js create mode 100644 external-admin/src/styles/index.css create mode 100644 src/features/external-admin-users/api.test.ts create mode 100644 src/features/external-admin-users/api.ts create mode 100644 src/features/external-admin-users/components/ExternalAdminUserFormDialog.jsx create mode 100644 src/features/external-admin-users/components/ExternalAdminUserPasswordDialog.jsx create mode 100644 src/features/external-admin-users/external-admin-users.module.css create mode 100644 src/features/external-admin-users/hooks/useExternalAdminUsersPage.js create mode 100644 src/features/external-admin-users/hooks/useExternalAdminUsersPage.test.jsx create mode 100644 src/features/external-admin-users/pages/ExternalAdminUsersPage.jsx create mode 100644 src/features/external-admin-users/permissions.js create mode 100644 src/features/external-admin-users/routes.js create mode 100644 src/features/external-admin-users/schema.test.ts create mode 100644 src/features/external-admin-users/schema.ts diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index 837af5f..5e0167b 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -6121,6 +6121,125 @@ "x-permissions": ["app-user:view"] } }, + "/admin/external-admin-users": { + "get": { + "operationId": "listExternalAdminUsers", + "parameters": [ + { "$ref": "#/components/parameters/AppCodeHeader" }, + { "$ref": "#/components/parameters/Page" }, + { "$ref": "#/components/parameters/PageSize" }, + { "$ref": "#/components/parameters/Keyword" }, + { "$ref": "#/components/parameters/Status" } + ], + "responses": { + "200": { + "$ref": "#/components/responses/ExternalAdminUserPageResponse" + } + }, + "x-permission": "external-admin-user:view", + "x-permissions": ["external-admin-user:view"] + }, + "post": { + "operationId": "createExternalAdminUser", + "parameters": [ + { "$ref": "#/components/parameters/AppCodeHeader" } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalAdminUserCreateInput" + } + } + } + }, + "responses": { + "201": { + "$ref": "#/components/responses/ExternalAdminUserResponse" + } + }, + "x-permission": "external-admin-user:create", + "x-permissions": ["external-admin-user:create"] + } + }, + "/admin/external-admin-users/target": { + "get": { + "operationId": "lookupExternalAdminUserTarget", + "parameters": [ + { "$ref": "#/components/parameters/AppCodeHeader" }, + { + "in": "query", + "name": "display_user_id", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/ExternalAdminUserTargetResponse" + } + }, + "x-permission": "external-admin-user:create", + "x-permissions": ["external-admin-user:create"] + } + }, + "/admin/external-admin-users/{id}/status": { + "patch": { + "operationId": "updateExternalAdminUserStatus", + "parameters": [ + { "$ref": "#/components/parameters/AppCodeHeader" }, + { "$ref": "#/components/parameters/Id" } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalAdminUserStatusInput" + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/ExternalAdminUserResponse" + } + }, + "x-permission": "external-admin-user:status", + "x-permissions": ["external-admin-user:status"] + } + }, + "/admin/external-admin-users/{id}/password": { + "post": { + "operationId": "resetExternalAdminUserPassword", + "parameters": [ + { "$ref": "#/components/parameters/AppCodeHeader" }, + { "$ref": "#/components/parameters/Id" } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalAdminUserPasswordInput" + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/ExternalAdminUserResponse" + } + }, + "x-permission": "external-admin-user:reset-password", + "x-permissions": ["external-admin-user:reset-password"] + } + }, "/exports/app-users": { "post": { "operationId": "appExportUsers", @@ -8550,6 +8669,30 @@ } } }, + "ExternalAdminUserPageResponse": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ApiResponseExternalAdminUserPage" } + } + } + }, + "ExternalAdminUserResponse": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ApiResponseExternalAdminUser" } + } + } + }, + "ExternalAdminUserTargetResponse": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ApiResponseExternalAdminUserTarget" } + } + } + }, "AgencyPageResponse": { "description": "OK", "content": { @@ -9393,6 +9536,124 @@ } ] }, + "ApiResponseExternalAdminUserPage": { + "allOf": [ + { "$ref": "#/components/schemas/Envelope" }, + { + "type": "object", + "properties": { + "data": { "$ref": "#/components/schemas/ApiPageExternalAdminUser" } + } + } + ] + }, + "ApiResponseExternalAdminUser": { + "allOf": [ + { "$ref": "#/components/schemas/Envelope" }, + { + "type": "object", + "properties": { + "data": { "$ref": "#/components/schemas/ExternalAdminUser" } + } + } + ] + }, + "ApiResponseExternalAdminUserTarget": { + "allOf": [ + { "$ref": "#/components/schemas/Envelope" }, + { + "type": "object", + "properties": { + "data": { "$ref": "#/components/schemas/ExternalAdminUserTargetLookup" } + } + } + ] + }, + "ApiPageExternalAdminUser": { + "type": "object", + "required": ["items", "page", "pageSize", "total"], + "properties": { + "items": { + "type": "array", + "items": { "$ref": "#/components/schemas/ExternalAdminUser" } + }, + "page": { "type": "integer", "minimum": 1 }, + "pageSize": { "type": "integer", "minimum": 1 }, + "total": { "type": "integer", "format": "int64", "minimum": 0 } + } + }, + "ExternalAdminUserTarget": { + "type": "object", + "required": ["userId", "displayUserId", "status"], + "properties": { + "userId": { "type": "string" }, + "displayUserId": { "type": "string" }, + "defaultDisplayUserId": { "type": "string" }, + "prettyDisplayUserId": { "type": "string" }, + "prettyId": { "type": "string" }, + "username": { "type": "string" }, + "avatar": { "type": "string" }, + "status": { "type": "string" } + } + }, + "ExternalAdminUserTargetLookup": { + "type": "object", + "required": ["linkedUser"], + "properties": { + "linkedUser": { "$ref": "#/components/schemas/ExternalAdminUserTarget" }, + "existingExternalAdminUser": { "$ref": "#/components/schemas/ExternalAdminUser" } + } + }, + "ExternalAdminUser": { + "type": "object", + "required": ["id", "appCode", "username", "status", "linkedUser", "permissions", "createdAtMs"], + "properties": { + "id": { "type": "integer" }, + "appCode": { "type": "string" }, + "username": { "type": "string" }, + "status": { "type": "string", "enum": ["active", "disabled"] }, + "linkedUser": { "$ref": "#/components/schemas/ExternalAdminUserTarget" }, + "permissions": { + "type": "array", + "items": { "type": "string" } + }, + "createdByAdminId": { "type": "integer" }, + "lastLoginAtMs": { "type": "integer", "format": "int64", "nullable": true }, + "createdAtMs": { "type": "integer", "format": "int64" }, + "updatedAtMs": { "type": "integer", "format": "int64" } + } + }, + "ExternalAdminUserCreateInput": { + "type": "object", + "required": ["targetUserId", "username", "password"], + "additionalProperties": false, + "properties": { + "targetUserId": { "type": "string", "minLength": 1 }, + "username": { + "type": "string", + "minLength": 3, + "maxLength": 64, + "pattern": "^[A-Za-z0-9._-]+$" + }, + "password": { "type": "string", "minLength": 8, "maxLength": 72 } + } + }, + "ExternalAdminUserStatusInput": { + "type": "object", + "required": ["status"], + "additionalProperties": false, + "properties": { + "status": { "type": "string", "enum": ["active", "disabled"] } + } + }, + "ExternalAdminUserPasswordInput": { + "type": "object", + "required": ["password"], + "additionalProperties": false, + "properties": { + "password": { "type": "string", "minLength": 8, "maxLength": 72 } + } + }, "ApiPageAppUser": { "type": "object", "required": ["items", "page", "pageSize", "total"], diff --git a/deploy/nginx.conf b/deploy/nginx.conf index dba22a3..cee04e8 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -37,6 +37,15 @@ server { proxy_pass http://127.0.0.1:13100/readyz; } + location = /external-admin { + return 301 /external-admin/$is_args$args; + } + + # 外管使用独立 HTML 入口;深链必须回退到自己的入口,不能落入主后台的 index.html。 + location ^~ /external-admin/ { + try_files $uri $uri/ /external-admin/index.html; + } + location / { try_files $uri $uri/ /index.html; } diff --git a/external-admin/index.html b/external-admin/index.html new file mode 100644 index 0000000..c7759b6 --- /dev/null +++ b/external-admin/index.html @@ -0,0 +1,13 @@ + + + + + + + HYApp 外管 + + +
+ + + diff --git a/external-admin/src/ExternalAdminApp.jsx b/external-admin/src/ExternalAdminApp.jsx new file mode 100644 index 0000000..aaad881 --- /dev/null +++ b/external-admin/src/ExternalAdminApp.jsx @@ -0,0 +1,53 @@ +import { lazy, Suspense } from "react"; +import { Navigate, Route, Routes } from "react-router-dom"; +import { RequireExternalAuth, RequireExternalCapability, RequirePasswordChanged } from "./auth/RouteGuards.jsx"; +import { ExternalAdminLayout } from "./layout/ExternalAdminLayout.jsx"; +import { ExternalPageState } from "./shared/PageComponents.jsx"; + +const LoginPage = lazy(() => import("./pages/LoginPage.jsx")); +const OverviewPage = lazy(() => import("./pages/OverviewPage.jsx")); +const UsersPage = lazy(() => import("./pages/UsersPage.jsx")); +const BansPage = lazy(() => import("./pages/BansPage.jsx")); +const OrganizationPage = lazy(() => import("./pages/OrganizationPage.jsx")); +const RoomsPage = lazy(() => import("./pages/RoomsPage.jsx")); +const GrantsPage = lazy(() => import("./pages/GrantsPage.jsx")); +const BannersPage = lazy(() => import("./pages/BannersPage.jsx")); +const ChangePasswordPage = lazy(() => import("./pages/ChangePasswordPage.jsx")); + +export function ExternalAdminApp() { + return ( + }> + + } path="/login" /> + }> + } path="/change-password" /> + }> + }> + } index /> + } path="/overview" /> + }> + } path="/users" /> + + }> + } path="/bans" /> + + }> + } path="/organization/:kind" /> + + }> + } path="/rooms" /> + + }> + } path="/grants" /> + + }> + } path="/banners" /> + + } path="*" /> + + + + + + ); +} diff --git a/external-admin/src/api/auth.js b/external-admin/src/api/auth.js new file mode 100644 index 0000000..596ac33 --- /dev/null +++ b/external-admin/src/api/auth.js @@ -0,0 +1,94 @@ +import { externalRequest, rememberExternalCsrfToken } from "./client.js"; +import { arrayValue, asRecord, textValue } from "./normalizers.js"; + +// These external-session auth routes intentionally have no main-admin OpenAPI operation. +const EXTERNAL_AUTH_PATHS = Object.freeze({ + apps: "/auth/apps", + changePassword: "/auth/change-password", + login: "/auth/login", + logout: "/auth/logout", + me: "/auth/me" +}); + +export async function listExternalApps() { + const data = await externalRequest(EXTERNAL_AUTH_PATHS.apps, { notifyUnauthorized: false }); + const source = asRecord(data); + return arrayValue(data, source.items, source.apps) + .map((item) => normalizeApp(item)) + .filter((item) => item.appCode); +} + +export async function loginExternal(payload) { + const data = await externalRequest(EXTERNAL_AUTH_PATHS.login, { + body: { + account: String(payload.account || "").trim(), + appCode: String(payload.appCode || "").trim(), + password: String(payload.password || "") + }, + method: "POST", + notifyUnauthorized: false + }); + rememberExternalCsrfToken(asRecord(data).csrfToken); + return normalizeSession(data); +} + +export async function getExternalSession() { + const data = await externalRequest(EXTERNAL_AUTH_PATHS.me, { notifyUnauthorized: false }); + rememberExternalCsrfToken(asRecord(data).csrfToken); + return normalizeSession(data); +} + +export async function logoutExternal() { + try { + await externalRequest(EXTERNAL_AUTH_PATHS.logout, { method: "POST", notifyUnauthorized: false }); + } finally { + rememberExternalCsrfToken(""); + } +} + +export async function changeExternalPassword(payload) { + const data = await externalRequest(EXTERNAL_AUTH_PATHS.changePassword, { + body: { + currentPassword: payload.oldPassword, + newPassword: payload.newPassword + }, + method: "POST" + }); + return data ? normalizeSession(data) : null; +} + +export function normalizeSession(data) { + const root = asRecord(data); + const accountRecord = asRecord(root.account); + const linkedUser = asRecord(root.user || root.linkedUser || root.linked_user); + const legacyAccount = asRecord(root.externalAdminUser || root.external_admin_user); + const app = asRecord(root.app || linkedUser.app || accountRecord.app); + const capabilities = [ + ...arrayValue(root.capabilities), + ...arrayValue(root.permissions), + ...arrayValue(accountRecord.capabilities, accountRecord.permissions), + ...arrayValue(linkedUser.capabilities, linkedUser.permissions) + ] + .map(String) + .filter(Boolean); + + return { + account: textValue(accountRecord.username, accountRecord.account, legacyAccount.username, legacyAccount.account, root.accountName, typeof root.account === "string" ? root.account : "", root.username), + appCode: textValue(root.appCode, root.app_code, app.appCode, app.app_code, accountRecord.appCode, accountRecord.app_code, linkedUser.appCode, linkedUser.app_code), + appName: textValue(app.name, app.appName, app.app_name, root.appName, root.app_name), + capabilities: [...new Set(capabilities)], + displayName: textValue(linkedUser.displayName, linkedUser.display_name, linkedUser.name, linkedUser.username, accountRecord.username, legacyAccount.username, root.displayName), + id: textValue(accountRecord.id, accountRecord.externalAdminUserId, accountRecord.external_admin_user_id, legacyAccount.id, root.accountId, root.account_id), + passwordChangeRequired: Boolean( + root.passwordChangeRequired ?? root.password_change_required ?? accountRecord.passwordChangeRequired ?? accountRecord.password_change_required + ) + }; +} + +function normalizeApp(value) { + const item = typeof value === "string" ? { appCode: value } : asRecord(value); + return { + appCode: textValue(item.appCode, item.app_code, item.code, item.value), + name: textValue(item.name, item.appName, item.app_name, item.label, item.appCode, item.code) + }; +} diff --git a/external-admin/src/api/auth.test.js b/external-admin/src/api/auth.test.js new file mode 100644 index 0000000..b509f0a --- /dev/null +++ b/external-admin/src/api/auth.test.js @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { changeExternalPassword, listExternalApps, normalizeSession } from "./auth.js"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("normalizeSession", () => { + test("normalizes account objects, fixed app and capabilities without stringifying objects", () => { + expect(normalizeSession({ + account: { id: "ext-1", username: "fami-manager" }, + app: { appCode: "fami", appName: "Fami" }, + capabilities: ["user:list"], + passwordChangeRequired: true + })).toEqual(expect.objectContaining({ + account: "fami-manager", + appCode: "fami", + appName: "Fami", + capabilities: ["user:list"], + passwordChangeRequired: true + })); + }); + + test("merges permission aliases from root and bound user", () => { + const session = normalizeSession({ + appCode: "lalu", + permissions: ["host:list"], + user: { permissions: ["team:view"], username: "lalu-ops" } + }); + expect(session.capabilities).toEqual(["host:list", "team:view"]); + }); + + test("keeps the external account separate from the linked App user", () => { + const session = normalizeSession({ + account: { id: "external-account-8", username: "fami-manager" }, + app: { appCode: "fami", appName: "Fami" }, + user: { userId: "app-user-9", username: "App Nickname" } + }); + expect(session.account).toBe("fami-manager"); + expect(session.displayName).toBe("App Nickname"); + expect(session.id).toBe("external-account-8"); + }); +}); + +describe("external auth API contract", () => { + test("reads appName from auth/apps items", async () => { + vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ code: 0, data: { items: [{ appCode: "fami", appName: "Fami" }], total: 1 } }))); + await expect(listExternalApps()).resolves.toEqual([{ appCode: "fami", name: "Fami" }]); + }); + + test("change-password sends currentPassword and never oldPassword", async () => { + const fetchMock = vi.fn(async () => jsonResponse({ code: 0, data: {} })); + vi.stubGlobal("fetch", fetchMock); + + await changeExternalPassword({ newPassword: "new-password", oldPassword: "current-password" }); + + expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ currentPassword: "current-password", newPassword: "new-password" }); + }); +}); + +function jsonResponse(body, status = 200) { + return new Response(JSON.stringify(body), { headers: { "content-type": "application/json" }, status }); +} diff --git a/external-admin/src/api/business.js b/external-admin/src/api/business.js new file mode 100644 index 0000000..e42aaff --- /dev/null +++ b/external-admin/src/api/business.js @@ -0,0 +1,321 @@ +import { API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints"; +import { validatePublicAppJumpParam } from "@/shared/app-jump/appJumpConfig.js"; +import { externalRequest } from "./client.js"; +import { asRecord, booleanValue, entityId, normalizePage, numberValue, textValue } from "./normalizers.js"; + +// my-team is external-session scoped and is not part of the main-admin OpenAPI contract yet. +const EXTERNAL_ONLY_PATHS = Object.freeze({ myTeamAgencies: "/admin/my-team/agencies" }); + +export function listUsers(query) { + return externalRequest(externalOperationPath(API_OPERATIONS.appListUsers), { query }).then((data) => normalizePage(data, normalizeUser)); +} + +export function listBannedUsers(query) { + return externalRequest(externalOperationPath(API_OPERATIONS.appListBannedUsers), { query }).then((data) => normalizePage(data, normalizeBannedUser)); +} + +export function updateUser(userId, payload) { + return externalRequest(externalOperationPath(API_OPERATIONS.appUpdateUser, { id: userId }), { body: payload, method: "PATCH" }); +} + +export function banUser(userId, payload) { + return externalRequest(externalOperationPath(API_OPERATIONS.appBanUser, { id: userId }), { body: payload, method: "POST" }); +} + +export function unbanUser(userId, payload = {}) { + return externalRequest(externalOperationPath(API_OPERATIONS.appUnbanUser, { id: userId }), { body: payload, method: "POST" }); +} + +export function updateUserLevels(userId, payload) { + return externalRequest(externalOperationPath(API_OPERATIONS.appAdjustUserLevels, { id: userId }), { body: payload, method: "PUT" }); +} + +export async function grantUserVip(targetUserId, payload) { + const [programData, levelsData] = await Promise.all([ + externalRequest(externalOperationPath(API_OPERATIONS.getVipProgram)), + externalRequest(externalOperationPath(API_OPERATIONS.getVipConfig)) + ]); + const programRoot = asRecord(programData); + const program = asRecord(programRoot.config || programRoot.programConfig || programRoot.program_config || programRoot); + const levelsRoot = asRecord(levelsData); + const levels = Array.isArray(levelsData) ? levelsData : levelsRoot.levels || []; + const level = Number(payload.level); + + if (program.status === "disabled") { + throw new Error("当前 App 的 VIP 体系已停用"); + } + if (Array.isArray(levels) && levels.length && !levels.some((item) => Number(item.level) === level)) { + throw new Error("请选择当前 App 已配置的 VIP 等级"); + } + + const command = { + commandId: payload.commandId, + level, + reason: payload.reason, + targetUserId + }; + if (program.grantMode === "trial_card" || program.grant_mode === "trial_card") { + return externalRequest(externalOperationPath(API_OPERATIONS.grantVipTrialCard), { + body: { ...command, durationMs: Number(payload.durationMs) }, + method: "POST" + }); + } + return externalRequest(externalOperationPath(API_OPERATIONS.grantVip), { body: command, method: "POST" }); +} + +export function listOrganizations(kind, query = {}) { + const routes = { + agencies: externalOperationPath(API_OPERATIONS.listAgencies), + bds: externalOperationPath(API_OPERATIONS.listBDs), + "bd-leaders": externalOperationPath(API_OPERATIONS.listManagers), + hosts: externalOperationPath(API_OPERATIONS.listHosts), + managers: externalOperationPath(API_OPERATIONS.listManagers), + "super-admins": externalOperationPath(API_OPERATIONS.listBDLeaders), + team: EXTERNAL_ONLY_PATHS.myTeamAgencies + }; + if (!routes[kind]) { + throw new Error("该组织列表尚无安全的数据接口"); + } + const scopedQuery = kind === "super-admins" ? { ...query, status: query.status || "active" } : query; + return externalRequest(routes[kind], { query: scopedQuery }).then((data) => { + const page = normalizePage(data, normalizeOrganization); + if (kind !== "team") { + return page; + } + const source = asRecord(data); + return { + ...page, + totalBdLeaders: numberValue(source.totalBdLeaders ?? source.total_bd_leaders), + totalBds: numberValue(source.totalBds ?? source.total_bds), + truncated: booleanValue(source.truncated) + }; + }); +} + +export function listRooms(query) { + return externalRequest(externalOperationPath(API_OPERATIONS.listRooms), { query }).then((data) => normalizePage(data, normalizeRoom)); +} + +export function updateRoom(roomId, payload) { + return externalRequest(externalOperationPath(API_OPERATIONS.updateRoom, { room_id: roomId }), { body: buildRoomUpdatePayload(payload), method: "PATCH" }); +} + +export function buildRoomUpdatePayload(payload) { + const regionText = String(payload.visibleRegionId ?? "").trim(); + const visibleRegionId = regionText ? Number(regionText) : 0; + if (!Number.isSafeInteger(visibleRegionId) || visibleRegionId < 0) { + throw new Error("可见区域 ID 必须是安全的正整数"); + } + return { + coverUrl: String(payload.coverUrl || "").trim(), + description: String(payload.description || "").trim(), + title: String(payload.title || "").trim(), + visibleRegionId + }; +} + +export function listResources(query) { + return externalRequest(externalOperationPath(API_OPERATIONS.listResources), { query }).then((data) => normalizePage(data, normalizeResource)); +} + +export function listResourceGrants(query) { + return externalRequest(externalOperationPath(API_OPERATIONS.listResourceGrants), { query }).then((data) => normalizePage(data)); +} + +export async function lookupGrantTarget(userIdOrPrettyId) { + const data = await externalRequest(externalOperationPath(API_OPERATIONS.lookupResourceGrantTarget), { query: { user_id: userIdOrPrettyId } }); + const source = asRecord(data); + const user = asRecord(source.user || source.targetUser || source.target_user); + return { + id: textValue(source.targetUserId, source.target_user_id, source.userId, source.user_id, user.id, user.userId, user.user_id), + prettyId: textValue(source.prettyId, source.pretty_id, user.prettyId, user.pretty_id, user.displayUserId, user.display_user_id), + username: textValue(source.username, source.nickname, user.username, user.nickname) + }; +} + +export function grantResource(payload) { + return externalRequest(externalOperationPath(API_OPERATIONS.grantResource), { body: payload, method: "POST" }); +} + +export function grantPrettyId(payload) { + return externalRequest(externalOperationPath(API_OPERATIONS.grantPrettyId), { body: payload, method: "POST" }); +} + +export function listBanners(query) { + return externalRequest(externalOperationPath(API_OPERATIONS.listBanners), { query }).then((data) => normalizePage(data, normalizeBanner)); +} + +export function createBanner(payload) { + return externalRequest(externalOperationPath(API_OPERATIONS.createBanner), { body: buildBannerPayload(payload), method: "POST" }); +} + +export function buildBannerPayload(payload) { + if (!["h5", "app"].includes(payload.bannerType)) { + throw new Error("Banner 类型不正确"); + } + if (!["android", "ios"].includes(payload.platform)) { + throw new Error("Banner 平台不正确"); + } + if (!["active", "disabled", "expired"].includes(payload.status)) { + throw new Error("Banner 状态不正确"); + } + const displayScopes = [...new Set((payload.displayScopes || []).filter((scope) => ["home", "room", "recharge", "me"].includes(scope)))]; + if (!displayScopes.length) { + throw new Error("请至少选择一个展示范围"); + } + if (!payload.coverUrl) { + throw new Error("请上传 Banner 图片"); + } + if (payload.bannerType === "h5") { + let target; + try { + target = new URL(payload.param); + } catch { + throw new Error("请填写合法的 H5 链接"); + } + if (!["http:", "https:"].includes(target.protocol)) { + throw new Error("H5 链接仅支持 http 或 https"); + } + } + if (payload.bannerType === "app") { + const validation = validatePublicAppJumpParam(payload.param); + if (!validation.valid) { + throw new Error(validation.message); + } + } + if (displayScopes.includes("room") && !payload.roomSmallImageUrl) { + throw new Error("房间范围必须上传房间内小屏图"); + } + const countryCode = String(payload.countryCode || "").trim().toUpperCase(); + if (countryCode && !/^[A-Z]{2,3}$/.test(countryCode)) { + throw new Error("国家码必须是 2-3 位字母"); + } + const regionId = Number(payload.regionId || 0); + if (!Number.isSafeInteger(regionId) || regionId < 0) { + throw new Error("区域 ID 不正确"); + } + const startsAtMs = Number(payload.startsAtMs || 0); + const endsAtMs = Number(payload.endsAtMs || 0); + if (startsAtMs > 0 && endsAtMs > 0 && startsAtMs >= endsAtMs) { + throw new Error("投放结束时间必须晚于开始时间"); + } + + return { + bannerType: payload.bannerType, + countryCode, + coverUrl: payload.coverUrl, + description: String(payload.description || "").trim(), + displayScope: displayScopes[0], + displayScopes, + endsAtMs, + param: String(payload.param || "").trim(), + platform: payload.platform, + regionId, + roomSmallImageUrl: displayScopes.includes("room") ? payload.roomSmallImageUrl : "", + sortOrder: Number(payload.sortOrder || 0), + startsAtMs, + status: payload.status + }; +} + +export async function uploadExternalImage(file) { + const body = new FormData(); + body.append("file", file); + const data = await externalRequest(externalOperationPath(API_OPERATIONS.uploadImage), { body, method: "POST" }); + const source = asRecord(data); + return textValue(source.url, source.fileUrl, source.file_url, asRecord(source.file).url); +} + +export function normalizeUser(item) { + const levels = asRecord(item.levels); + const wealth = asRecord(levels.wealth); + const charm = asRecord(levels.charm); + const vip = asRecord(item.vip); + const ban = asRecord(item.ban); + return { + avatar: textValue(item.avatar, item.avatarUrl, item.avatar_url), + banned: booleanValue(ban.active, item.banned, item.isBanned, item.is_banned) || ["banned", "disabled"].includes(String(item.status).toLowerCase()), + charmLevel: numberValue(item.charmLevel ?? item.charm_level ?? charm.displayLevel ?? charm.display_level ?? charm.level), + country: textValue(item.country, item.countryCode, item.country_code), + gender: textValue(item.gender), + id: entityId(item), + prettyId: textValue(item.prettyId, item.pretty_id, item.displayUserId, item.display_user_id, item.shortId, item.short_id), + status: textValue(item.status, item.banned ? "banned" : "active"), + username: textValue(item.username, item.nickname, item.nickName, item.name), + vipLevel: numberValue(item.vipLevel ?? item.vip_level ?? vip.level), + wealthLevel: numberValue(item.wealthLevel ?? item.wealth_level ?? wealth.displayLevel ?? wealth.display_level ?? wealth.level) + }; +} + +export function normalizeBannedUser(item) { + // Ban-list rows use the ban-log id as `id`; unban must always target the nested App user id instead. + const target = asRecord(item.target || item.user || item.targetUser || item.target_user); + const user = normalizeUser(target); + return { + ...user, + banned: true, + bannedAtMs: numberValue(item.bannedAtMs ?? item.banned_at_ms ?? item.createdAtMs ?? item.created_at_ms), + banLogId: textValue(item.id, item.banLogId, item.ban_log_id), + expiresAtMs: numberValue(item.expiresAtMs ?? item.expires_at_ms), + id: textValue(target.userId, target.user_id, target.id), + prettyId: textValue(target.displayUserId, target.display_user_id, target.prettyId, target.pretty_id), + reason: textValue(item.reason) + }; +} + +export function normalizeOrganization(item) { + const user = asRecord(item.user || item.profile || item.manager); + const agency = Boolean(item.agencyId ?? item.agency_id); + return { + avatar: textValue(agency ? item.ownerAvatar : "", agency ? item.owner_avatar : "", item.avatar, item.avatarUrl, item.avatar_url, user.avatar), + appCode: textValue(item.appCode, item.app_code), + country: textValue(item.regionName, item.region_name, item.country, item.countryCode, item.country_code, user.regionName, user.region_name, user.country), + id: textValue(item.agencyId, item.agency_id, item.userId, item.user_id, item.id, entityId(user)), + name: textValue(item.name, agency ? item.ownerUsername : "", item.displayName, item.display_name, item.username, user.username, user.name), + parentBdUserId: textValue(item.parentBdUserId, item.parent_bd_user_id), + prettyId: textValue(agency ? item.ownerDisplayUserId : "", agency ? item.owner_display_user_id : "", item.displayUserId, item.display_user_id, item.prettyId, item.pretty_id, user.displayUserId, user.display_user_id, user.prettyId, user.pretty_id), + status: textValue(item.status, item.enabled === false ? "disabled" : "active"), + username: textValue(agency ? item.ownerUsername : "", agency ? item.owner_username : "", item.username, user.username, user.nickname), + userId: textValue(agency ? item.ownerUserId : "", agency ? item.owner_user_id : "", item.userId, item.user_id, user.userId, user.user_id) + }; +} + +function normalizeRoom(item) { + return { + coverUrl: textValue(item.coverUrl, item.cover_url, item.backgroundUrl, item.background_url), + description: textValue(item.description, item.introduction), + id: entityId(item), + ownerName: textValue(item.ownerName, item.owner_name, asRecord(item.owner).username), + status: textValue(item.status, item.liveStatus, item.live_status), + title: textValue(item.title, item.name, item.roomName, item.room_name), + visibleRegionId: textValue(item.visibleRegionId, item.visible_region_id) + }; +} + +function normalizeResource(item) { + return { + coverUrl: textValue(item.coverUrl, item.cover_url, item.iconUrl, item.icon_url), + id: textValue(item.resourceId, item.resource_id, item.id), + name: textValue(item.name, item.resourceName, item.resource_name, item.resourceCode, item.resource_code), + status: textValue(item.status), + type: textValue(item.resourceType, item.resource_type, item.type) + }; +} + +function normalizeBanner(item) { + return { + bannerType: textValue(item.bannerType, item.banner_type, item.type), + coverUrl: textValue(item.coverUrl, item.cover_url, item.imageUrl, item.image_url), + createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms), + description: textValue(item.description, item.title), + id: textValue(item.id, item.bannerId, item.banner_id), + platform: textValue(item.platform), + sortOrder: numberValue(item.sortOrder ?? item.sort_order), + status: textValue(item.status) + }; +} + +function externalOperationPath(operation, params) { + // Generated main-admin paths start with /v1; the external gateway already owns that version prefix in its base URL. + return apiEndpointPath(operation, params).replace(/^\/v1(?=\/)/, ""); +} diff --git a/external-admin/src/api/business.test.js b/external-admin/src/api/business.test.js new file mode 100644 index 0000000..f7df65e --- /dev/null +++ b/external-admin/src/api/business.test.js @@ -0,0 +1,187 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { buildBannerPayload, buildRoomUpdatePayload, createBanner, grantUserVip, listOrganizations, normalizeBannedUser, normalizeOrganization, normalizeUser, unbanUser } from "./business.js"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("normalizeBannedUser", () => { + test("uses nested target user id instead of ban log id for unban actions", () => { + const item = normalizeBannedUser({ + id: "ban-log-9001", + reason: "spam", + target: { + avatar: "https://cdn.test/avatar.png", + displayUserId: "163000", + userId: "318705991371722752", + username: "Fami User" + } + }); + + expect(item.id).toBe("318705991371722752"); + expect(item.banLogId).toBe("ban-log-9001"); + expect(item.prettyId).toBe("163000"); + expect(item.reason).toBe("spam"); + }); + + test("unban request uses the normalized target user id", async () => { + const user = normalizeBannedUser({ id: "ban-log-1", target: { userId: "canonical-user-2" } }); + const fetchMock = vi.fn(async () => jsonResponse({ code: 0, data: {} })); + vi.stubGlobal("fetch", fetchMock); + + await unbanUser(user.id, { reason: "manual" }); + + expect(fetchMock.mock.calls[0][0]).toBe("/api/v1/external/app/users/canonical-user-2/unban"); + }); +}); + +describe("room API payload", () => { + test("converts region id to int64-compatible number and maps empty to all regions", () => { + expect(buildRoomUpdatePayload({ coverUrl: " cover ", description: " desc ", title: " Room ", visibleRegionId: "18" })) + .toEqual({ coverUrl: "cover", description: "desc", title: "Room", visibleRegionId: 18 }); + expect(buildRoomUpdatePayload({ visibleRegionId: "" }).visibleRegionId).toBe(0); + expect(() => buildRoomUpdatePayload({ visibleRegionId: "1.5" })).toThrow("可见区域 ID"); + }); +}); + +describe("real external DTO normalization", () => { + test("reads nested levels, vip and ban state from the App user DTO", () => { + const user = normalizeUser({ + ban: { active: true }, + levels: { charm: { displayLevel: 8 }, wealth: { displayLevel: 12 } }, + userId: "user-1", + vip: { level: 4 } + }); + expect(user).toEqual(expect.objectContaining({ banned: true, charmLevel: 8, vipLevel: 4, wealthLevel: 12 })); + }); + + test("normalizes agency owner fields and ordinary member fields", () => { + expect(normalizeOrganization({ + agencyId: "agency-1", + name: "Fami Guild", + ownerAvatar: "owner.png", + ownerDisplayUserId: "163000", + ownerUserId: "owner-9", + ownerUsername: "Guild Owner", + regionName: "Middle East" + })).toEqual(expect.objectContaining({ + avatar: "owner.png", + id: "agency-1", + name: "Fami Guild", + prettyId: "163000", + userId: "owner-9", + username: "Guild Owner" + })); + expect(normalizeOrganization({ displayUserId: "9988", regionName: "SEA", userId: "bd-1", username: "BD One" })) + .toEqual(expect.objectContaining({ id: "bd-1", name: "BD One", prettyId: "9988" })); + }); + + test("maps BD Manager, Super Admin and team to their real scoped endpoints", async () => { + const fetchMock = vi.fn(async () => jsonResponse({ code: 0, data: { items: [], total: 0 } })); + vi.stubGlobal("fetch", fetchMock); + + await listOrganizations("bd-leaders", { page: 1 }); + await listOrganizations("super-admins", { page: 1 }); + await listOrganizations("team", { page: 1 }); + + expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ + "/api/v1/external/admin/managers?page=1", + "/api/v1/external/admin/bd-leaders?page=1&status=active", + "/api/v1/external/admin/my-team/agencies?page=1" + ]); + }); + + test("preserves scoped team totals and truncation metadata", async () => { + vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ + code: 0, + data: { + agencies: [{ agencyId: "agency-9", ownerUserId: "owner-9", parentBdUserId: "bd-7", status: "active" }], + total: 1, + totalBdLeaders: 2, + totalBds: 7, + truncated: true + } + }))); + + const team = await listOrganizations("team", { page: 1 }); + + expect(team).toEqual(expect.objectContaining({ total: 1, totalBdLeaders: 2, totalBds: 7, truncated: true })); + expect(team.items[0]).toEqual(expect.objectContaining({ id: "agency-9", parentBdUserId: "bd-7", userId: "owner-9" })); + }); +}); + +describe("banner API payload", () => { + test("uses only the real appconfig banner enums and room image contract", () => { + expect(buildBannerPayload({ + bannerType: "h5", + countryCode: "sa", + coverUrl: "https://cdn.test/banner.webp", + description: "Campaign", + displayScopes: ["home", "room"], + param: "https://fami.test/campaign", + platform: "android", + regionId: 8, + roomSmallImageUrl: "https://cdn.test/room.webp", + sortOrder: 2, + status: "active" + })).toEqual(expect.objectContaining({ + bannerType: "h5", + countryCode: "SA", + displayScope: "home", + displayScopes: ["home", "room"], + platform: "android", + roomSmallImageUrl: "https://cdn.test/room.webp", + status: "active" + })); + expect(() => buildBannerPayload({ bannerType: "link", coverUrl: "x", displayScopes: ["home"], platform: "all", status: "enabled" })) + .toThrow("Banner 类型不正确"); + }); + + test("create API sends the normalized appconfig payload", async () => { + const fetchMock = vi.fn(async () => jsonResponse({ code: 0, data: {} })); + vi.stubGlobal("fetch", fetchMock); + await createBanner({ + bannerType: "app", + coverUrl: "https://cdn.test/banner.webp", + displayScopes: ["me"], + param: JSON.stringify({ type: "wallet" }), + platform: "ios", + status: "disabled" + }); + + expect(fetchMock.mock.calls[0][0]).toBe("/api/v1/external/admin/app-config/banners"); + expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual(expect.objectContaining({ + bannerType: "app", + displayScope: "me", + displayScopes: ["me"], + platform: "ios", + status: "disabled" + })); + }); +}); + +describe("VIP grant routing", () => { + test("routes trial-card VIP grants to vipconfig and never sends track=vip to app users", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ code: 0, data: { config: { grantMode: "trial_card", status: "active" } } })) + .mockResolvedValueOnce(jsonResponse({ code: 0, data: { levels: [{ level: 5 }] } })) + .mockResolvedValueOnce(jsonResponse({ code: 0, data: {} })); + vi.stubGlobal("fetch", fetchMock); + + await grantUserVip("user-9", { commandId: "vip-test", durationMs: 604800000, level: 5, reason: "manual" }); + + expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ + "/api/v1/external/admin/activity/vip-program", + "/api/v1/external/admin/activity/vip-levels", + "/api/v1/external/admin/activity/vip-trial-card-grants" + ]); + const payload = JSON.parse(fetchMock.mock.calls[2][1].body); + expect(payload).toEqual(expect.objectContaining({ durationMs: 604800000, level: 5, targetUserId: "user-9" })); + expect(payload).not.toHaveProperty("track"); + }); +}); + +function jsonResponse(body, status = 200) { + return new Response(JSON.stringify(body), { headers: { "content-type": "application/json" }, status }); +} diff --git a/external-admin/src/api/client.js b/external-admin/src/api/client.js new file mode 100644 index 0000000..507b791 --- /dev/null +++ b/external-admin/src/api/client.js @@ -0,0 +1,149 @@ +const EXTERNAL_API_BASE = "/api/v1/external"; +const CSRF_COOKIE_NAME = "hyapp_external_csrf"; +const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); + +let unauthorizedHandler = null; +let inMemoryCsrfToken = ""; + +export class ExternalApiError extends Error { + constructor(message, { code = "", status = 0, details = null } = {}) { + super(message); + this.name = "ExternalApiError"; + this.code = code; + this.status = status; + this.details = details; + } +} + +export function setExternalUnauthorizedHandler(handler) { + unauthorizedHandler = typeof handler === "function" ? handler : null; + return () => { + if (unauthorizedHandler === handler) { + unauthorizedHandler = null; + } + }; +} + +export function rememberExternalCsrfToken(token) { + // Cookie remains the source of truth. The memory copy only covers deployments where Cookie Path prevents page JavaScript from reading it. + inMemoryCsrfToken = String(token || "").trim(); +} + +export function readExternalCsrfToken(cookieText = globalThis.document?.cookie || "") { + const prefix = `${CSRF_COOKIE_NAME}=`; + const cookieValue = String(cookieText) + .split(";") + .map((part) => part.trim()) + .find((part) => part.startsWith(prefix)); + + if (!cookieValue) { + return inMemoryCsrfToken; + } + + try { + return decodeURIComponent(cookieValue.slice(prefix.length)); + } catch { + return cookieValue.slice(prefix.length); + } +} + +export async function externalRequest(path, options = {}) { + const method = String(options.method || "GET").toUpperCase(); + const headers = new Headers(options.headers || {}); + const body = createBody(options.body, headers); + + if (!SAFE_METHODS.has(method)) { + const csrfToken = readExternalCsrfToken(); + if (csrfToken) { + headers.set("X-CSRF-Token", csrfToken); + } + } + + const response = await fetch(buildUrl(path, options.query), { + body, + credentials: "include", + headers, + method, + signal: options.signal + }); + const payload = await readPayload(response); + + if (response.status === 401 && options.notifyUnauthorized !== false) { + unauthorizedHandler?.(); + } + + if (!response.ok) { + throw apiError(payload, response.status, response.statusText || "请求失败"); + } + + if (isEnvelopeFailure(payload)) { + throw apiError(payload, response.status, "请求失败"); + } + + const data = unwrapPayload(payload); + if (data && typeof data === "object" && "csrfToken" in data) { + rememberExternalCsrfToken(data.csrfToken); + } + return data; +} + +function buildUrl(path, query) { + const normalizedPath = String(path || "").startsWith("/") ? path : `/${path}`; + const params = new URLSearchParams(); + + Object.entries(query || {}).forEach(([key, value]) => { + if (value === undefined || value === null || value === "") { + return; + } + (Array.isArray(value) ? value : [value]).forEach((item) => params.append(key, String(item))); + }); + + const search = params.toString(); + return `${EXTERNAL_API_BASE}${normalizedPath}${search ? `?${search}` : ""}`; +} + +function createBody(body, headers) { + if (body === undefined || body === null) { + return undefined; + } + if (body instanceof FormData || typeof body === "string" || body instanceof Blob) { + return body; + } + headers.set("Content-Type", "application/json"); + return JSON.stringify(body); +} + +async function readPayload(response) { + if (response.status === 204) { + return null; + } + const contentType = response.headers.get("content-type") || ""; + if (contentType.includes("application/json")) { + return response.json(); + } + const text = await response.text(); + return text ? { message: text } : null; +} + +function isEnvelopeFailure(payload) { + if (!payload || typeof payload !== "object" || !("code" in payload)) { + return false; + } + return ![0, 200, "0", "200", "OK", "SUCCESS"].includes(payload.code); +} + +function unwrapPayload(payload) { + if (!payload || typeof payload !== "object") { + return payload; + } + return "data" in payload ? payload.data : payload; +} + +function apiError(payload, status, fallback) { + const source = payload && typeof payload === "object" ? payload : {}; + return new ExternalApiError(source.message || source.msg || fallback, { + code: source.code, + details: source.details || source.errors || null, + status + }); +} diff --git a/external-admin/src/api/client.test.js b/external-admin/src/api/client.test.js new file mode 100644 index 0000000..93fd1bb --- /dev/null +++ b/external-admin/src/api/client.test.js @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + ExternalApiError, + externalRequest, + readExternalCsrfToken, + rememberExternalCsrfToken, + setExternalUnauthorizedHandler +} from "./client.js"; + +describe("externalRequest", () => { + beforeEach(() => { + rememberExternalCsrfToken(""); + document.cookie = "hyapp_external_csrf=; Max-Age=0; Path=/"; + }); + + afterEach(() => { + setExternalUnauthorizedHandler(null); + vi.unstubAllGlobals(); + }); + + test("uses only the isolated cookie session and external API base", async () => { + const fetchMock = vi.fn(async () => jsonResponse({ code: 0, data: { items: [] } })); + vi.stubGlobal("fetch", fetchMock); + + await externalRequest("/app/users", { query: { keyword: "163000", page: 1 } }); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("/api/v1/external/app/users?keyword=163000&page=1"); + expect(init.credentials).toBe("include"); + expect(init.headers.get("Authorization")).toBeNull(); + expect(init.headers.get("X-App-Code")).toBeNull(); + expect(init.headers.get("X-CSRF-Token")).toBeNull(); + }); + + test("adds CSRF to writes from the dedicated cookie", async () => { + document.cookie = "hyapp_external_csrf=csrf%20token; Path=/"; + const fetchMock = vi.fn(async () => jsonResponse({ code: 0, data: { ok: true } })); + vi.stubGlobal("fetch", fetchMock); + + await externalRequest("/auth/change-password", { body: { currentPassword: "old", newPassword: "new" }, method: "POST" }); + + const [, init] = fetchMock.mock.calls[0]; + expect(init.headers.get("X-CSRF-Token")).toBe("csrf token"); + expect(JSON.parse(init.body)).toEqual({ currentPassword: "old", newPassword: "new" }); + }); + + test("keeps csrfToken in memory when the API-scoped cookie is unreadable", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ code: 0, data: { csrfToken: "memory-csrf", user: { username: "operator" } } })) + .mockResolvedValueOnce(jsonResponse({ code: 0, data: {} })); + vi.stubGlobal("fetch", fetchMock); + + await externalRequest("/auth/me"); + await externalRequest("/auth/logout", { method: "POST" }); + + expect(fetchMock.mock.calls[1][1].headers.get("X-CSRF-Token")).toBe("memory-csrf"); + expect(readExternalCsrfToken("")).toBe("memory-csrf"); + }); + + test("invalidates the external session on protected 401", async () => { + const handler = vi.fn(); + setExternalUnauthorizedHandler(handler); + vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ code: "UNAUTHORIZED", message: "请登录" }, 401))); + + await expect(externalRequest("/admin/rooms")).rejects.toEqual(expect.objectContaining({ status: 401 })); + expect(handler).toHaveBeenCalledTimes(1); + expect(new ExternalApiError("x")).toBeInstanceOf(Error); + }); +}); + +function jsonResponse(body, status = 200) { + return new Response(JSON.stringify(body), { headers: { "content-type": "application/json" }, status }); +} diff --git a/external-admin/src/api/normalizers.js b/external-admin/src/api/normalizers.js new file mode 100644 index 0000000..c03506f --- /dev/null +++ b/external-admin/src/api/normalizers.js @@ -0,0 +1,59 @@ +export function normalizePage(data, mapItem = normalizeRecord) { + const source = asRecord(data); + const rawItems = Array.isArray(data) + ? data + : arrayValue(source.items, source.agencies, source.list, source.records, source.content, source.rows); + const page = positiveNumber(source.page ?? source.page_no ?? source.pageNo, 1); + const pageSize = positiveNumber(source.pageSize ?? source.page_size ?? source.size, rawItems.length || 20); + + return { + items: rawItems.map((item) => mapItem(asRecord(item))), + page, + pageSize, + total: nonNegativeNumber(source.total ?? source.total_count ?? source.totalCount, rawItems.length) + }; +} + +export function normalizeRecord(value) { + return asRecord(value); +} + +export function asRecord(value) { + return value && typeof value === "object" && !Array.isArray(value) ? value : {}; +} + +export function arrayValue(...values) { + return values.find(Array.isArray) || []; +} + +export function textValue(...values) { + const value = values.find((item) => item !== undefined && item !== null && item !== ""); + return value === undefined ? "" : String(value); +} + +export function numberValue(value, fallback = 0) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +export function booleanValue(...values) { + const value = values.find((item) => item !== undefined && item !== null); + if (typeof value === "string") { + return ["1", "true", "enabled", "active", "yes"].includes(value.toLowerCase()); + } + return Boolean(value); +} + +export function entityId(item) { + return textValue(item.id, item.userId, item.user_id, item.roomId, item.room_id, item.resourceId, item.resource_id); +} + +function positiveNumber(value, fallback) { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function nonNegativeNumber(value, fallback) { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; +} diff --git a/external-admin/src/auth/ExternalAuthProvider.jsx b/external-admin/src/auth/ExternalAuthProvider.jsx new file mode 100644 index 0000000..b7de90d --- /dev/null +++ b/external-admin/src/auth/ExternalAuthProvider.jsx @@ -0,0 +1,73 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { changeExternalPassword, getExternalSession, loginExternal, logoutExternal } from "../api/auth.js"; +import { ExternalApiError, setExternalUnauthorizedHandler } from "../api/client.js"; + +const ExternalAuthContext = createContext(null); + +export function ExternalAuthProvider({ children }) { + const [session, setSession] = useState(null); + const [initializing, setInitializing] = useState(true); + const [initializationError, setInitializationError] = useState(""); + + const refresh = useCallback(async () => { + setInitializationError(""); + try { + const nextSession = await getExternalSession(); + setSession(nextSession); + return nextSession; + } catch (error) { + setSession(null); + if (!(error instanceof ExternalApiError && error.status === 401)) { + setInitializationError(error.message || "会话校验失败"); + } + return null; + } finally { + setInitializing(false); + } + }, []); + + useEffect(() => { + refresh(); + }, [refresh]); + + useEffect(() => { + // Any protected request returning 401 invalidates the entire external session; it never mutates the main-admin session. + return setExternalUnauthorizedHandler(() => setSession(null)); + }, []); + + const login = useCallback(async (credentials) => { + const nextSession = await loginExternal(credentials); + setSession(nextSession); + return nextSession; + }, []); + + const logout = useCallback(async () => { + try { + await logoutExternal(); + } finally { + setSession(null); + } + }, []); + + const changePassword = useCallback(async (payload) => { + const responseSession = await changeExternalPassword(payload); + const nextSession = responseSession?.appCode ? responseSession : await getExternalSession(); + setSession({ ...nextSession, passwordChangeRequired: false }); + return nextSession; + }, []); + + const value = useMemo( + () => ({ changePassword, initializationError, initializing, login, logout, refresh, session }), + [changePassword, initializationError, initializing, login, logout, refresh, session] + ); + + return {children}; +} + +export function useExternalAuth() { + const context = useContext(ExternalAuthContext); + if (!context) { + throw new Error("useExternalAuth must be used inside ExternalAuthProvider"); + } + return context; +} diff --git a/external-admin/src/auth/RouteGuards.jsx b/external-admin/src/auth/RouteGuards.jsx new file mode 100644 index 0000000..e47933a --- /dev/null +++ b/external-admin/src/auth/RouteGuards.jsx @@ -0,0 +1,58 @@ +import Alert from "@mui/material/Alert"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import CircularProgress from "@mui/material/CircularProgress"; +import Typography from "@mui/material/Typography"; +import { Navigate, Outlet, useLocation } from "react-router-dom"; +import { hasAnyExternalCapability } from "../config/capabilities.js"; +import { useExternalAuth } from "./ExternalAuthProvider.jsx"; + +export function RequireExternalAuth() { + const { initializationError, initializing, refresh, session } = useExternalAuth(); + const location = useLocation(); + + if (initializing) { + return ( + + + 正在校验外管会话 + + ); + } + + if (!session) { + if (initializationError) { + return ( + + {initializationError} + + + ); + } + return ; + } + + return ; +} + +export function RequirePasswordChanged() { + const { session } = useExternalAuth(); + return session?.passwordChangeRequired ? : ; +} + +export function RequireExternalCapability({ anyOf }) { + const { session } = useExternalAuth(); + + if (hasAnyExternalCapability(session, anyOf)) { + return ; + } + + return ( + + 当前外管账号没有访问此功能的权限。 + + + ); +} diff --git a/external-admin/src/components/BannerCreateDialog.jsx b/external-admin/src/components/BannerCreateDialog.jsx new file mode 100644 index 0000000..98ee4ad --- /dev/null +++ b/external-admin/src/components/BannerCreateDialog.jsx @@ -0,0 +1,110 @@ +import Button from "@mui/material/Button"; +import Checkbox from "@mui/material/Checkbox"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogTitle from "@mui/material/DialogTitle"; +import ListItemText from "@mui/material/ListItemText"; +import MenuItem from "@mui/material/MenuItem"; +import Stack from "@mui/material/Stack"; +import TextField from "@mui/material/TextField"; +import { useEffect, useState } from "react"; +import { AppJumpConfigField } from "@/shared/ui/AppJumpConfigField.jsx"; +import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx"; +import { ExternalImageUploadField } from "../shared/ExternalImageUploadField.jsx"; + +const displayScopeOptions = [ + ["home", "首页"], + ["room", "房间"], + ["recharge", "充值"], + ["me", "我的"] +]; + +export function BannerCreateDialog({ loading, onClose, onSubmit, open }) { + const [form, setForm] = useState(initialForm); + useEffect(() => setForm(initialForm), [open]); + + const submit = (event) => { + event.preventDefault(); + onSubmit({ + ...form, + regionId: Number(form.regionId || 0), + roomSmallImageUrl: form.displayScopes.includes("room") ? form.roomSmallImageUrl : "", + sortOrder: Number(form.sortOrder || 0) + }); + }; + + return ( + + + 创建 Banner + + + setForm({ ...form, coverUrl })} /> + {form.displayScopes.includes("room") ? setForm({ ...form, roomSmallImageUrl })} /> : null} + setForm({ ...form, platform: event.target.value })}> + Android + iOS + + setForm({ ...form, description: event.target.value })} /> + setForm({ ...form, bannerType: type, param })} + /> + selected.map((value) => displayScopeOptions.find(([key]) => key === value)?.[1] || value).join("、") } }} + value={form.displayScopes} + onChange={(event) => setForm({ ...form, displayScopes: typeof event.target.value === "string" ? event.target.value.split(",") : event.target.value })} + > + {displayScopeOptions.map(([value, label]) => )} + + + setForm({ ...form, regionId: event.target.value })} /> + setForm({ ...form, countryCode: event.target.value.toUpperCase() })} /> + setForm({ ...form, sortOrder: event.target.value })} /> + + setForm({ ...form, endsAtMs: endMs, startsAtMs: startMs })} + /> + setForm({ ...form, status: event.target.value })}> + 启用 + 禁用 + 过期 + + + + + + + ); +} + +const initialForm = { + bannerType: "h5", + countryCode: "", + coverUrl: "", + description: "", + displayScopes: ["home"], + endsAtMs: 0, + param: "", + platform: "android", + regionId: "", + roomSmallImageUrl: "", + sortOrder: "0", + startsAtMs: 0, + status: "active" +}; diff --git a/external-admin/src/components/BannerCreateDialog.test.jsx b/external-admin/src/components/BannerCreateDialog.test.jsx new file mode 100644 index 0000000..54b4fd8 --- /dev/null +++ b/external-admin/src/components/BannerCreateDialog.test.jsx @@ -0,0 +1,20 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, test, vi } from "vitest"; +import { BannerCreateDialog } from "./BannerCreateDialog.jsx"; + +describe("BannerCreateDialog", () => { + test("uses the shared structured app jump field and unified time range picker", async () => { + const user = userEvent.setup(); + render(); + + expect(screen.getByRole("textbox", { name: "参数(H5 链接)" })).toBeInTheDocument(); + expect(screen.getByText("投放时间")).toBeInTheDocument(); + + await user.click(screen.getByRole("combobox", { name: "跳转类型" })); + await user.click(await screen.findByRole("option", { name: "APP" })); + + expect(screen.getByRole("combobox", { name: "APP 目标" })).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: "参数(APP 公共跳转 JSON)" })).toHaveValue(JSON.stringify({ type: "wallet" })); + }); +}); diff --git a/external-admin/src/components/GrantDialogs.jsx b/external-admin/src/components/GrantDialogs.jsx new file mode 100644 index 0000000..ab6d34d --- /dev/null +++ b/external-admin/src/components/GrantDialogs.jsx @@ -0,0 +1,60 @@ +import Alert from "@mui/material/Alert"; +import Button from "@mui/material/Button"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogTitle from "@mui/material/DialogTitle"; +import MenuItem from "@mui/material/MenuItem"; +import Stack from "@mui/material/Stack"; +import TextField from "@mui/material/TextField"; +import { useEffect, useState } from "react"; + +export function ResourceGrantDialog({ loading, onClose, onSubmit, open, resources }) { + const [form, setForm] = useState(resourceGrantInitial); + useEffect(() => setForm(resourceGrantInitial), [open]); + return ( + onSubmit(form)}> + + setForm({ ...form, target: event.target.value.trim() })} /> + setForm({ ...form, resourceId: event.target.value })}> + {resources.map((item) => {item.name}({item.id}))} + + setForm({ ...form, quantity: event.target.value })} /> + setForm({ ...form, durationDays: event.target.value })} /> + setForm({ ...form, reason: event.target.value })} /> + + ); +} + +export function PrettyIdGrantDialog({ loading, onClose, onSubmit, open }) { + const [form, setForm] = useState(prettyGrantInitial); + useEffect(() => setForm(prettyGrantInitial), [open]); + return ( + onSubmit(form)}> + + setForm({ ...form, target: event.target.value.trim() })} /> + setForm({ ...form, prettyId: event.target.value.trim() })} /> + setForm({ ...form, durationDays: event.target.value })} /> + setForm({ ...form, reason: event.target.value })} /> + + ); +} + +function TargetNotice() { + return 提交前会校验当前 App 内的用户,并使用服务端返回的用户 ID 执行发放。; +} + +function GrantDialog({ children, loading, onClose, onSubmit, open, title }) { + return ( + + { event.preventDefault(); onSubmit(); }}> + {title} + {children} + + + + ); +} + +const resourceGrantInitial = { durationDays: "30", quantity: "1", reason: "", resourceId: "", target: "" }; +const prettyGrantInitial = { durationDays: "0", prettyId: "", reason: "", target: "" }; diff --git a/external-admin/src/components/RoomEditDialog.jsx b/external-admin/src/components/RoomEditDialog.jsx new file mode 100644 index 0000000..da60be6 --- /dev/null +++ b/external-admin/src/components/RoomEditDialog.jsx @@ -0,0 +1,30 @@ +import Button from "@mui/material/Button"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogTitle from "@mui/material/DialogTitle"; +import Stack from "@mui/material/Stack"; +import TextField from "@mui/material/TextField"; +import { useEffect, useState } from "react"; +import { ExternalImageUploadField } from "../shared/ExternalImageUploadField.jsx"; + +export function RoomEditDialog({ loading, onClose, onSubmit, open, room }) { + const [form, setForm] = useState({ coverUrl: "", description: "", title: "", visibleRegionId: "" }); + useEffect(() => { + setForm({ coverUrl: room?.coverUrl || "", description: room?.description || "", title: room?.title || "", visibleRegionId: room?.visibleRegionId || "" }); + }, [room]); + return ( + + { event.preventDefault(); onSubmit(form); }}> + 编辑房间 + + setForm({ ...form, coverUrl })} /> + setForm({ ...form, title: event.target.value })} /> + setForm({ ...form, description: event.target.value })} /> + setForm({ ...form, visibleRegionId: event.target.value.trim() })} /> + + + + + ); +} diff --git a/external-admin/src/components/UserDialogs.jsx b/external-admin/src/components/UserDialogs.jsx new file mode 100644 index 0000000..a227b14 --- /dev/null +++ b/external-admin/src/components/UserDialogs.jsx @@ -0,0 +1,69 @@ +import Button from "@mui/material/Button"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogTitle from "@mui/material/DialogTitle"; +import MenuItem from "@mui/material/MenuItem"; +import Stack from "@mui/material/Stack"; +import TextField from "@mui/material/TextField"; +import { useEffect, useState } from "react"; +import { ExternalImageUploadField } from "../shared/ExternalImageUploadField.jsx"; + +export function UserEditDialog({ loading, onClose, onSubmit, open, user }) { + const [form, setForm] = useState(emptyEditForm); + useEffect(() => { + setForm({ avatar: user?.avatar || "", country: user?.country || "", gender: user?.gender || "", username: user?.username || "" }); + }, [user]); + return ( + onSubmit(form)}> + setForm({ ...form, avatar })} /> + setForm({ ...form, username: event.target.value })} /> + setForm({ ...form, gender: event.target.value })}> + 未设置未知 + + setForm({ ...form, country: event.target.value.trim().toUpperCase() })} /> + + ); +} + +export function UserBanDialog({ loading, onClose, onSubmit, open, user }) { + const [form, setForm] = useState({ days: "7", reason: "" }); + useEffect(() => setForm({ days: "7", reason: "" }), [user]); + return ( + onSubmit(form)}> + setForm({ ...form, days: event.target.value })} /> + setForm({ ...form, reason: event.target.value })} /> + + ); +} + +export function UserLevelDialog({ loading, onClose, onSubmit, open, user }) { + const [form, setForm] = useState({ days: "30", level: "", reason: "", track: "wealth" }); + useEffect(() => setForm({ days: "30", level: "", reason: "", track: "wealth" }), [user]); + return ( + onSubmit(form)}> + setForm({ ...form, track: event.target.value })}>财富等级魅力等级VIP 等级 + setForm({ ...form, level: event.target.value })} /> + setForm({ ...form, days: event.target.value })} /> + setForm({ ...form, reason: event.target.value })} /> + + ); +} + +function BaseDialog({ children, loading, onClose, onSubmit, open, title }) { + const handleSubmit = (event) => { + event.preventDefault(); + onSubmit(); + }; + return ( + + + {title} + {children} + + + + ); +} + +const emptyEditForm = { avatar: "", country: "", gender: "", username: "" }; diff --git a/external-admin/src/config/capabilities.js b/external-admin/src/config/capabilities.js new file mode 100644 index 0000000..a346f9f --- /dev/null +++ b/external-admin/src/config/capabilities.js @@ -0,0 +1,31 @@ +export const EXTERNAL_CAPABILITIES = [ + { code: "user:list", label: "用户列表", path: "/users" }, + { code: "user:update", label: "用户信息编辑", path: "/users" }, + { code: "user-ban:list", label: "账号封禁列表", path: "/bans" }, + { code: "user:ban", label: "执行封禁", path: "/users" }, + { code: "user:unban", label: "解除封禁", path: "/bans" }, + { code: "host:list", label: "主播列表", path: "/organization/hosts" }, + { code: "agency:list", label: "公会列表", path: "/organization/agencies" }, + { code: "bd:list", label: "BD 列表", path: "/organization/bds" }, + { code: "bd-manager:list", label: "BD Manager 列表", path: "/organization/bd-leaders" }, + { code: "super-admin:list", label: "Super Admin 列表", path: "/organization/super-admins" }, + { code: "room:list", label: "房间管理", path: "/rooms" }, + { code: "room:update", label: "房间编辑", path: "/rooms" }, + { code: "privilege:list", label: "用户特权道具列表", path: "/grants" }, + { code: "privilege:grant", label: "特权道具发放", path: "/grants" }, + { code: "banner:create", label: "Banner 创建", path: "/banners" }, + { code: "pretty-id:grant", label: "靓号下发", path: "/grants" }, + { code: "user-title:grant", label: "用户称号下发", path: "/grants" }, + { code: "room-background:grant", label: "房间背景图下发", path: "/grants" }, + { code: "user-level:grant", label: "财富/VIP等级下发", path: "/users" }, + { code: "team:view", label: "我的团队", path: "/organization/team" } +]; + +export function hasExternalCapability(session, code) { + const capabilities = session?.capabilities || []; + return capabilities.includes("*") || capabilities.includes("external-admin:*") || capabilities.includes(code); +} + +export function hasAnyExternalCapability(session, codes) { + return codes.some((code) => hasExternalCapability(session, code)); +} diff --git a/external-admin/src/config/entry-routing.test.js b/external-admin/src/config/entry-routing.test.js new file mode 100644 index 0000000..b7a3291 --- /dev/null +++ b/external-admin/src/config/entry-routing.test.js @@ -0,0 +1,43 @@ +import { describe, expect, test, vi } from "vitest"; +import { redirectSubsystemEntry } from "../../../vite.config.js"; + +describe("external-admin Vite entry routing", () => { + test("redirects the bare subsystem path and preserves the app query", () => { + const req = { url: "/external-admin?app=fami" }; + const res = responseStub(); + const next = vi.fn(); + + redirectSubsystemEntry(req, res, next); + + expect(res.statusCode).toBe(301); + expect(res.setHeader).toHaveBeenCalledWith("Location", "/external-admin/?app=fami"); + expect(res.end).toHaveBeenCalled(); + expect(next).not.toHaveBeenCalled(); + }); + + test("rewrites a BrowserRouter deep link to the isolated HTML document", () => { + const req = { url: "/external-admin/organization/hosts?from=test" }; + const res = responseStub(); + const next = vi.fn(); + + redirectSubsystemEntry(req, res, next); + + expect(req.url).toBe("/external-admin/index.html?from=test"); + expect(next).toHaveBeenCalledTimes(1); + expect(res.end).not.toHaveBeenCalled(); + }); + + test("does not rewrite source module requests", () => { + const req = { url: "/external-admin/src/main.jsx" }; + const next = vi.fn(); + + redirectSubsystemEntry(req, responseStub(), next); + + expect(req.url).toBe("/external-admin/src/main.jsx"); + expect(next).toHaveBeenCalledTimes(1); + }); +}); + +function responseStub() { + return { end: vi.fn(), setHeader: vi.fn(), statusCode: 200 }; +} diff --git a/external-admin/src/layout/ExternalAdminLayout.jsx b/external-admin/src/layout/ExternalAdminLayout.jsx new file mode 100644 index 0000000..59a5787 --- /dev/null +++ b/external-admin/src/layout/ExternalAdminLayout.jsx @@ -0,0 +1,128 @@ +import ApartmentOutlined from "@mui/icons-material/ApartmentOutlined"; +import BadgeOutlined from "@mui/icons-material/BadgeOutlined"; +import DashboardOutlined from "@mui/icons-material/DashboardOutlined"; +import GroupsOutlined from "@mui/icons-material/GroupsOutlined"; +import ImageOutlined from "@mui/icons-material/ImageOutlined"; +import LogoutOutlined from "@mui/icons-material/LogoutOutlined"; +import MenuOutlined from "@mui/icons-material/MenuOutlined"; +import MeetingRoomOutlined from "@mui/icons-material/MeetingRoomOutlined"; +import PeopleOutlineOutlined from "@mui/icons-material/PeopleOutlineOutlined"; +import PersonOffOutlined from "@mui/icons-material/PersonOffOutlined"; +import RedeemOutlined from "@mui/icons-material/RedeemOutlined"; +import SecurityOutlined from "@mui/icons-material/SecurityOutlined"; +import AppBar from "@mui/material/AppBar"; +import Avatar from "@mui/material/Avatar"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Divider from "@mui/material/Divider"; +import Drawer from "@mui/material/Drawer"; +import IconButton from "@mui/material/IconButton"; +import List from "@mui/material/List"; +import ListItemButton from "@mui/material/ListItemButton"; +import ListItemIcon from "@mui/material/ListItemIcon"; +import ListItemText from "@mui/material/ListItemText"; +import Stack from "@mui/material/Stack"; +import Toolbar from "@mui/material/Toolbar"; +import Tooltip from "@mui/material/Tooltip"; +import Typography from "@mui/material/Typography"; +import useMediaQuery from "@mui/material/useMediaQuery"; +import { useTheme } from "@mui/material/styles"; +import { useMemo, useState } from "react"; +import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom"; +import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; +import { hasAnyExternalCapability } from "../config/capabilities.js"; + +const drawerWidth = 248; +const navigation = [ + { icon: DashboardOutlined, label: "权限总览", path: "/overview" }, + { capabilities: ["user:list", "user:update", "user:ban", "user-level:grant"], icon: PeopleOutlineOutlined, label: "用户管理", path: "/users" }, + { capabilities: ["user-ban:list", "user:unban"], icon: PersonOffOutlined, label: "封禁管理", path: "/bans" }, + { capabilities: ["host:list"], icon: BadgeOutlined, label: "主播列表", path: "/organization/hosts" }, + { capabilities: ["agency:list"], icon: ApartmentOutlined, label: "公会列表", path: "/organization/agencies" }, + { capabilities: ["bd:list"], icon: GroupsOutlined, label: "BD 列表", path: "/organization/bds" }, + { capabilities: ["bd-manager:list"], icon: SecurityOutlined, label: "BD Manager", path: "/organization/bd-leaders" }, + { capabilities: ["super-admin:list"], icon: SecurityOutlined, label: "Super Admin", path: "/organization/super-admins" }, + { capabilities: ["room:list", "room:update"], icon: MeetingRoomOutlined, label: "房间管理", path: "/rooms" }, + { capabilities: ["privilege:list", "privilege:grant", "pretty-id:grant", "user-title:grant", "room-background:grant"], icon: RedeemOutlined, label: "资源与靓号", path: "/grants" }, + { capabilities: ["banner:create"], icon: ImageOutlined, label: "Banner 管理", path: "/banners" }, + { capabilities: ["team:view"], icon: GroupsOutlined, label: "我的团队", path: "/organization/team" } +]; + +export function ExternalAdminLayout() { + const theme = useTheme(); + const mobile = useMediaQuery(theme.breakpoints.down("md")); + const [drawerOpen, setDrawerOpen] = useState(false); + const { logout, session } = useExternalAuth(); + const location = useLocation(); + const navigate = useNavigate(); + const visibleNavigation = useMemo( + () => navigation.filter((item) => !item.capabilities || hasAnyExternalCapability(session, item.capabilities)), + [session] + ); + const current = [...visibleNavigation].reverse().find((item) => location.pathname.startsWith(item.path)); + + const handleLogout = async () => { + await logout(); + navigate("/login", { replace: true }); + }; + + const drawer = ( + + + HY + + HYApp 外管 + {session?.appName || session?.appCode} + + + + + {visibleNavigation.map((item) => { + const Icon = item.icon; + return ( + setDrawerOpen(false)} + > + + + + ); + })} + + + ); + + return ( + + + + {mobile ? setDrawerOpen(true)}> : null} + {current?.label || "外管后台"} + + + {String(session?.account || "管").slice(0, 1).toUpperCase()} + {!mobile ? ( + + {session?.account} + {session?.appCode}{session?.displayName ? ` · ${session.displayName}` : ""} + + ) : null} + + + + + + + setDrawerOpen(false)} ModalProps={{ keepMounted: true }} sx={{ display: { md: "none" }, "& .MuiDrawer-paper": { width: drawerWidth } }}>{drawer} + {drawer} + + + + + + ); +} diff --git a/external-admin/src/main.jsx b/external-admin/src/main.jsx new file mode 100644 index 0000000..353822a --- /dev/null +++ b/external-admin/src/main.jsx @@ -0,0 +1,27 @@ +import CssBaseline from "@mui/material/CssBaseline"; +import { ThemeProvider } from "@mui/material/styles"; +import React from "react"; +import { createRoot } from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import { ToastProvider } from "@/shared/ui/ToastProvider.jsx"; +import { theme } from "@/theme.js"; +import { ExternalAdminApp } from "./ExternalAdminApp.jsx"; +import { ExternalAuthProvider } from "./auth/ExternalAuthProvider.jsx"; +import "@/styles/tokens.css"; +import "@/styles/shared-ui.css"; +import "./styles/index.css"; + +createRoot(document.getElementById("external-admin-root")).render( + + + + + + + + + + + + +); diff --git a/external-admin/src/pages/BannersPage.jsx b/external-admin/src/pages/BannersPage.jsx new file mode 100644 index 0000000..5c0144d --- /dev/null +++ b/external-admin/src/pages/BannersPage.jsx @@ -0,0 +1,49 @@ +import AddOutlined from "@mui/icons-material/AddOutlined"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { useCallback, useMemo, useState } from "react"; +import { useToast } from "@/shared/ui/ToastProvider.jsx"; +import { createBanner, listBanners } from "../api/business.js"; +import { BannerCreateDialog } from "../components/BannerCreateDialog.jsx"; +import { ExternalPage, ExternalPageState, PagePagination, ResponsiveDataList, StatusChip } from "../shared/PageComponents.jsx"; +import { usePagedResource } from "../shared/usePagedResource.js"; + +export default function BannersPage() { + const { showToast } = useToast(); + const [open, setOpen] = useState(false); + const [page, setPage] = useState(1); + const [submitting, setSubmitting] = useState(false); + const load = useCallback(() => listBanners({ page, page_size: 20 }), [page]); + const { data, error, loading, reload } = usePagedResource(load); + const columns = useMemo(() => [ + { key: "banner", label: "Banner", render: (item) => {item.coverUrl ? : null}{item.description || "-"}ID {item.id || "-"} }, + { key: "type", label: "类型", render: (item) => item.bannerType || "-" }, + { key: "platform", label: "平台", render: (item) => item.platform || "all" }, + { key: "sort", label: "排序", render: (item) => item.sortOrder }, + { key: "status", label: "状态", render: (item) => } + ], []); + + const submit = async (form) => { + setSubmitting(true); + try { + await createBanner(form); + showToast("Banner 已创建", "success"); + setOpen(false); + reload(); + } catch (requestError) { + showToast(requestError.message || "Banner 创建失败", "error"); + } finally { + setSubmitting(false); + } + }; + + return ( + setOpen(true)} startIcon={} variant="contained">创建 Banner} title="Banner 管理"> + + {!loading && !error ? <> item.id} /> : null} + setOpen(false)} onSubmit={submit} /> + + ); +} diff --git a/external-admin/src/pages/BansPage.jsx b/external-admin/src/pages/BansPage.jsx new file mode 100644 index 0000000..773edfb --- /dev/null +++ b/external-admin/src/pages/BansPage.jsx @@ -0,0 +1,61 @@ +import LockOpenOutlined from "@mui/icons-material/LockOpenOutlined"; +import Avatar from "@mui/material/Avatar"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Stack from "@mui/material/Stack"; +import TextField from "@mui/material/TextField"; +import Typography from "@mui/material/Typography"; +import { useCallback, useMemo, useState } from "react"; +import { useToast } from "@/shared/ui/ToastProvider.jsx"; +import { listBannedUsers, unbanUser } from "../api/business.js"; +import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; +import { hasExternalCapability } from "../config/capabilities.js"; +import { ConfirmDialog } from "../shared/ConfirmDialog.jsx"; +import { ExternalPage, ExternalPageState, PagePagination, ResponsiveDataList, StatusChip } from "../shared/PageComponents.jsx"; +import { usePagedResource } from "../shared/usePagedResource.js"; + +export default function BansPage() { + const { session } = useExternalAuth(); + const { showToast } = useToast(); + const [keyword, setKeyword] = useState(""); + const [query, setQuery] = useState({ keyword: "", page: 1 }); + const [target, setTarget] = useState(null); + const [submitting, setSubmitting] = useState(false); + const load = useCallback(() => listBannedUsers({ keyword: query.keyword, page: query.page, page_size: 20 }), [query]); + const { data, error, loading, reload } = usePagedResource(load); + const canUnban = hasExternalCapability(session, "user:unban"); + + const handleUnban = async () => { + setSubmitting(true); + try { + await unbanUser(target.id, { reason: "external-admin manual unban" }); + showToast("用户已解除封禁", "success"); + setTarget(null); + reload(); + } catch (requestError) { + showToast(requestError.message || "解除封禁失败", "error"); + } finally { + setSubmitting(false); + } + }; + + const columns = useMemo(() => [ + { key: "user", label: "用户", render: (user) => {user.username.slice(0, 1)}{user.username || "-"}ID {user.id} }, + { key: "prettyId", label: "短 ID(靓号)", render: (user) => user.prettyId || "-" }, + { key: "reason", label: "封禁原因", render: (user) => user.reason || "-" }, + { key: "status", label: "状态", render: () => }, + { key: "actions", label: "操作", render: (user) => canUnban ? : "-" } + ], [canUnban]); + + return ( + + { event.preventDefault(); setQuery({ keyword, page: 1 }); }}> + setKeyword(event.target.value)} /> + + + + {!loading && !error ? <> item.id} /> setQuery({ ...query, page })} /> : null} + setTarget(null)} onConfirm={handleUnban} /> + + ); +} diff --git a/external-admin/src/pages/ChangePasswordPage.jsx b/external-admin/src/pages/ChangePasswordPage.jsx new file mode 100644 index 0000000..36d1eb9 --- /dev/null +++ b/external-admin/src/pages/ChangePasswordPage.jsx @@ -0,0 +1,69 @@ +import KeyOutlined from "@mui/icons-material/KeyOutlined"; +import Alert from "@mui/material/Alert"; +import Avatar from "@mui/material/Avatar"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import Stack from "@mui/material/Stack"; +import TextField from "@mui/material/TextField"; +import Typography from "@mui/material/Typography"; +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; + +export default function ChangePasswordPage() { + const { changePassword, logout, session } = useExternalAuth(); + const [form, setForm] = useState({ confirmPassword: "", newPassword: "", oldPassword: "" }); + const [error, setError] = useState(""); + const [submitting, setSubmitting] = useState(false); + const navigate = useNavigate(); + + const submit = async (event) => { + event.preventDefault(); + if (!form.newPassword.trim()) { + setError("新密码不能为空或全为空白字符"); + return; + } + if (form.newPassword.length < 8) { + setError("新密码至少 8 个字符"); + return; + } + if (form.newPassword !== form.confirmPassword) { + setError("两次输入的新密码不一致"); + return; + } + setSubmitting(true); + setError(""); + try { + await changePassword({ newPassword: form.newPassword, oldPassword: form.oldPassword }); + navigate("/overview", { replace: true }); + } catch (requestError) { + setError(requestError.message || "密码修改失败"); + } finally { + setSubmitting(false); + } + }; + + const cancel = async () => { + await logout(); + navigate("/login", { replace: true }); + }; + + return ( + + + + 修改外管密码{session?.passwordChangeRequired ? "首次登录必须修改密码" : session?.account} + + {error ? {error} : null} + setForm({ ...form, oldPassword: event.target.value })} /> + setForm({ ...form, newPassword: event.target.value })} /> + setForm({ ...form, confirmPassword: event.target.value })} /> + + + + + + ); +} diff --git a/external-admin/src/pages/ChangePasswordPage.test.jsx b/external-admin/src/pages/ChangePasswordPage.test.jsx new file mode 100644 index 0000000..a724534 --- /dev/null +++ b/external-admin/src/pages/ChangePasswordPage.test.jsx @@ -0,0 +1,47 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import ChangePasswordPage from "./ChangePasswordPage.jsx"; + +const auth = vi.hoisted(() => ({ + changePassword: vi.fn(), + logout: vi.fn(), + session: { account: "fami-manager", passwordChangeRequired: true } +})); + +vi.mock("../auth/ExternalAuthProvider.jsx", () => ({ useExternalAuth: () => auth })); + +describe("ChangePasswordPage", () => { + beforeEach(() => { + auth.changePassword.mockReset().mockResolvedValue({}); + auth.logout.mockReset().mockResolvedValue(undefined); + }); + + test("blocks a whitespace-only new password", async () => { + const user = userEvent.setup(); + const { container } = render(); + const [newPassword, confirmPassword] = container.querySelectorAll('input[autocomplete="new-password"]'); + + fireEvent.change(container.querySelector('input[autocomplete="current-password"]'), { target: { value: "old-password" } }); + fireEvent.change(newPassword, { target: { value: " " } }); + fireEvent.change(confirmPassword, { target: { value: " " } }); + await user.click(screen.getByRole("button", { name: "保存密码" })); + + expect(screen.getByText("新密码不能为空或全为空白字符")).toBeInTheDocument(); + expect(auth.changePassword).not.toHaveBeenCalled(); + }); + + test("preserves password bytes instead of trimming before submission", async () => { + const user = userEvent.setup(); + const { container } = render(); + const [newPassword, confirmPassword] = container.querySelectorAll('input[autocomplete="new-password"]'); + + fireEvent.change(container.querySelector('input[autocomplete="current-password"]'), { target: { value: " current-password " } }); + fireEvent.change(newPassword, { target: { value: " pass word " } }); + fireEvent.change(confirmPassword, { target: { value: " pass word " } }); + await user.click(screen.getByRole("button", { name: "保存密码" })); + + expect(auth.changePassword).toHaveBeenCalledWith({ newPassword: " pass word ", oldPassword: " current-password " }); + }); +}); diff --git a/external-admin/src/pages/GrantsPage.jsx b/external-admin/src/pages/GrantsPage.jsx new file mode 100644 index 0000000..4fe3522 --- /dev/null +++ b/external-admin/src/pages/GrantsPage.jsx @@ -0,0 +1,106 @@ +import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined"; +import NumbersOutlined from "@mui/icons-material/NumbersOutlined"; +import Avatar from "@mui/material/Avatar"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { useCallback, useMemo, useState } from "react"; +import { useToast } from "@/shared/ui/ToastProvider.jsx"; +import { grantPrettyId, grantResource, listResourceGrants, listResources, lookupGrantTarget } from "../api/business.js"; +import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; +import { PrettyIdGrantDialog, ResourceGrantDialog } from "../components/GrantDialogs.jsx"; +import { hasAnyExternalCapability, hasExternalCapability } from "../config/capabilities.js"; +import { ExternalPage, ExternalPageState, PagePagination, ResponsiveDataList, StatusChip } from "../shared/PageComponents.jsx"; +import { usePagedResource } from "../shared/usePagedResource.js"; + +export default function GrantsPage() { + const { session } = useExternalAuth(); + const { showToast } = useToast(); + const [dialog, setDialog] = useState(""); + const [page, setPage] = useState(1); + const [submitting, setSubmitting] = useState(false); + const canList = hasExternalCapability(session, "privilege:list"); + const canGrant = hasAnyExternalCapability(session, ["privilege:grant", "user-title:grant", "room-background:grant"]); + const canGrantPrettyId = hasExternalCapability(session, "pretty-id:grant"); + const loadResources = useCallback(() => canGrant ? listResources({ page: 1, page_size: 200, status: "active" }) : Promise.resolve({ items: [], page: 1, pageSize: 200, total: 0 }), [canGrant]); + const loadGrants = useCallback(() => canList ? listResourceGrants({ page, page_size: 20 }) : Promise.resolve({ items: [], page: 1, pageSize: 20, total: 0 }), [canList, page]); + const resourcesState = usePagedResource(loadResources); + const grantsState = usePagedResource(loadGrants); + + const execute = async (request, successMessage) => { + setSubmitting(true); + try { + await request(); + showToast(successMessage, "success"); + setDialog(""); + grantsState.reload(); + } catch (requestError) { + showToast(requestError.message || "发放失败", "error"); + } finally { + setSubmitting(false); + } + }; + + const resolveTarget = async (input) => { + const target = await lookupGrantTarget(input); + if (!target.id) { + throw new Error("未找到当前 App 内的目标用户"); + } + return target; + }; + + const submitResource = (form) => execute(async () => { + const target = await resolveTarget(form.target); + await grantResource({ + commandId: createCommandId("external-resource-grant"), + durationMs: Number(form.durationDays) * 86400000, + quantity: Number(form.quantity), + reason: form.reason.trim(), + resourceId: Number(form.resourceId), + targetUserId: target.id + }); + }, "特权道具已发放"); + + const submitPrettyId = (form) => execute(async () => { + const target = await resolveTarget(form.target); + await grantPrettyId({ + display_user_id: form.prettyId, + duration_ms: Number(form.durationDays) * 86400000, + reason: form.reason.trim(), + target_user_id: target.id + }); + }, "靓号已下发"); + + const columns = useMemo(() => [ + { key: "target", label: "目标用户", render: (item) => String(item.targetUserId || item.target_user_id || item.userId || item.user_id || "-") }, + { key: "resource", label: "资源", render: (item) => String(item.resourceName || item.resource_name || item.resourceId || item.resource_id || item.subject || "-") }, + { key: "quantity", label: "数量", render: (item) => String(item.quantity ?? item.amount ?? "-") }, + { key: "status", label: "状态", render: (item) => }, + { key: "reason", label: "原因", render: (item) => String(item.reason || "-") } + ], []); + + return ( + {canGrant ? : null}{canGrantPrettyId ? : null}} + title="资源与靓号" + > + {canGrant ? ( + + 可发放资源 + + {resourcesState.data.items.slice(0, 12).map((resource) => {resource.name})} + {!resourcesState.loading && !resourcesState.data.items.length ? 当前无数据 : null} + + + ) : null} + {canList ? <>{!grantsState.loading && !grantsState.error ? <> item.grantId || item.grant_id || index} /> : null} : null} + setDialog("")} onSubmit={submitResource} /> + setDialog("")} onSubmit={submitPrettyId} /> + + ); +} + +function createCommandId(prefix) { + return `${prefix}-${globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`}`; +} diff --git a/external-admin/src/pages/LoginPage.jsx b/external-admin/src/pages/LoginPage.jsx new file mode 100644 index 0000000..ba44b37 --- /dev/null +++ b/external-admin/src/pages/LoginPage.jsx @@ -0,0 +1,123 @@ +import LockOutlined from "@mui/icons-material/LockOutlined"; +import VisibilityOffOutlined from "@mui/icons-material/VisibilityOffOutlined"; +import VisibilityOutlined from "@mui/icons-material/VisibilityOutlined"; +import Alert from "@mui/material/Alert"; +import Avatar from "@mui/material/Avatar"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import CircularProgress from "@mui/material/CircularProgress"; +import FormControl from "@mui/material/FormControl"; +import IconButton from "@mui/material/IconButton"; +import InputAdornment from "@mui/material/InputAdornment"; +import InputLabel from "@mui/material/InputLabel"; +import MenuItem from "@mui/material/MenuItem"; +import Select from "@mui/material/Select"; +import Stack from "@mui/material/Stack"; +import TextField from "@mui/material/TextField"; +import Typography from "@mui/material/Typography"; +import { useCallback, useEffect, useState } from "react"; +import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; +import { listExternalApps } from "../api/auth.js"; +import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; + +export default function LoginPage() { + const { initializing, login, session } = useExternalAuth(); + const [apps, setApps] = useState([]); + const [appsError, setAppsError] = useState(""); + const [appsLoading, setAppsLoading] = useState(true); + const [form, setForm] = useState({ account: "", appCode: "", password: "" }); + const [error, setError] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const location = useLocation(); + + const loadApps = useCallback(async () => { + setAppsLoading(true); + setAppsError(""); + try { + const items = await listExternalApps(); + setApps(items); + const requestedApp = searchParams.get("app") || ""; + const preferredApp = items.find((item) => item.appCode === requestedApp)?.appCode || items[0]?.appCode || requestedApp; + setForm((current) => ({ ...current, appCode: current.appCode || preferredApp })); + } catch (requestError) { + setAppsError(requestError.message || "App 列表加载失败"); + } finally { + setAppsLoading(false); + } + }, [searchParams]); + + useEffect(() => { + loadApps(); + }, [loadApps]); + + useEffect(() => { + if (!initializing && session) { + const destination = session.passwordChangeRequired ? "/change-password" : location.state?.from?.pathname || "/overview"; + navigate(destination, { replace: true }); + } + }, [initializing, location.state, navigate, session]); + + const handleSubmit = async (event) => { + event.preventDefault(); + if (!form.appCode || !form.account.trim() || !form.password) { + setError("请选择 App 并填写账号、密码"); + return; + } + setSubmitting(true); + setError(""); + try { + const nextSession = await login(form); + navigate(nextSession.passwordChangeRequired ? "/change-password" : "/overview", { replace: true }); + } catch (requestError) { + setError(requestError.message || "登录失败"); + } finally { + setSubmitting(false); + } + }; + + return ( + + + + + + 外管后台登录 + + + {error ? {error} : null} + {appsError ? 重试} severity="error">{appsError} : null} + {apps.length || appsLoading ? ( + + App + + + ) : ( + setForm({ ...form, appCode: event.target.value.trim() })} /> + )} + setForm({ ...form, account: event.target.value })} /> + setForm({ ...form, password: event.target.value })} + slotProps={{ input: { endAdornment: setShowPassword((current) => !current)}>{showPassword ? : } } }} + /> + + + + + + ); +} diff --git a/external-admin/src/pages/OrganizationPage.jsx b/external-admin/src/pages/OrganizationPage.jsx new file mode 100644 index 0000000..57eb724 --- /dev/null +++ b/external-admin/src/pages/OrganizationPage.jsx @@ -0,0 +1,70 @@ +import Avatar from "@mui/material/Avatar"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Stack from "@mui/material/Stack"; +import TextField from "@mui/material/TextField"; +import Typography from "@mui/material/Typography"; +import Alert from "@mui/material/Alert"; +import { useCallback, useMemo, useState } from "react"; +import { useParams } from "react-router-dom"; +import { listOrganizations } from "../api/business.js"; +import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; +import { hasExternalCapability } from "../config/capabilities.js"; +import { ExternalPage, ExternalPageState, PagePagination, ResponsiveDataList, StatusChip } from "../shared/PageComponents.jsx"; +import { usePagedResource } from "../shared/usePagedResource.js"; + +const organizationKinds = { + agencies: { capability: "agency:list", title: "公会列表" }, + bds: { capability: "bd:list", title: "BD 列表" }, + "bd-leaders": { capability: "bd-manager:list", title: "BD Manager 列表" }, + hosts: { capability: "host:list", title: "主播列表" }, + managers: { capability: "bd-manager:list", title: "管理员列表" }, + "super-admins": { capability: "super-admin:list", title: "Super Admin 列表" }, + team: { capability: "team:view", title: "我的团队" } +}; + +export default function OrganizationPage() { + const { kind = "hosts" } = useParams(); + const config = organizationKinds[kind]; + const { session } = useExternalAuth(); + const [keyword, setKeyword] = useState(""); + const [query, setQuery] = useState({ keyword: "", page: 1 }); + const allowed = Boolean(config && hasExternalCapability(session, config.capability)); + const load = useCallback(() => allowed ? listOrganizations(kind, { keyword: query.keyword, page: query.page, page_size: 20 }) : Promise.resolve({ items: [], page: 1, pageSize: 20, total: 0 }), [allowed, kind, query]); + const { data, error, loading, reload } = usePagedResource(load); + const columns = useMemo(() => kind === "team" ? [ + { key: "agency", label: "公会", render: (item) => 公会 {item.id || "-"}Owner {item.userId || "-"} }, + { key: "parent", label: "上级 BD", render: (item) => item.parentBdUserId || "-" }, + { key: "status", label: "状态", render: (item) => } + ] : [ + { key: "identity", label: "成员", render: (item) => {(item.name || item.username || "-").slice(0, 1)}{item.name || item.username || "-"}ID {item.id || "-"}{item.userId && item.userId !== item.id ? ` / 用户 ${item.userId}` : ""} }, + { key: "prettyId", label: "短 ID(靓号)", render: (item) => item.prettyId || "-" }, + { key: "country", label: "国家/地区", render: (item) => item.country || "-" }, + { key: "status", label: "状态", render: (item) => } + ], [kind]); + + if (!config || !allowed) { + return 当前外管账号没有访问该组织列表的权限。; + } + + return ( + + { event.preventDefault(); setQuery({ keyword, page: 1 }); }}> + setKeyword(event.target.value)} /> + + + + {kind === "team" && !loading && !error ? ( + + + BD Leader:{data.totalBdLeaders || 0} + BD:{data.totalBds || 0} + 公会:{data.total || data.items.length} + + {data.truncated ? 团队规模超过接口上限,当前结果不完整 : null} + + ) : null} + {!loading && !error ? <> item.id || index} /> setQuery({ ...query, page })} /> : null} + + ); +} diff --git a/external-admin/src/pages/OverviewPage.jsx b/external-admin/src/pages/OverviewPage.jsx new file mode 100644 index 0000000..c2bfc3a --- /dev/null +++ b/external-admin/src/pages/OverviewPage.jsx @@ -0,0 +1,50 @@ +import CheckCircleOutlineOutlined from "@mui/icons-material/CheckCircleOutlineOutlined"; +import LockOutlined from "@mui/icons-material/LockOutlined"; +import Box from "@mui/material/Box"; +import Card from "@mui/material/Card"; +import CardActionArea from "@mui/material/CardActionArea"; +import CardContent from "@mui/material/CardContent"; +import Chip from "@mui/material/Chip"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { Link } from "react-router-dom"; +import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; +import { EXTERNAL_CAPABILITIES, hasExternalCapability } from "../config/capabilities.js"; +import { ExternalPage } from "../shared/PageComponents.jsx"; + +export default function OverviewPage() { + const { session } = useExternalAuth(); + + return ( + + + + + 当前 App{session.appName || session.appCode}({session.appCode}) + 外管账号{session.account} + 已开通权限{EXTERNAL_CAPABILITIES.filter((item) => hasExternalCapability(session, item.code)).length} / {EXTERNAL_CAPABILITIES.length} + + + + + {EXTERNAL_CAPABILITIES.map((item) => { + const enabled = hasExternalCapability(session, item.code); + const content = ( + + + {enabled ? : } + {item.label} + + + + ); + return ( + + {enabled ? {content} : content} + + ); + })} + + + ); +} diff --git a/external-admin/src/pages/OverviewPage.test.jsx b/external-admin/src/pages/OverviewPage.test.jsx new file mode 100644 index 0000000..90540d9 --- /dev/null +++ b/external-admin/src/pages/OverviewPage.test.jsx @@ -0,0 +1,31 @@ +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, test, vi } from "vitest"; +import { EXTERNAL_CAPABILITIES } from "../config/capabilities.js"; +import OverviewPage from "./OverviewPage.jsx"; + +const authState = vi.hoisted(() => ({ + session: { + account: "fami-operator", + appCode: "fami", + appName: "Fami", + capabilities: ["user:list"] + } +})); + +vi.mock("../auth/ExternalAuthProvider.jsx", () => ({ + useExternalAuth: () => authState +})); + +describe("OverviewPage", () => { + test("renders all 20 market capability entries and only links enabled entries", () => { + render(); + + expect(EXTERNAL_CAPABILITIES).toHaveLength(20); + expect(screen.getAllByText("已开通")).toHaveLength(1); + expect(screen.getAllByText("未开通")).toHaveLength(19); + expect(screen.getByText("用户列表").closest("a")).toHaveAttribute("href", "/users"); + expect(screen.getByText("用户信息编辑").closest("a")).toBeNull(); + expect(screen.getByText("我的团队")).toBeInTheDocument(); + }); +}); diff --git a/external-admin/src/pages/RoomsPage.jsx b/external-admin/src/pages/RoomsPage.jsx new file mode 100644 index 0000000..706fecd --- /dev/null +++ b/external-admin/src/pages/RoomsPage.jsx @@ -0,0 +1,63 @@ +import EditOutlined from "@mui/icons-material/EditOutlined"; +import Avatar from "@mui/material/Avatar"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Stack from "@mui/material/Stack"; +import TextField from "@mui/material/TextField"; +import Typography from "@mui/material/Typography"; +import { useCallback, useMemo, useState } from "react"; +import { useToast } from "@/shared/ui/ToastProvider.jsx"; +import { listRooms, updateRoom } from "../api/business.js"; +import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; +import { RoomEditDialog } from "../components/RoomEditDialog.jsx"; +import { hasExternalCapability } from "../config/capabilities.js"; +import { ExternalPage, ExternalPageState, PagePagination, ResponsiveDataList, StatusChip } from "../shared/PageComponents.jsx"; +import { usePagedResource } from "../shared/usePagedResource.js"; + +export default function RoomsPage() { + const { session } = useExternalAuth(); + const { showToast } = useToast(); + const [keyword, setKeyword] = useState(""); + const [query, setQuery] = useState({ keyword: "", page: 1 }); + const [room, setRoom] = useState(null); + const [submitting, setSubmitting] = useState(false); + const load = useCallback(() => listRooms({ keyword: query.keyword, page: query.page, page_size: 20 }), [query]); + const { data, error, loading, reload } = usePagedResource(load); + const canEdit = hasExternalCapability(session, "room:update"); + + const columns = useMemo(() => [ + { key: "room", label: "房间", render: (item) => {item.title || "-"}ID {item.id} }, + { key: "owner", label: "房主", render: (item) => item.ownerName || "-" }, + { key: "region", label: "可见区域", render: (item) => item.visibleRegionId || "全部" }, + { key: "status", label: "状态", render: (item) => }, + { key: "actions", label: "操作", render: (item) => canEdit ? : "-" } + ], [canEdit]); + + const submit = async (form) => { + setSubmitting(true); + try { + const regionText = String(form.visibleRegionId || "").trim(); + const visibleRegionId = regionText ? Number(regionText) : 0; + if (!Number.isSafeInteger(visibleRegionId) || visibleRegionId < 0) { + throw new Error("可见区域 ID 必须是安全的正整数"); + } + await updateRoom(room.id, { ...form, visibleRegionId }); + showToast("房间信息已更新", "success"); + setRoom(null); + reload(); + } catch (requestError) { + showToast(requestError.message || "房间更新失败", "error"); + } finally { + setSubmitting(false); + } + }; + + return ( + + { event.preventDefault(); setQuery({ keyword, page: 1 }); }}> setKeyword(event.target.value)} /> + + {!loading && !error ? <> item.id} /> setQuery({ ...query, page })} /> : null} + setRoom(null)} onSubmit={submit} /> + + ); +} diff --git a/external-admin/src/pages/UsersPage.jsx b/external-admin/src/pages/UsersPage.jsx new file mode 100644 index 0000000..72db4b7 --- /dev/null +++ b/external-admin/src/pages/UsersPage.jsx @@ -0,0 +1,77 @@ +import EditOutlined from "@mui/icons-material/EditOutlined"; +import GppBadOutlined from "@mui/icons-material/GppBadOutlined"; +import TrendingUpOutlined from "@mui/icons-material/TrendingUpOutlined"; +import Avatar from "@mui/material/Avatar"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import MenuItem from "@mui/material/MenuItem"; +import Stack from "@mui/material/Stack"; +import TextField from "@mui/material/TextField"; +import Typography from "@mui/material/Typography"; +import { useCallback, useMemo, useState } from "react"; +import { useToast } from "@/shared/ui/ToastProvider.jsx"; +import { banUser, grantUserVip, listUsers, updateUser, updateUserLevels } from "../api/business.js"; +import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; +import { UserBanDialog, UserEditDialog, UserLevelDialog } from "../components/UserDialogs.jsx"; +import { hasExternalCapability } from "../config/capabilities.js"; +import { ExternalPage, ExternalPageState, PagePagination, ResponsiveDataList, StatusChip } from "../shared/PageComponents.jsx"; +import { usePagedResource } from "../shared/usePagedResource.js"; + +export default function UsersPage() { + const { session } = useExternalAuth(); + const { showToast } = useToast(); + const [filters, setFilters] = useState({ keyword: "", status: "" }); + const [query, setQuery] = useState({ keyword: "", page: 1, status: "" }); + const [dialog, setDialog] = useState({ type: "", user: null }); + const [submitting, setSubmitting] = useState(false); + const load = useCallback(() => listUsers({ keyword: query.keyword, page: query.page, page_size: 20, status: query.status }), [query]); + const { data, error, loading, reload } = usePagedResource(load); + const canEdit = hasExternalCapability(session, "user:update"); + const canBan = hasExternalCapability(session, "user:ban"); + const canGrantLevel = hasExternalCapability(session, "user-level:grant"); + + const closeDialog = () => setDialog({ type: "", user: null }); + const execute = async (request, successMessage) => { + setSubmitting(true); + try { + await request(); + showToast(successMessage, "success"); + closeDialog(); + reload(); + } catch (requestError) { + showToast(requestError.message || "用户操作失败", "error"); + } finally { + setSubmitting(false); + } + }; + + const columns = useMemo(() => [ + { key: "user", label: "用户", render: (user) => {user.username.slice(0, 1)}{user.username || "-"}ID {user.id} }, + { key: "prettyId", label: "短 ID(靓号)", render: (user) => user.prettyId || "-" }, + { key: "country", label: "国家/性别", render: (user) => `${user.country || "-"} / ${user.gender || "-"}` }, + { key: "level", label: "等级", render: (user) => `财富 ${user.wealthLevel} / 魅力 ${user.charmLevel} / VIP ${user.vipLevel}` }, + { key: "status", label: "状态", render: (user) => }, + { key: "actions", label: "操作", render: (user) => {canEdit ? : null}{canGrantLevel ? : null}{canBan && !user.banned ? : null} } + ], [canBan, canEdit, canGrantLevel]); + + return ( + + { event.preventDefault(); setQuery({ ...filters, page: 1 }); }}> + setFilters({ ...filters, keyword: event.target.value })} /> + setFilters({ ...filters, status: event.target.value })}>全部状态正常已封禁 + + + + {!loading && !error ? <> item.id} /> setQuery({ ...query, page })} /> : null} + execute(() => updateUser(dialog.user.id, form), "用户信息已更新")} /> + execute(() => banUser(dialog.user.id, { expires_at_ms: Number(form.days) === 0 ? 0 : Date.now() + Number(form.days) * 86400000, reason: form.reason.trim() }), "用户已封禁")} /> + execute(() => form.track === "vip" + ? grantUserVip(dialog.user.id, { commandId: createLevelCommandId(), durationMs: Number(form.days) * 86400000, level: Number(form.level), reason: form.reason.trim() }) + : updateUserLevels(dialog.user.id, { adjustments: [{ duration_days: Number(form.days), level: Number(form.level), track: form.track }], reason: form.reason.trim() }), "用户等级已下发")} /> + + ); +} + +function createLevelCommandId() { + return `external-level-grant-${globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`}`; +} diff --git a/external-admin/src/shared/ConfirmDialog.jsx b/external-admin/src/shared/ConfirmDialog.jsx new file mode 100644 index 0000000..e947974 --- /dev/null +++ b/external-admin/src/shared/ConfirmDialog.jsx @@ -0,0 +1,16 @@ +import Button from "@mui/material/Button"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogContentText from "@mui/material/DialogContentText"; +import DialogTitle from "@mui/material/DialogTitle"; + +export function ConfirmDialog({ confirmColor = "primary", content, loading, onClose, onConfirm, open, title }) { + return ( + + {title} + {content} + + + ); +} diff --git a/external-admin/src/shared/ExternalImageUploadField.jsx b/external-admin/src/shared/ExternalImageUploadField.jsx new file mode 100644 index 0000000..4851533 --- /dev/null +++ b/external-admin/src/shared/ExternalImageUploadField.jsx @@ -0,0 +1,64 @@ +import CloudUploadOutlined from "@mui/icons-material/CloudUploadOutlined"; +import Alert from "@mui/material/Alert"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import CircularProgress from "@mui/material/CircularProgress"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { useRef, useState } from "react"; +import { uploadExternalImage } from "../api/business.js"; + +export function ExternalImageUploadField({ disabled, label, onChange, value }) { + const inputRef = useRef(null); + const [error, setError] = useState(""); + const [uploading, setUploading] = useState(false); + + const handleFile = async (event) => { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) { + return; + } + if (!file.type.startsWith("image/")) { + setError("请选择图片文件"); + return; + } + if (file.size > 10 * 1024 * 1024) { + setError("图片不能超过 10MB"); + return; + } + + setError(""); + setUploading(true); + try { + const url = await uploadExternalImage(file); + if (!url) { + throw new Error("上传结果缺少图片地址"); + } + onChange(url); + } catch (uploadError) { + setError(uploadError.message || "图片上传失败"); + } finally { + setUploading(false); + } + }; + + return ( + + {label} + + {value ? {`${label}预览`} : 暂无图片} + + + + {error ? {error} : null} + + ); +} diff --git a/external-admin/src/shared/PageComponents.jsx b/external-admin/src/shared/PageComponents.jsx new file mode 100644 index 0000000..86da941 --- /dev/null +++ b/external-admin/src/shared/PageComponents.jsx @@ -0,0 +1,117 @@ +import Alert from "@mui/material/Alert"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import Chip from "@mui/material/Chip"; +import Skeleton from "@mui/material/Skeleton"; +import Stack from "@mui/material/Stack"; +import Table from "@mui/material/Table"; +import TableBody from "@mui/material/TableBody"; +import TableCell from "@mui/material/TableCell"; +import TableContainer from "@mui/material/TableContainer"; +import TableHead from "@mui/material/TableHead"; +import TableRow from "@mui/material/TableRow"; +import Typography from "@mui/material/Typography"; +import useMediaQuery from "@mui/material/useMediaQuery"; +import { useTheme } from "@mui/material/styles"; + +export function ExternalPage({ actions, children, title }) { + return ( + + + + {title} + + {actions ? {actions} : null} + + {children} + + ); +} + +export function ExternalPageState({ error, loading, onRetry }) { + if (loading) { + return ( + + + + + ); + } + if (error) { + return ( + + {error} + + + ); + } + return null; +} + +export function ResponsiveDataList({ columns, emptyText = "当前无数据", items, rowKey }) { + const theme = useTheme(); + const mobile = useMediaQuery(theme.breakpoints.down("sm")); + + if (!items.length) { + return {emptyText}; + } + + if (mobile) { + return ( + + {items.map((item, index) => ( + + + {columns.map((column) => ( + + {column.label} + {column.render(item)} + + ))} + + + ))} + + ); + } + + return ( + + + + {columns.map((column) => {column.label})} + + + {items.map((item, index) => ( + + {columns.map((column) => {column.render(item)})} + + ))} + +
+
+ ); +} + +export function StatusChip({ status }) { + const normalized = String(status || "-").toLowerCase(); + const enabled = ["active", "enabled", "running", "normal", "unbanned"].includes(normalized); + const dangerous = ["banned", "disabled", "failed", "blocked"].includes(normalized); + return ; +} + +export function PagePagination({ page, pageSize, total, onChange }) { + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + return ( + + 共 {total} 条 + + + {page} / {totalPages} + + + + ); +} diff --git a/external-admin/src/shared/usePagedResource.js b/external-admin/src/shared/usePagedResource.js new file mode 100644 index 0000000..af442f8 --- /dev/null +++ b/external-admin/src/shared/usePagedResource.js @@ -0,0 +1,41 @@ +import { useCallback, useEffect, useState } from "react"; + +const EMPTY_PAGE = { items: [], page: 1, pageSize: 20, total: 0 }; + +export function usePagedResource(loader) { + const [data, setData] = useState(EMPTY_PAGE); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(true); + const [version, setVersion] = useState(0); + + const reload = useCallback(() => setVersion((current) => current + 1), []); + + useEffect(() => { + let active = true; + setLoading(true); + setError(""); + // Start inside the promise chain so synchronous validation errors use the same visible error state as network failures. + Promise.resolve() + .then(loader) + .then((result) => { + if (active) { + setData(result || EMPTY_PAGE); + } + }) + .catch((requestError) => { + if (active) { + setError(requestError.message || "数据加载失败"); + } + }) + .finally(() => { + if (active) { + setLoading(false); + } + }); + return () => { + active = false; + }; + }, [loader, version]); + + return { data, error, loading, reload }; +} diff --git a/external-admin/src/styles/index.css b/external-admin/src/styles/index.css new file mode 100644 index 0000000..2969394 --- /dev/null +++ b/external-admin/src/styles/index.css @@ -0,0 +1,299 @@ +html, +body, +#external-admin-root { + min-height: 100%; + margin: 0; +} + +body { + background: var(--bg-page); + color: var(--text-primary); + font-family: var(--font-system); +} + +.external-login-page { + min-height: 100dvh; + display: grid; + place-items: center; + padding: max(24px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right)) max(24px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left)); + background: radial-gradient(circle at 50% 0, rgba(37, 99, 235, 0.13), transparent 42%), var(--bg-page); +} + +.external-login-card { + width: min(100%, 420px); + border: 1px solid var(--border); + box-shadow: var(--shadow-panel); +} + +.external-login-card .MuiCardContent-root { + display: grid; + gap: 28px; + padding: 32px; +} + +.external-login-avatar, +.external-logo-mark { + background: var(--brand-gradient) !important; + color: #fff; +} + +.external-full-state { + min-height: 100dvh; + display: grid; + place-content: center; + justify-items: center; + gap: 16px; + padding: 24px; +} + +.external-shell, +.external-main { + min-height: 100dvh; +} + +.external-header { + border-bottom: 1px solid var(--border); + background: rgba(255, 255, 255, 0.96) !important; + backdrop-filter: blur(12px); +} + +.external-header .MuiToolbar-root { + min-height: 64px; + gap: 10px; +} + +.external-user-avatar { + width: 34px !important; + height: 34px !important; + font-size: 13px !important; + background: var(--primary) !important; +} + +.external-sidebar { + min-height: 100%; + display: flex; + flex-direction: column; + background: var(--bg-sidebar); +} + +.external-logo { + width: 248px; + min-height: 72px; + display: flex; + align-items: center; + gap: 12px; + padding: 12px 20px; +} + +.external-logo-mark { + width: 38px; + height: 38px; + display: grid; + place-items: center; + border-radius: 10px; + font-weight: 800; + box-shadow: var(--brand-shadow); +} + +.external-nav { + padding: 12px !important; + overflow-y: auto; +} + +.external-nav .MuiListItemButton-root { + min-height: 44px; + margin-bottom: 4px; + border-radius: 8px; + color: var(--text-secondary); + transition: color var(--motion-fast) var(--ease-standard), background-color var(--motion-fast) var(--ease-standard), transform var(--motion-base) var(--ease-emphasized); +} + +.external-nav .MuiListItemButton-root:hover { + background: var(--bg-hover); +} + +.external-nav .MuiListItemButton-root.Mui-selected, +.external-nav .MuiListItemButton-root.Mui-selected:hover { + background: var(--primary-surface-strong); + color: var(--primary); + font-weight: 700; +} + +.external-nav .MuiListItemIcon-root { + min-width: 38px; + color: inherit; +} + +.external-main { + background: var(--bg-page); +} + +.external-page { + display: grid; + gap: 20px; + padding: 24px; + animation: external-page-enter var(--motion-slow) var(--ease-emphasized) both; +} + +.external-session-card, +.external-table-container, +.external-filter-bar, +.external-form-panel, +.external-empty { + border: 1px solid var(--border); + border-radius: var(--radius-card); + background: var(--bg-card); +} + +.external-filter-bar, +.external-form-panel { + padding: 16px; +} + +.external-filter-bar .MuiTextField-root { + min-width: min(100%, 220px); +} + +.external-capability-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; +} + +.external-capability-card { + min-height: 150px; + opacity: 0.68; +} + +.external-capability-card.is-enabled { + opacity: 1; +} + +.external-capability-card.is-enabled:hover { + border-color: var(--primary-border); + transform: translateY(-2px); + box-shadow: var(--shadow-soft); +} + +.external-capability-card .MuiCardActionArea-root, +.external-capability-card .MuiCardContent-root { + height: 100%; +} + +.external-table-container { + overflow-x: auto; +} + +.external-table-container .MuiTableHead-root { + background: var(--bg-card-strong); +} + +.external-table-container .MuiTableCell-root { + white-space: nowrap; + border-color: var(--row-border); +} + +.external-empty { + min-height: 200px; + display: grid; + place-items: center; + color: var(--text-secondary); +} + +.external-skeleton { + min-height: 50vh; + display: grid; + grid-template-rows: auto 1fr; + gap: 12px; +} + +.external-page-number { + min-width: 64px; + display: grid; + place-items: center; +} + +.external-mobile-card .external-mobile-row { + display: grid; + grid-template-columns: minmax(92px, 0.38fr) minmax(0, 1fr); + align-items: start; + gap: 12px; + padding: 10px 0; + border-bottom: 1px solid var(--row-border); +} + +.external-mobile-card .external-mobile-row:last-child { + border-bottom: 0; +} + +.external-dialog-fields { + padding-top: 8px; +} + +.external-image-field { + display: flex; + align-items: center; + gap: 12px; + min-height: 92px; + padding: 12px; + border: 1px dashed var(--border); + border-radius: var(--radius-control); +} + +.external-image-field img, +.external-image-placeholder { + width: 104px; + height: 68px; + object-fit: cover; + border-radius: 6px; + background: var(--bg-card-strong); +} + +.external-banner-thumb { + width: 88px; + height: 50px; + object-fit: cover; + border-radius: 6px; + background: var(--bg-card-strong); +} + +.external-image-placeholder { + display: grid; + place-items: center; + color: var(--text-tertiary); + font-size: 12px; +} + +@keyframes external-page-enter { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +@media (max-width: 1199px) { + .external-capability-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } +} + +@media (max-width: 899px) { + .external-page { padding: 18px; } + .external-capability-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} + +@media (max-width: 599px) { + .external-login-card .MuiCardContent-root { padding: 24px 20px; } + .external-page { padding: 14px; gap: 14px; } + .external-capability-grid { grid-template-columns: 1fr; } + .external-capability-card { min-height: 128px; } + .external-filter-bar .MuiTextField-root { width: 100%; } + .external-image-field { align-items: flex-start; flex-direction: column; } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/src/app/navigation/menu.js b/src/app/navigation/menu.js index 66267e9..799281e 100644 --- a/src/app/navigation/menu.js +++ b/src/app/navigation/menu.js @@ -191,6 +191,7 @@ export const fallbackNavigation = [ routeNavItem("operation-coin-ledger", { icon: ReceiptLongOutlined }), routeNavItem("operation-coin-seller-ledger", { icon: ReceiptLongOutlined }), routeNavItem("operation-coin-adjustment", { icon: WalletOutlined }), + routeNavItem("operation-external-admin-users", { icon: ManageAccountsOutlined }), routeNavItem("lucky-gift", { icon: RedeemOutlined }), routeNavItem("operation-reports", { icon: FlagOutlined }), routeNavItem("operation-gift-diamond", { icon: DiamondOutlined }), diff --git a/src/app/navigation/menu.test.jsx b/src/app/navigation/menu.test.jsx index 33f9e5e..46520a7 100644 --- a/src/app/navigation/menu.test.jsx +++ b/src/app/navigation/menu.test.jsx @@ -100,6 +100,23 @@ describe("navigation menu helpers", () => { expect(flattenCodes(merged)).toContain("host-org-managers"); }); + test("places external admin users under operations with an isolated permission", () => { + const items = filterNavigationByPermission( + fallbackNavigation, + (code) => code === "external-admin-user:view", + ); + const operations = items.find((item) => item.code === "operations"); + + expect(operations?.children).toEqual([ + expect.objectContaining({ + code: "operation-external-admin-users", + label: "外管用户", + path: "/operations/external-admin-users", + permissionCode: "external-admin-user:view", + }), + ]); + }); + test("keeps payment children under payment menu when backend menu is current", () => { const mapped = mapBackendMenus([ { children: [], code: "app-config", icon: "settings", id: "app-config", label: "APP配置" }, diff --git a/src/app/permissions.ts b/src/app/permissions.ts index a8904fe..ea6a475 100644 --- a/src/app/permissions.ts +++ b/src/app/permissions.ts @@ -55,11 +55,16 @@ export const PERMISSIONS = { financeOrderCoinSellerRechargeCreate: "finance-order:coin-seller-recharge:create", financeOrderCoinSellerRechargeVerify: "finance-order:coin-seller-recharge:verify", financeOrderCoinSellerRechargeGrant: "finance-order:coin-seller-recharge:grant", + financeOrderUSDTAddressUpdate: "finance-order:usdt-address:update", coinLedgerView: "coin-ledger:view", coinSellerLedgerView: "coin-seller-ledger:view", giftRecordView: "gift-record:view", coinAdjustmentView: "coin-adjustment:view", coinAdjustmentCreate: "coin-adjustment:create", + externalAdminUserView: "external-admin-user:view", + externalAdminUserCreate: "external-admin-user:create", + externalAdminUserStatus: "external-admin-user:status", + externalAdminUserResetPassword: "external-admin-user:reset-password", reportView: "report:view", giftDiamondView: "gift-diamond:view", giftDiamondUpdate: "gift-diamond:update", @@ -261,6 +266,7 @@ export const MENU_CODES = { operationCoinSellerLedger: "operation-coin-seller-ledger", operationGiftRecords: "operation-gift-records", operationCoinAdjustment: "operation-coin-adjustment", + operationExternalAdminUsers: "operation-external-admin-users", operationReports: "operation-reports", operationGiftDiamond: "operation-gift-diamond", policyTemplate: "policy-template", diff --git a/src/app/router/routeConfig.ts b/src/app/router/routeConfig.ts index 42272d8..ce62705 100644 --- a/src/app/router/routeConfig.ts +++ b/src/app/router/routeConfig.ts @@ -9,6 +9,7 @@ import { cpConfigRoutes } from "@/features/cp-config/routes.js"; import { cpWeeklyRankRoutes } from "@/features/cp-weekly-rank/routes.js"; import { dashboardRoutes } from "@/features/dashboard/routes.js"; import { dailyTaskRoutes } from "@/features/daily-tasks/routes.js"; +import { externalAdminUserRoutes } from "@/features/external-admin-users/routes.js"; import { firstRechargeRewardRoutes } from "@/features/first-recharge-reward/routes.js"; import { gameRoutes } from "@/features/games/routes.js"; import { hostAgencyPolicyRoutes } from "@/features/host-agency-policy/routes.js"; @@ -68,6 +69,7 @@ export const adminRoutes: AdminRoute[] = [ ...vipConfigRoutes, ...resourceRoutes, ...operationsRoutes, + ...externalAdminUserRoutes, ...policyConfigRoutes, ...luckyGiftRoutes, ...paymentRoutes, diff --git a/src/features/external-admin-users/api.test.ts b/src/features/external-admin-users/api.test.ts new file mode 100644 index 0000000..bc3e742 --- /dev/null +++ b/src/features/external-admin-users/api.test.ts @@ -0,0 +1,121 @@ +import { afterEach, expect, test, vi } from "vitest"; +import { setAccessToken, setSelectedAppCode } from "@/shared/api/request"; +import { + createExternalAdminUser, + listExternalAdminUsers, + lookupExternalAdminUserTarget, + resetExternalAdminUserPassword, + updateExternalAdminUserStatus, +} from "./api"; + +afterEach(() => { + setAccessToken(""); + setSelectedAppCode("lalu"); + vi.unstubAllGlobals(); +}); + +test("external admin APIs pin every read and write to the explicit App scope", async () => { + setSelectedAppCode("lalu"); + const externalUser = externalUserFixture(); + vi.stubGlobal( + "fetch", + vi.fn() + .mockResolvedValueOnce(envelope({ items: [externalUser], page: 1, page_size: 50, total: 1 })) + .mockResolvedValueOnce(envelope({ linked_user: externalUser.linked_user })) + .mockResolvedValueOnce(envelope(externalUser, 201)) + .mockResolvedValueOnce(envelope({ ...externalUser, status: "disabled" })) + .mockResolvedValueOnce(envelope(externalUser)), + ); + + const list = await listExternalAdminUsers(" Huwaa ", { + keyword: "ops.fami", + page: 1, + page_size: 50, + status: "active", + }); + const target = await lookupExternalAdminUserTarget("huwaa", "VIP-1001"); + await createExternalAdminUser("huwaa", { + password: "StrongPass123!", + targetUserId: "user-1001", + username: "ops.fami", + }); + await updateExternalAdminUserStatus("huwaa", 71, "disabled"); + await resetExternalAdminUserPassword("huwaa", 71, "NextStrongPass123!"); + + const calls = vi.mocked(fetch).mock.calls; + expect(calls.map(([, init]) => init?.method)).toEqual(["GET", "GET", "POST", "PATCH", "POST"]); + calls.forEach(([, init]) => { + expect(init?.headers).toMatchObject({ "X-App-Code": "huwaa" }); + }); + expect(String(calls[0][0])).toContain("/api/v1/admin/external-admin-users?"); + expect(String(calls[0][0])).toContain("keyword=ops.fami"); + expect(String(calls[0][0])).toContain("status=active"); + expect(String(calls[1][0])).toContain("/api/v1/admin/external-admin-users/target?display_user_id=VIP-1001"); + expect(String(calls[3][0])).toContain("/api/v1/admin/external-admin-users/71/status"); + expect(String(calls[4][0])).toContain("/api/v1/admin/external-admin-users/71/password"); + expect(requestBody(calls[2][1])).toEqual({ + password: "StrongPass123!", + targetUserId: "user-1001", + username: "ops.fami", + }); + expect(requestBody(calls[3][1])).toEqual({ status: "disabled" }); + expect(requestBody(calls[4][1])).toEqual({ password: "NextStrongPass123!" }); + expect(list).toMatchObject({ + page: 1, + pageSize: 50, + total: 1, + items: [ + { + appCode: "huwaa", + createdAtMs: 1784073600000, + createdByAdminId: 9, + id: 71, + linkedUser: { + defaultDisplayUserId: "1001", + prettyDisplayUserId: "VIP-1001", + userId: "user-1001", + }, + permissions: ["user:list", "room:edit"], + status: "active", + username: "ops.fami", + }, + ], + }); + expect(target).toMatchObject({ + defaultDisplayUserId: "1001", + prettyDisplayUserId: "VIP-1001", + userId: "user-1001", + }); +}); + +function envelope(data: unknown, status = 200) { + return new Response(JSON.stringify({ code: 0, data }), { status }); +} + +function requestBody(init: RequestInit | undefined) { + return JSON.parse(String(init?.body || "{}")); +} + +function externalUserFixture() { + return { + app_code: "huwaa", + created_at_ms: "1784073600000", + created_by_admin_id: 9, + id: 71, + last_login_at_ms: null, + linked_user: { + avatar: "https://cdn.example.com/u.png", + default_display_user_id: "1001", + display_user_id: "VIP-1001", + pretty_display_user_id: "VIP-1001", + pretty_id: "pretty-1", + status: "active", + user_id: "user-1001", + username: "Fami user", + }, + permissions: ["user:list", "room:edit"], + status: "active", + updated_at_ms: "1784077200000", + username: "ops.fami", + }; +} diff --git a/src/features/external-admin-users/api.ts b/src/features/external-admin-users/api.ts new file mode 100644 index 0000000..e815302 --- /dev/null +++ b/src/features/external-admin-users/api.ts @@ -0,0 +1,223 @@ +import { apiRequest } from "@/shared/api/request"; +import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints"; +import type { ApiPage, CoinLedgerUserDto, EntityId, PageQuery } from "@/shared/api/types"; + +export type ExternalAdminUserStatus = "active" | "disabled"; + +export interface ExternalAdminLinkedUserDto extends CoinLedgerUserDto { + defaultDisplayUserId?: string; + status?: string; +} + +export interface ExternalAdminUserDto { + appCode?: string; + createdAtMs?: number; + createdByAdminId?: EntityId; + id: EntityId; + lastLoginAtMs?: number; + linkedUser: ExternalAdminLinkedUserDto; + permissions: string[]; + status: ExternalAdminUserStatus; + updatedAtMs?: number; + username: string; +} + +export interface ExternalAdminUserTargetDto extends ExternalAdminLinkedUserDto { + existingExternalAdminUser?: ExternalAdminUserDto; +} + +export interface CreateExternalAdminUserPayload { + password: string; + targetUserId: string; + username: string; +} + +export async function listExternalAdminUsers( + appCode: string, + query: PageQuery = {}, +): Promise> { + const endpoint = API_ENDPOINTS.listExternalAdminUsers; + const data = await apiRequest>(apiEndpointPath(API_OPERATIONS.listExternalAdminUsers), { + headers: appScopeHeaders(appCode), + method: endpoint.method, + query, + }); + return normalizePage(data, query); +} + +export async function lookupExternalAdminUserTarget( + appCode: string, + displayUserId: string, +): Promise { + const endpoint = API_ENDPOINTS.lookupExternalAdminUserTarget; + const data = await apiRequest(apiEndpointPath(API_OPERATIONS.lookupExternalAdminUserTarget), { + headers: appScopeHeaders(appCode), + method: endpoint.method, + query: { display_user_id: displayUserId }, + }); + return normalizeTarget(data); +} + +export async function createExternalAdminUser( + appCode: string, + payload: CreateExternalAdminUserPayload, +): Promise { + const endpoint = API_ENDPOINTS.createExternalAdminUser; + const data = await apiRequest( + apiEndpointPath(API_OPERATIONS.createExternalAdminUser), + { + body: payload, + headers: appScopeHeaders(appCode), + method: endpoint.method, + }, + ); + return normalizeExternalAdminUser(data); +} + +export async function updateExternalAdminUserStatus( + appCode: string, + id: EntityId, + status: ExternalAdminUserStatus, +): Promise { + const endpoint = API_ENDPOINTS.updateExternalAdminUserStatus; + const data = await apiRequest( + apiEndpointPath(API_OPERATIONS.updateExternalAdminUserStatus, { id }), + { + body: { status }, + headers: appScopeHeaders(appCode), + method: endpoint.method, + }, + ); + return normalizeExternalAdminUser(data); +} + +export async function resetExternalAdminUserPassword( + appCode: string, + id: EntityId, + password: string, +): Promise { + const endpoint = API_ENDPOINTS.resetExternalAdminUserPassword; + const data = await apiRequest( + apiEndpointPath(API_OPERATIONS.resetExternalAdminUserPassword, { id }), + { + body: { password }, + headers: appScopeHeaders(appCode), + method: endpoint.method, + }, + ); + return normalizeExternalAdminUser(data); +} + +function appScopeHeaders(appCode: string) { + // 外管账号是强 App 隔离数据;显式固定请求头,避免切换 App 后全局请求上下文把写操作送到另一租户。 + return { "X-App-Code": String(appCode || "").trim().toLowerCase() }; +} + +type RawRecord = Record; + +interface RawPage { + items?: TItem[]; + page?: number; + page_size?: number; + pageSize?: number; + total?: number; +} + +function normalizePage(data: RawPage, query: PageQuery): ApiPage { + return { + items: (Array.isArray(data?.items) ? data.items : []).map(normalizeExternalAdminUser), + page: numberValue(data?.page, Number(query.page || 1)), + pageSize: numberValue(data?.pageSize ?? data?.page_size, Number(query.page_size || 50)), + total: numberValue(data?.total), + }; +} + +function normalizeExternalAdminUser(raw: RawRecord): ExternalAdminUserDto { + const item = asRecord(raw) || {}; + const linkedUser = normalizeUser( + asRecord(item.linkedUser) || + asRecord(item.linked_user) || + asRecord(item.user) || + asRecord(item.targetUser) || + asRecord(item.target_user) || + item, + ); + const status = stringValue(item.status).toLowerCase(); + return { + appCode: stringValue(item.appCode ?? item.app_code), + createdAtMs: optionalNumber(item.createdAtMs ?? item.created_at_ms), + createdByAdminId: entityValue(item.createdByAdminId ?? item.created_by_admin_id), + id: entityValue(item.id ?? item.externalAdminUserId ?? item.external_admin_user_id), + lastLoginAtMs: optionalNumber(item.lastLoginAtMs ?? item.last_login_at_ms), + linkedUser, + permissions: stringArray(item.permissions), + status: status === "disabled" ? "disabled" : "active", + updatedAtMs: optionalNumber(item.updatedAtMs ?? item.updated_at_ms), + username: stringValue(item.username ?? item.account ?? item.accountName ?? item.account_name), + }; +} + +function normalizeTarget(raw: RawRecord): ExternalAdminUserTargetDto { + const item = asRecord(raw) || {}; + const user = normalizeUser( + asRecord(item.linkedUser) || + asRecord(item.linked_user) || + asRecord(item.user) || + asRecord(item.targetUser) || + asRecord(item.target_user) || + item, + ); + const existing = + asRecord(item.existingExternalAdminUser) || + asRecord(item.existing_external_admin_user) || + asRecord(item.existingAccount) || + asRecord(item.existing_account); + return { + ...user, + existingExternalAdminUser: existing ? normalizeExternalAdminUser(existing) : undefined, + }; +} + +function normalizeUser(raw: RawRecord): ExternalAdminLinkedUserDto { + const user = asRecord(raw) || {}; + return { + avatar: stringValue(user.avatar), + defaultDisplayUserId: stringValue(user.defaultDisplayUserId ?? user.default_display_user_id), + displayUserId: stringValue( + user.displayUserId ?? user.display_user_id ?? user.defaultDisplayUserId ?? user.default_display_user_id, + ), + prettyDisplayUserId: stringValue(user.prettyDisplayUserId ?? user.pretty_display_user_id), + prettyId: stringValue(user.prettyId ?? user.pretty_id), + status: stringValue(user.status), + userId: stringValue(user.userId ?? user.user_id ?? user.targetUserId ?? user.target_user_id), + username: stringValue(user.username ?? user.name), + }; +} + +function asRecord(value: unknown): RawRecord | undefined { + return value && typeof value === "object" && !Array.isArray(value) ? (value as RawRecord) : undefined; +} + +function entityValue(value: unknown): EntityId { + return typeof value === "number" ? value : stringValue(value); +} + +function numberValue(value: unknown, fallback = 0) { + const number = Number(value); + return Number.isFinite(number) ? number : fallback; +} + +function optionalNumber(value: unknown) { + if (value === undefined || value === null || value === "") { + return undefined; + } + return numberValue(value); +} + +function stringValue(value: unknown) { + return value === undefined || value === null ? "" : String(value); +} + +function stringArray(value: unknown) { + return Array.isArray(value) ? value.map(stringValue).filter(Boolean) : []; +} diff --git a/src/features/external-admin-users/components/ExternalAdminUserFormDialog.jsx b/src/features/external-admin-users/components/ExternalAdminUserFormDialog.jsx new file mode 100644 index 0000000..ce0a679 --- /dev/null +++ b/src/features/external-admin-users/components/ExternalAdminUserFormDialog.jsx @@ -0,0 +1,113 @@ +import SearchOutlined from "@mui/icons-material/SearchOutlined"; +import TextField from "@mui/material/TextField"; +import { Button } from "@/shared/ui/Button.jsx"; +import { + AdminFormDialog, + AdminFormFieldGrid, + AdminFormSection, +} from "@/shared/ui/AdminFormDialog.jsx"; +import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx"; +import styles from "@/features/external-admin-users/external-admin-users.module.css"; + +export function ExternalAdminUserFormDialog({ + abilities, + form, + loading, + lookupLoading, + onClose, + onDisplayUserIdChange, + onFormChange, + onLookup, + onSubmit, + open, + targetUser, +}) { + const existingAccount = targetUser?.existingExternalAdminUser; + + return ( + + +
+ onDisplayUserIdChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + onLookup(); + } + }} + /> + +
+ {targetUser ? ( +
+ + {existingAccount ? ( +
+ 该用户已绑定外管账号 {existingAccount.username || existingAccount.id},不能重复创建。 +
+ ) : null} +
+ ) : null} +
+ + + + onFormChange({ username: event.target.value })} + /> + onFormChange({ password: event.target.value })} + /> + onFormChange({ confirmPassword: event.target.value })} + /> + + +
+ ); +} diff --git a/src/features/external-admin-users/components/ExternalAdminUserPasswordDialog.jsx b/src/features/external-admin-users/components/ExternalAdminUserPasswordDialog.jsx new file mode 100644 index 0000000..aac97ea --- /dev/null +++ b/src/features/external-admin-users/components/ExternalAdminUserPasswordDialog.jsx @@ -0,0 +1,55 @@ +import TextField from "@mui/material/TextField"; +import { AdminFormDialog, AdminFormFieldGrid, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx"; +import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx"; + +export function ExternalAdminUserPasswordDialog({ + abilities, + form, + loading, + onClose, + onFormChange, + onSubmit, + user, +}) { + return ( + + + + + + + onFormChange({ password: event.target.value })} + /> + onFormChange({ confirmPassword: event.target.value })} + /> + + + + ); +} diff --git a/src/features/external-admin-users/external-admin-users.module.css b/src/features/external-admin-users/external-admin-users.module.css new file mode 100644 index 0000000..35ff7ed --- /dev/null +++ b/src/features/external-admin-users/external-admin-users.module.css @@ -0,0 +1,61 @@ +.lookupRow { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: var(--space-3); +} + +.lookupButton { + min-width: 92px; +} + +.targetPanel { + display: grid; + gap: var(--space-3); + padding: var(--space-4); + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--bg-card-strong); +} + +.existingWarning { + padding: var(--space-3); + border: 1px solid var(--danger-border); + border-radius: var(--radius-control); + background: var(--danger-surface); + color: var(--danger); + font-size: 13px; + line-height: 20px; +} + +.accountCell { + display: grid; + min-width: 0; + gap: var(--space-1); +} + +.accountName { + overflow: hidden; + color: var(--text-primary); + font-weight: 720; + text-overflow: ellipsis; + white-space: nowrap; +} + +.accountMeta { + overflow: hidden; + color: var(--text-tertiary); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +@media (max-width: 760px) { + .lookupRow { + grid-template-columns: 1fr; + } + + .lookupButton { + width: 100%; + } +} diff --git a/src/features/external-admin-users/hooks/useExternalAdminUsersPage.js b/src/features/external-admin-users/hooks/useExternalAdminUsersPage.js new file mode 100644 index 0000000..4584d50 --- /dev/null +++ b/src/features/external-admin-users/hooks/useExternalAdminUsersPage.js @@ -0,0 +1,377 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useAppScope } from "@/app/app-scope/AppScopeProvider.jsx"; +import { + createExternalAdminUser, + listExternalAdminUsers, + lookupExternalAdminUserTarget, + resetExternalAdminUserPassword, + updateExternalAdminUserStatus, +} from "@/features/external-admin-users/api"; +import { useExternalAdminUserAbilities } from "@/features/external-admin-users/permissions.js"; +import { + externalAdminUserCreateFormSchema, + externalAdminUserLookupSchema, + externalAdminUserPasswordFormSchema, +} from "@/features/external-admin-users/schema"; +import { toPageQuery } from "@/shared/api/query"; +import { parseForm } from "@/shared/forms/validation"; +import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js"; +import { mergePaginatedItems } from "@/shared/hooks/usePaginatedQuery.js"; +import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx"; +import { useToast } from "@/shared/ui/ToastProvider.jsx"; + +const pageSize = 50; +const emptyPage = { items: [], page: 1, pageSize, total: 0 }; +const emptyCreateForm = () => ({ confirmPassword: "", displayUserId: "", password: "", username: "" }); +const emptyPasswordForm = () => ({ confirmPassword: "", password: "" }); + +export function useExternalAdminUsersPage() { + const { appCode } = useAppScope(); + const abilities = useExternalAdminUserAbilities(); + const confirm = useConfirm(); + const queryClient = useQueryClient(); + const { showToast } = useToast(); + const currentAppCodeRef = useRef(appCode); + const lookupRequestRef = useRef(0); + + const [keyword, setKeywordState] = useState(""); + const [status, setStatusState] = useState(""); + const [page, setPage] = useState(1); + const [mergedPage, setMergedPage] = useState(() => ({ ...emptyPage, appCode })); + const [createOpen, setCreateOpen] = useState(false); + const [createForm, setCreateFormState] = useState(emptyCreateForm); + const [targetUser, setTargetUser] = useState(null); + const [lookupLoading, setLookupLoading] = useState(false); + const [passwordUser, setPasswordUser] = useState(null); + const [passwordForm, setPasswordFormState] = useState(emptyPasswordForm); + const [actionAppCode, setActionAppCode] = useState(""); + const [loadingAction, setLoadingAction] = useState(""); + + const requestQuery = useMemo( + () => toPageQuery({ keyword, page, pageSize, status }), + [keyword, page, status], + ); + const queryFn = useCallback(() => listExternalAdminUsers(appCode, requestQuery), [appCode, requestQuery]); + const { + data: queryPage = emptyPage, + error, + loading: queryLoading, + reload, + } = useAdminQuery(queryFn, { + enabled: Boolean(appCode && abilities.canView), + errorMessage: "加载外管用户失败", + initialData: emptyPage, + queryKey: ["external-admin-users", appCode, requestQuery], + }); + + useLayoutEffect(() => { + // 写操作完成回调依赖最新租户;布局 effect 在用户能继续交互前同步边界,又不在渲染阶段读写 ref。 + currentAppCodeRef.current = appCode; + }, [appCode]); + + useEffect(() => { + // App 是外管账号的租户边界;切换后必须立即丢弃账号、密码和用户匹配结果,禁止跨 App 复用敏感表单。 + lookupRequestRef.current += 1; + setCreateOpen(false); + setCreateFormState(emptyCreateForm()); + setTargetUser(null); + setLookupLoading(false); + setPasswordUser(null); + setPasswordFormState(emptyPasswordForm()); + setActionAppCode(""); + setLoadingAction(""); + setKeywordState(""); + setStatusState(""); + setPage(1); + setMergedPage({ ...emptyPage, appCode }); + }, [appCode]); + + useEffect(() => { + setMergedPage({ ...emptyPage, appCode }); + }, [appCode, keyword, status]); + + useEffect(() => { + if (queryLoading) { + return; + } + const nextItems = Array.isArray(queryPage?.items) ? queryPage.items : []; + const nextPage = Number(queryPage?.page || page || 1); + const nextPageSize = Number(queryPage?.pageSize || pageSize); + const nextTotal = Number(queryPage?.total || 0); + + setMergedPage((current) => { + // 即使旧 App 请求晚到,也不能进入当前表格;scope 标记同时避免 React effect 执行前闪现旧租户数据。 + if (current.appCode !== appCode || nextPage <= 1) { + return { ...queryPage, appCode, items: nextItems, page: nextPage, pageSize: nextPageSize, total: nextTotal }; + } + return { + ...queryPage, + appCode, + items: mergePaginatedItems(current.items || [], nextItems), + page: nextPage, + pageSize: nextPageSize, + total: nextTotal, + }; + }); + }, [appCode, page, queryLoading, queryPage]); + + const data = mergedPage.appCode === appCode ? mergedPage : { ...emptyPage, appCode }; + const loading = queryLoading && data.items.length === 0; + + const setKeyword = (value) => { + setKeywordState(value); + setPage(1); + }; + const setStatus = (value) => { + setStatusState(value); + setPage(1); + }; + const resetFilters = () => { + setKeywordState(""); + setStatusState(""); + setPage(1); + }; + + const setCreateForm = (patch) => { + setCreateFormState((current) => ({ ...current, ...patch })); + }; + const changeDisplayUserId = (value) => { + lookupRequestRef.current += 1; + setCreateForm({ displayUserId: value }); + setTargetUser(null); + setLookupLoading(false); + }; + const openCreate = () => { + lookupRequestRef.current += 1; + setCreateFormState(emptyCreateForm()); + setTargetUser(null); + setLookupLoading(false); + setActionAppCode(appCode); + setCreateOpen(true); + }; + const closeCreate = () => { + if (loadingAction === "create") { + return; + } + lookupRequestRef.current += 1; + setCreateOpen(false); + setCreateFormState(emptyCreateForm()); + setTargetUser(null); + setLookupLoading(false); + setActionAppCode(""); + }; + + const lookupTarget = async () => { + let displayUserId; + try { + ({ displayUserId } = parseForm(externalAdminUserLookupSchema, createForm)); + } catch (err) { + showToast(err.message || "请输入用户短 ID / 靓号", "error"); + return; + } + const scope = appCode; + const requestId = lookupRequestRef.current + 1; + lookupRequestRef.current = requestId; + setLookupLoading(true); + setTargetUser(null); + try { + const result = await lookupExternalAdminUserTarget(scope, displayUserId); + if (currentAppCodeRef.current === scope && lookupRequestRef.current === requestId) { + setTargetUser(result); + } + } catch (err) { + if (currentAppCodeRef.current === scope && lookupRequestRef.current === requestId) { + showToast(err.message || "查询 App 用户失败", "error"); + } + } finally { + if (currentAppCodeRef.current === scope && lookupRequestRef.current === requestId) { + setLookupLoading(false); + } + } + }; + + const refreshList = useCallback( + async (scope) => { + if (currentAppCodeRef.current !== scope) { + return; + } + setPage(1); + setMergedPage({ ...emptyPage, appCode: scope }); + await queryClient.invalidateQueries({ queryKey: ["external-admin-users", scope] }); + }, + [queryClient], + ); + + const submitCreate = async (event) => { + event.preventDefault(); + const scope = actionAppCode; + if (!scope || scope !== appCode) { + showToast("应用已切换,请重新创建", "error"); + return; + } + if (!targetUser?.userId) { + showToast("请先搜索并确认 App 用户", "error"); + return; + } + if (targetUser.existingExternalAdminUser) { + showToast("该 App 用户已绑定外管账号", "error"); + return; + } + let formPayload; + try { + formPayload = parseForm(externalAdminUserCreateFormSchema, createForm); + } catch (err) { + showToast(err.message || "创建参数不正确", "error"); + return; + } + + setLoadingAction("create"); + try { + await createExternalAdminUser(scope, { + password: formPayload.password, + // 始终提交查询接口返回的稳定 userId,短 ID / 靓号只用于定位,避免后续改号造成错误绑定。 + targetUserId: targetUser.userId, + username: formPayload.username, + }); + if (currentAppCodeRef.current !== scope) { + return; + } + setCreateOpen(false); + setCreateFormState(emptyCreateForm()); + setTargetUser(null); + setActionAppCode(""); + showToast("外管用户已创建", "success"); + await refreshList(scope); + } catch (err) { + if (currentAppCodeRef.current === scope) { + showToast(err.message || "创建外管用户失败", "error"); + } + } finally { + if (currentAppCodeRef.current === scope) { + setLoadingAction(""); + } + } + }; + + const toggleStatus = async (item) => { + const scope = appCode; + const nextStatus = item.status === "active" ? "disabled" : "active"; + const actionLabel = nextStatus === "active" ? "启用" : "停用"; + const ok = await confirm({ + confirmText: actionLabel, + message: `${item.username || item.id} 的外管登录权限会立即${nextStatus === "active" ? "恢复" : "失效"}。`, + title: `${actionLabel}外管用户`, + tone: nextStatus === "active" ? "primary" : "danger", + }); + if (!ok || currentAppCodeRef.current !== scope) { + return; + } + + setLoadingAction(`status:${item.id}`); + try { + await updateExternalAdminUserStatus(scope, item.id, nextStatus); + if (currentAppCodeRef.current !== scope) { + return; + } + showToast(`外管用户已${actionLabel}`, "success"); + await refreshList(scope); + } catch (err) { + if (currentAppCodeRef.current === scope) { + showToast(err.message || `${actionLabel}外管用户失败`, "error"); + } + } finally { + if (currentAppCodeRef.current === scope) { + setLoadingAction(""); + } + } + }; + + const openPassword = (item) => { + setPasswordUser(item); + setPasswordFormState(emptyPasswordForm()); + setActionAppCode(appCode); + }; + const closePassword = () => { + if (loadingAction.startsWith("password:")) { + return; + } + setPasswordUser(null); + setPasswordFormState(emptyPasswordForm()); + setActionAppCode(""); + }; + const setPasswordForm = (patch) => { + setPasswordFormState((current) => ({ ...current, ...patch })); + }; + const submitPassword = async (event) => { + event.preventDefault(); + const scope = actionAppCode; + const item = passwordUser; + if (!item || !scope || scope !== appCode) { + showToast("应用已切换,请重新操作", "error"); + return; + } + let payload; + try { + payload = parseForm(externalAdminUserPasswordFormSchema, passwordForm); + } catch (err) { + showToast(err.message || "密码参数不正确", "error"); + return; + } + + setLoadingAction(`password:${item.id}`); + try { + await resetExternalAdminUserPassword(scope, item.id, payload.password); + if (currentAppCodeRef.current !== scope) { + return; + } + setPasswordUser(null); + setPasswordFormState(emptyPasswordForm()); + setActionAppCode(""); + showToast("外管密码已重置", "success"); + await refreshList(scope); + } catch (err) { + if (currentAppCodeRef.current === scope) { + showToast(err.message || "重置外管密码失败", "error"); + } + } finally { + if (currentAppCodeRef.current === scope) { + setLoadingAction(""); + } + } + }; + + return { + abilities, + closeCreate, + closePassword, + createForm, + createOpen, + data, + error, + keyword, + loading, + loadingAction, + lookupLoading, + lookupTarget, + openCreate, + openPassword, + page, + pageSize, + passwordForm, + passwordUser, + reload, + resetFilters, + setCreateForm, + setDisplayUserId: changeDisplayUserId, + setKeyword, + setPage, + setPasswordForm, + setStatus, + status, + submitCreate, + submitPassword, + targetUser, + toggleStatus, + }; +} diff --git a/src/features/external-admin-users/hooks/useExternalAdminUsersPage.test.jsx b/src/features/external-admin-users/hooks/useExternalAdminUsersPage.test.jsx new file mode 100644 index 0000000..1e25d6e --- /dev/null +++ b/src/features/external-admin-users/hooks/useExternalAdminUsersPage.test.jsx @@ -0,0 +1,191 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + abilities: { canCreate: true, canResetPassword: true, canStatus: true, canView: true }, + appCode: "fami", + confirm: vi.fn(), + createExternalAdminUser: vi.fn(), + invalidateQueries: vi.fn(), + listExternalAdminUsers: vi.fn(), + lookupExternalAdminUserTarget: vi.fn(), + queryOptions: null, + queryPage: { items: [], page: 1, pageSize: 50, total: 0 }, + reload: vi.fn(), + resetExternalAdminUserPassword: vi.fn(), + showToast: vi.fn(), + updateExternalAdminUserStatus: vi.fn(), +})); + +vi.mock("@tanstack/react-query", () => ({ + useQueryClient: () => ({ invalidateQueries: mocks.invalidateQueries }), +})); + +vi.mock("@/app/app-scope/AppScopeProvider.jsx", () => ({ + useAppScope: () => ({ appCode: mocks.appCode }), +})); + +vi.mock("@/features/external-admin-users/api", () => ({ + createExternalAdminUser: mocks.createExternalAdminUser, + listExternalAdminUsers: mocks.listExternalAdminUsers, + lookupExternalAdminUserTarget: mocks.lookupExternalAdminUserTarget, + resetExternalAdminUserPassword: mocks.resetExternalAdminUserPassword, + updateExternalAdminUserStatus: mocks.updateExternalAdminUserStatus, +})); + +vi.mock("@/features/external-admin-users/permissions.js", () => ({ + useExternalAdminUserAbilities: () => mocks.abilities, +})); + +vi.mock("@/shared/hooks/useAdminQuery.js", () => ({ + useAdminQuery: (_queryFn, options) => { + mocks.queryOptions = options; + return { data: mocks.queryPage, error: "", loading: false, reload: mocks.reload }; + }, +})); + +vi.mock("@/shared/ui/ConfirmProvider.jsx", () => ({ + useConfirm: () => mocks.confirm, +})); + +vi.mock("@/shared/ui/ToastProvider.jsx", () => ({ + useToast: () => ({ showToast: mocks.showToast }), +})); + +import { useExternalAdminUsersPage } from "./useExternalAdminUsersPage.js"; + +beforeEach(() => { + mocks.appCode = "fami"; + mocks.queryPage = { items: [], page: 1, pageSize: 50, total: 0 }; + mocks.confirm.mockResolvedValue(true); + mocks.createExternalAdminUser.mockResolvedValue(externalUserFixture()); + mocks.invalidateQueries.mockResolvedValue(undefined); + mocks.lookupExternalAdminUserTarget.mockResolvedValue(targetFixture()); + mocks.reload.mockResolvedValue(undefined); + mocks.resetExternalAdminUserPassword.mockResolvedValue(externalUserFixture()); + mocks.updateExternalAdminUserStatus.mockResolvedValue({ ...externalUserFixture(), status: "disabled" }); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +test("creates an App-scoped account with the canonical userId returned by lookup", async () => { + const { result } = renderHook(() => useExternalAdminUsersPage()); + + act(() => result.current.openCreate()); + act(() => result.current.setDisplayUserId("VIP-1001")); + await act(async () => result.current.lookupTarget()); + act(() => + result.current.setCreateForm({ + confirmPassword: "StrongPass123!", + password: "StrongPass123!", + username: "ops.fami", + }), + ); + await act(async () => result.current.submitCreate({ preventDefault: vi.fn() })); + + expect(mocks.lookupExternalAdminUserTarget).toHaveBeenCalledWith("fami", "VIP-1001"); + expect(mocks.createExternalAdminUser).toHaveBeenCalledWith("fami", { + password: "StrongPass123!", + targetUserId: "internal-user-1001", + username: "ops.fami", + }); + expect(mocks.invalidateQueries).toHaveBeenCalledWith({ queryKey: ["external-admin-users", "fami"] }); + expect(result.current.createOpen).toBe(false); +}); + +test("clears list filters, lookup results and password form when App scope changes", async () => { + const { result, rerender } = renderHook(() => useExternalAdminUsersPage()); + + act(() => result.current.setKeyword("old account")); + act(() => result.current.setStatus("active")); + act(() => result.current.openCreate()); + act(() => result.current.setDisplayUserId("VIP-1001")); + await act(async () => result.current.lookupTarget()); + act(() => result.current.openPassword(externalUserFixture())); + act(() => result.current.setPasswordForm({ confirmPassword: "OldPass123!", password: "OldPass123!" })); + + mocks.appCode = "lalu"; + rerender(); + + await waitFor(() => expect(result.current.keyword).toBe("")); + expect(result.current.status).toBe(""); + expect(result.current.createOpen).toBe(false); + expect(result.current.createForm).toEqual({ confirmPassword: "", displayUserId: "", password: "", username: "" }); + expect(result.current.targetUser).toBeNull(); + expect(result.current.passwordUser).toBeNull(); + expect(result.current.passwordForm).toEqual({ confirmPassword: "", password: "" }); + expect(mocks.queryOptions.queryKey[1]).toBe("lalu"); +}); + +test("ignores a late user lookup response after switching App", async () => { + let resolveLookup; + mocks.lookupExternalAdminUserTarget.mockImplementationOnce( + () => new Promise((resolve) => { + resolveLookup = resolve; + }), + ); + const { result, rerender } = renderHook(() => useExternalAdminUsersPage()); + + act(() => result.current.openCreate()); + act(() => result.current.setDisplayUserId("VIP-1001")); + let lookupPromise; + act(() => { + lookupPromise = result.current.lookupTarget(); + }); + mocks.appCode = "lalu"; + rerender(); + await waitFor(() => expect(result.current.createOpen).toBe(false)); + await act(async () => { + resolveLookup(targetFixture()); + await lookupPromise; + }); + + expect(result.current.targetUser).toBeNull(); +}); + +test("refreshes the App-scoped list after status and password changes", async () => { + const item = externalUserFixture(); + const { result } = renderHook(() => useExternalAdminUsersPage()); + + await act(async () => result.current.toggleStatus(item)); + expect(mocks.updateExternalAdminUserStatus).toHaveBeenCalledWith("fami", 71, "disabled"); + + act(() => result.current.openPassword(item)); + act(() => result.current.setPasswordForm({ confirmPassword: "NextPass123!", password: "NextPass123!" })); + await act(async () => result.current.submitPassword({ preventDefault: vi.fn() })); + + expect(mocks.resetExternalAdminUserPassword).toHaveBeenCalledWith("fami", 71, "NextPass123!"); + expect(mocks.invalidateQueries).toHaveBeenCalledTimes(2); + expect(mocks.invalidateQueries).toHaveBeenNthCalledWith(1, { queryKey: ["external-admin-users", "fami"] }); + expect(mocks.invalidateQueries).toHaveBeenNthCalledWith(2, { queryKey: ["external-admin-users", "fami"] }); +}); + +function targetFixture() { + return { + avatar: "https://cdn.example.com/user.png", + defaultDisplayUserId: "1001", + displayUserId: "VIP-1001", + prettyDisplayUserId: "VIP-1001", + prettyId: "pretty-1", + status: "active", + userId: "internal-user-1001", + username: "Fami user", + }; +} + +function externalUserFixture() { + return { + appCode: "fami", + createdAtMs: 1784073600000, + createdByAdminId: 9, + id: 71, + lastLoginAtMs: 1784077200000, + linkedUser: targetFixture(), + permissions: [], + status: "active", + updatedAtMs: 1784077200000, + username: "ops.fami", + }; +} diff --git a/src/features/external-admin-users/pages/ExternalAdminUsersPage.jsx b/src/features/external-admin-users/pages/ExternalAdminUsersPage.jsx new file mode 100644 index 0000000..a6db87d --- /dev/null +++ b/src/features/external-admin-users/pages/ExternalAdminUsersPage.jsx @@ -0,0 +1,217 @@ +import PasswordOutlined from "@mui/icons-material/PasswordOutlined"; +import PersonAddAltOutlined from "@mui/icons-material/PersonAddAltOutlined"; +import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined"; +import { ExternalAdminUserFormDialog } from "@/features/external-admin-users/components/ExternalAdminUserFormDialog.jsx"; +import { ExternalAdminUserPasswordDialog } from "@/features/external-admin-users/components/ExternalAdminUserPasswordDialog.jsx"; +import styles from "@/features/external-admin-users/external-admin-users.module.css"; +import { useExternalAdminUsersPage } from "@/features/external-admin-users/hooks/useExternalAdminUsersPage.js"; +import { + AdminActionIconButton, + AdminFilterResetButton, + AdminFilterSelect, + AdminListBody, + AdminListPage, + AdminListToolbar, + AdminRowActions, + AdminSearchBox, +} from "@/shared/ui/AdminListLayout.jsx"; +import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; +import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx"; +import { DataState } from "@/shared/ui/DataState.jsx"; +import { DataTable } from "@/shared/ui/DataTable.jsx"; +import { formatMillis } from "@/shared/utils/time.js"; + +const statusOptions = [ + ["", "全部状态"], + ["active", "启用"], + ["disabled", "停用"], +]; + +const columns = [ + { + key: "linkedUser", + label: "绑定 App 用户", + render: (item) => ( + + ), + width: "minmax(250px, 1.2fr)", + }, + { + key: "username", + label: "外管账号", + render: (item) => ( +
+ {item.username || "-"} + ID {item.id || "-"} +
+ ), + width: "minmax(190px, 0.95fr)", + }, + { + key: "status", + label: "状态", + render: (item, _index, context) => { + const page = context?.page; + // 外管状态和密码都是账号级敏感写操作;同页串行化可避免两个请求互相覆盖全局 loading 状态。 + const accountActionInFlight = Boolean(page?.loadingAction); + return ( + page.toggleStatus(item)} + /> + ); + }, + width: "minmax(120px, 0.65fr)", + }, + { + key: "permissions", + label: "外管权限", + render: (item) => (item.permissions?.length ? `${item.permissions.length} 项` : "-"), + width: "minmax(120px, 0.65fr)", + }, + { + key: "createdByAdminId", + label: "创建人", + render: (item) => item.createdByAdminId || "-", + width: "minmax(130px, 0.7fr)", + }, + { + key: "lastLoginAtMs", + label: "最近登录", + render: (item) => formatMillis(item.lastLoginAtMs), + width: "minmax(170px, 0.85fr)", + }, + { + key: "createdAtMs", + label: "创建时间", + render: (item) => formatMillis(item.createdAtMs), + width: "minmax(170px, 0.85fr)", + }, + { + key: "actions", + label: "操作", + render: (item, _index, context) => { + const page = context?.page; + return ( + + page.openPassword(item)} + > + + + + ); + }, + resizable: false, + width: "88px", + }, +]; + +export function ExternalAdminUsersPage() { + const page = useExternalAdminUsersPage(); + const items = page.data.items || []; + const total = Number(page.data.total || 0); + + return ( + + + + + + {page.abilities.canCreate ? ( + + + + ) : null} + + } + filters={ + <> + + + + + } + /> + + + 0 + ? { + onPageChange: page.setPage, + page: page.page, + pageSize: page.data.pageSize || page.pageSize, + total, + } + : undefined + } + rowKey={(item) => item.id || `${item.appCode}-${item.username}`} + /> + + + + + + ); +} diff --git a/src/features/external-admin-users/permissions.js b/src/features/external-admin-users/permissions.js new file mode 100644 index 0000000..0d01521 --- /dev/null +++ b/src/features/external-admin-users/permissions.js @@ -0,0 +1,13 @@ +import { useAuth } from "@/app/auth/AuthProvider.jsx"; +import { PERMISSIONS } from "@/app/permissions"; + +export function useExternalAdminUserAbilities() { + const { can } = useAuth(); + + return { + canCreate: can(PERMISSIONS.externalAdminUserCreate), + canResetPassword: can(PERMISSIONS.externalAdminUserResetPassword), + canStatus: can(PERMISSIONS.externalAdminUserStatus), + canView: can(PERMISSIONS.externalAdminUserView), + }; +} diff --git a/src/features/external-admin-users/routes.js b/src/features/external-admin-users/routes.js new file mode 100644 index 0000000..f6adec2 --- /dev/null +++ b/src/features/external-admin-users/routes.js @@ -0,0 +1,13 @@ +import { MENU_CODES, PERMISSIONS } from "@/app/permissions"; + +export const externalAdminUserRoutes = [ + { + label: "外管用户", + loader: () => + import("./pages/ExternalAdminUsersPage.jsx").then((module) => module.ExternalAdminUsersPage), + menuCode: MENU_CODES.operationExternalAdminUsers, + pageKey: "operation-external-admin-users", + path: "/operations/external-admin-users", + permission: PERMISSIONS.externalAdminUserView, + }, +]; diff --git a/src/features/external-admin-users/schema.test.ts b/src/features/external-admin-users/schema.test.ts new file mode 100644 index 0000000..ca1e605 --- /dev/null +++ b/src/features/external-admin-users/schema.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "vitest"; +import { + externalAdminUserCreateFormSchema, + externalAdminUserLookupSchema, + externalAdminUserPasswordFormSchema, +} from "./schema"; + +describe("external admin user schemas", () => { + test("accepts Unicode pretty IDs and trims login identifiers", () => { + expect(externalAdminUserLookupSchema.parse({ displayUserId: " 靓号-一号 " })).toEqual({ + displayUserId: "靓号-一号", + }); + + expect( + externalAdminUserCreateFormSchema.parse({ + confirmPassword: " StrongPass123! ", + displayUserId: " VIP-1001 ", + password: " StrongPass123! ", + username: " ops.fami ", + }), + ).toEqual({ + displayUserId: "VIP-1001", + password: " StrongPass123! ", + username: "ops.fami", + }); + }); + + test("keeps password bytes unchanged and rejects mismatched confirmation", () => { + expect( + externalAdminUserPasswordFormSchema.parse({ + confirmPassword: " password-with-spaces ", + password: " password-with-spaces ", + }), + ).toEqual({ password: " password-with-spaces " }); + + expect(() => + externalAdminUserPasswordFormSchema.parse({ + confirmPassword: "DifferentPass123!", + password: "StrongPass123!", + }), + ).toThrow("两次输入的密码不一致"); + }); + + test("rejects all-whitespace credentials that cannot complete first-login rotation", () => { + const result = externalAdminUserCreateFormSchema.safeParse({ + confirmPassword: " ", + displayUserId: "163000", + password: " ", + username: "fami-operator", + }); + + expect(result.success).toBe(false); + }); + + test("enforces the login account character set from the API contract", () => { + const result = externalAdminUserCreateFormSchema.safeParse({ + confirmPassword: "StrongPass123!", + displayUserId: "1001", + password: "StrongPass123!", + username: "运营账号", + }); + + expect(result.success).toBe(false); + }); +}); diff --git a/src/features/external-admin-users/schema.ts b/src/features/external-admin-users/schema.ts new file mode 100644 index 0000000..ca85633 --- /dev/null +++ b/src/features/external-admin-users/schema.ts @@ -0,0 +1,63 @@ +import { z } from "zod"; + +const displayUserIdSchema = z + .string() + .trim() + .min(1, "请输入用户短 ID / 靓号") + .refine((value) => Array.from(value).length <= 64, "用户短 ID / 靓号不能超过 64 个字符") + // 靓号可能包含 Unicode 字形,不能收窄为纯数字;这里只拦截会破坏查询协议和日志的空白、控制字符。 + .refine((value) => !/[\s\p{Cc}]/u.test(value), "用户短 ID / 靓号不能包含空白或控制字符"); + +const usernameSchema = z + .string() + .trim() + .min(3, "外管账号至少 3 位") + .max(64, "外管账号不能超过 64 个字符") + // 外管系统使用独立登录标识;限制为跨浏览器、网关和审计日志均稳定的 ASCII 账号字符集。 + .regex(/^[A-Za-z0-9._-]+$/, "外管账号只能包含字母、数字、点、下划线和连字符"); + +// 密码属于登录凭证,不能 trim 或自动改写;前端只做长度与确认值校验,服务端会重复校验并仅持久化密码摘要。 +const passwordSchema = z + .string() + .min(8, "密码至少 8 位") + .max(72, "密码不能超过 72 个字符") + .refine((value) => value.trim().length > 0, "密码不能全部为空白字符"); + +export const externalAdminUserLookupSchema = z.object({ + displayUserId: displayUserIdSchema, +}); + +export const externalAdminUserCreateFormSchema = z + .object({ + confirmPassword: passwordSchema, + displayUserId: displayUserIdSchema, + password: passwordSchema, + username: usernameSchema, + }) + .superRefine((value, context) => { + if (value.password !== value.confirmPassword) { + context.addIssue({ code: "custom", message: "两次输入的密码不一致", path: ["confirmPassword"] }); + } + }) + .transform(({ confirmPassword: _confirmPassword, ...payload }) => payload); + +export const externalAdminUserPasswordFormSchema = z + .object({ + confirmPassword: passwordSchema, + password: passwordSchema, + }) + .superRefine((value, context) => { + if (value.password !== value.confirmPassword) { + context.addIssue({ code: "custom", message: "两次输入的密码不一致", path: ["confirmPassword"] }); + } + }) + .transform(({ confirmPassword: _confirmPassword, ...payload }) => payload); + +export const externalAdminUserStatusSchema = z.object({ + status: z.enum(["active", "disabled"]), +}); + +export type ExternalAdminUserCreateForm = z.input; +export type ExternalAdminUserCreatePayload = z.output; +export type ExternalAdminUserPasswordForm = z.input; +export type ExternalAdminUserPasswordPayload = z.output; diff --git a/src/features/roles/hooks/useRolesPage.js b/src/features/roles/hooks/useRolesPage.js index 9fc6878..6c0d0f0 100644 --- a/src/features/roles/hooks/useRolesPage.js +++ b/src/features/roles/hooks/useRolesPage.js @@ -32,7 +32,14 @@ const permissionGroupDefinitions = [ }, { key: "operations", - prefixes: ["coin-ledger", "coin-seller-ledger", "coin-adjustment", "report", "gift-diamond"], + prefixes: [ + "coin-ledger", + "coin-seller-ledger", + "coin-adjustment", + "external-admin-user", + "report", + "gift-diamond", + ], title: "运营管理", }, { key: "payment", prefixes: ["payment-bill", "payment-product", "payment-third-party"], title: "支付管理" }, diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index bf7e8e2..4ca64a5 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -52,6 +52,7 @@ export const API_OPERATIONS = { createCountry: "createCountry", createEmojiPack: "createEmojiPack", createExploreTab: "createExploreTab", + createExternalAdminUser: "createExternalAdminUser", createFinanceCoinSellerRechargeOrder: "createFinanceCoinSellerRechargeOrder", createFullServerNoticeFanout: "createFullServerNoticeFanout", createGift: "createGift", @@ -210,6 +211,7 @@ export const API_OPERATIONS = { listEmojiPackCategories: "listEmojiPackCategories", listEmojiPacks: "listEmojiPacks", listExploreTabs: "listExploreTabs", + listExternalAdminUsers: "listExternalAdminUsers", listFinanceCoinSellerRechargeOrders: "listFinanceCoinSellerRechargeOrders", listFinanceScopeAssignments: "listFinanceScopeAssignments", listFinanceWithdrawalApplications: "listFinanceWithdrawalApplications", @@ -281,6 +283,7 @@ export const API_OPERATIONS = { login: "login", logout: "logout", lookupCoinAdjustmentTarget: "lookupCoinAdjustmentTarget", + lookupExternalAdminUserTarget: "lookupExternalAdminUserTarget", lookupResourceGrantTarget: "lookupResourceGrantTarget", me: "me", navigationMenus: "navigationMenus", @@ -301,6 +304,7 @@ export const API_OPERATIONS = { replaceRolePermissions: "replaceRolePermissions", replaceUserAppScopes: "replaceUserAppScopes", replaceUserFinanceScopes: "replaceUserFinanceScopes", + resetExternalAdminUserPassword: "resetExternalAdminUserPassword", resetUserPassword: "resetUserPassword", retryActivityTemplateRewardJob: "retryActivityTemplateRewardJob", retryActivityTemplateTaskClaim: "retryActivityTemplateTaskClaim", @@ -342,6 +346,7 @@ export const API_OPERATIONS = { updateCumulativeRechargeRewardConfig: "updateCumulativeRechargeRewardConfig", updateDiceConfig: "updateDiceConfig", updateExploreTab: "updateExploreTab", + updateExternalAdminUserStatus: "updateExternalAdminUserStatus", updateFirstRechargeRewardConfig: "updateFirstRechargeRewardConfig", updateGift: "updateGift", updateGiftType: "updateGiftType", @@ -674,6 +679,13 @@ export const API_ENDPOINTS: Record = { permission: "app-config:update", permissions: ["app-config:update"] }, + createExternalAdminUser: { + method: "POST", + operationId: API_OPERATIONS.createExternalAdminUser, + path: "/v1/admin/external-admin-users", + permission: "external-admin-user:create", + permissions: ["external-admin-user:create"] + }, createFinanceCoinSellerRechargeOrder: { method: "POST", operationId: API_OPERATIONS.createFinanceCoinSellerRechargeOrder, @@ -1778,6 +1790,13 @@ export const API_ENDPOINTS: Record = { permission: "app-config:view", permissions: ["app-config:view"] }, + listExternalAdminUsers: { + method: "GET", + operationId: API_OPERATIONS.listExternalAdminUsers, + path: "/v1/admin/external-admin-users", + permission: "external-admin-user:view", + permissions: ["external-admin-user:view"] + }, listFinanceCoinSellerRechargeOrders: { method: "GET", operationId: API_OPERATIONS.listFinanceCoinSellerRechargeOrders, @@ -2269,6 +2288,13 @@ export const API_ENDPOINTS: Record = { permission: "coin-adjustment:create", permissions: ["coin-adjustment:create"] }, + lookupExternalAdminUserTarget: { + method: "GET", + operationId: API_OPERATIONS.lookupExternalAdminUserTarget, + path: "/v1/admin/external-admin-users/target", + permission: "external-admin-user:create", + permissions: ["external-admin-user:create"] + }, lookupResourceGrantTarget: { method: "GET", operationId: API_OPERATIONS.lookupResourceGrantTarget, @@ -2403,6 +2429,13 @@ export const API_ENDPOINTS: Record = { permission: "user:update", permissions: ["user:update"] }, + resetExternalAdminUserPassword: { + method: "POST", + operationId: API_OPERATIONS.resetExternalAdminUserPassword, + path: "/v1/admin/external-admin-users/{id}/password", + permission: "external-admin-user:reset-password", + permissions: ["external-admin-user:reset-password"] + }, resetUserPassword: { method: "POST", operationId: API_OPERATIONS.resetUserPassword, @@ -2688,6 +2721,13 @@ export const API_ENDPOINTS: Record = { permission: "app-config:update", permissions: ["app-config:update"] }, + updateExternalAdminUserStatus: { + method: "PATCH", + operationId: API_OPERATIONS.updateExternalAdminUserStatus, + path: "/v1/admin/external-admin-users/{id}/status", + permission: "external-admin-user:status", + permissions: ["external-admin-user:status"] + }, updateFirstRechargeRewardConfig: { method: "PUT", operationId: API_OPERATIONS.updateFirstRechargeRewardConfig, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 0dadb3f..e3698b9 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -3596,6 +3596,70 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/external-admin-users": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listExternalAdminUsers"]; + put?: never; + post: operations["createExternalAdminUser"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/external-admin-users/target": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["lookupExternalAdminUserTarget"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/external-admin-users/{id}/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch: operations["updateExternalAdminUserStatus"]; + trace?: never; + }; + "/admin/external-admin-users/{id}/password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["resetExternalAdminUserPassword"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/exports/app-users": { parameters: { query?: never; @@ -4723,6 +4787,64 @@ export interface components { ApiResponseAppUserAccessToken: components["schemas"]["Envelope"] & { data?: components["schemas"]["AppUserAccessToken"]; }; + ApiResponseExternalAdminUserPage: components["schemas"]["Envelope"] & { + data?: components["schemas"]["ApiPageExternalAdminUser"]; + }; + ApiResponseExternalAdminUser: components["schemas"]["Envelope"] & { + data?: components["schemas"]["ExternalAdminUser"]; + }; + ApiResponseExternalAdminUserTarget: components["schemas"]["Envelope"] & { + data?: components["schemas"]["ExternalAdminUserTargetLookup"]; + }; + ApiPageExternalAdminUser: { + items: components["schemas"]["ExternalAdminUser"][]; + page: number; + pageSize: number; + /** Format: int64 */ + total: number; + }; + ExternalAdminUserTarget: { + userId: string; + displayUserId: string; + defaultDisplayUserId?: string; + prettyDisplayUserId?: string; + prettyId?: string; + username?: string; + avatar?: string; + status: string; + }; + ExternalAdminUserTargetLookup: { + linkedUser: components["schemas"]["ExternalAdminUserTarget"]; + existingExternalAdminUser?: components["schemas"]["ExternalAdminUser"]; + }; + ExternalAdminUser: { + id: number; + appCode: string; + username: string; + /** @enum {string} */ + status: "active" | "disabled"; + linkedUser: components["schemas"]["ExternalAdminUserTarget"]; + permissions: string[]; + createdByAdminId?: number; + /** Format: int64 */ + lastLoginAtMs?: number | null; + /** Format: int64 */ + createdAtMs: number; + /** Format: int64 */ + updatedAtMs?: number; + }; + ExternalAdminUserCreateInput: { + targetUserId: string; + username: string; + password: string; + }; + ExternalAdminUserStatusInput: { + /** @enum {string} */ + status: "active" | "disabled"; + }; + ExternalAdminUserPasswordInput: { + password: string; + }; ApiPageAppUser: { items: components["schemas"]["AppUser"][]; page: number; @@ -6882,6 +7004,33 @@ export interface components { }; }; /** @description OK */ + ExternalAdminUserPageResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponseExternalAdminUserPage"]; + }; + }; + /** @description OK */ + ExternalAdminUserResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponseExternalAdminUser"]; + }; + }; + /** @description OK */ + ExternalAdminUserTargetResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponseExternalAdminUserTarget"]; + }; + }; + /** @description OK */ AgencyPageResponse: { headers: { [name: string]: unknown; @@ -12028,6 +12177,99 @@ export interface operations { 200: components["responses"]["AppUserLoginLogPageResponse"]; }; }; + listExternalAdminUsers: { + parameters: { + query?: { + page?: components["parameters"]["Page"]; + page_size?: components["parameters"]["PageSize"]; + keyword?: components["parameters"]["Keyword"]; + status?: components["parameters"]["Status"]; + }; + header: { + "X-App-Code": components["parameters"]["AppCodeHeader"]; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["ExternalAdminUserPageResponse"]; + }; + }; + createExternalAdminUser: { + parameters: { + query?: never; + header: { + "X-App-Code": components["parameters"]["AppCodeHeader"]; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ExternalAdminUserCreateInput"]; + }; + }; + responses: { + 201: components["responses"]["ExternalAdminUserResponse"]; + }; + }; + lookupExternalAdminUserTarget: { + parameters: { + query: { + display_user_id: string; + }; + header: { + "X-App-Code": components["parameters"]["AppCodeHeader"]; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["ExternalAdminUserTargetResponse"]; + }; + }; + updateExternalAdminUserStatus: { + parameters: { + query?: never; + header: { + "X-App-Code": components["parameters"]["AppCodeHeader"]; + }; + path: { + id: components["parameters"]["Id"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ExternalAdminUserStatusInput"]; + }; + }; + responses: { + 200: components["responses"]["ExternalAdminUserResponse"]; + }; + }; + resetExternalAdminUserPassword: { + parameters: { + query?: never; + header: { + "X-App-Code": components["parameters"]["AppCodeHeader"]; + }; + path: { + id: components["parameters"]["Id"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ExternalAdminUserPasswordInput"]; + }; + }; + responses: { + 200: components["responses"]["ExternalAdminUserResponse"]; + }; + }; appExportUsers: { parameters: { query?: { diff --git a/vite.config.js b/vite.config.js index 77e7ea9..bc6aeb6 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,7 +1,7 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; -function redirectSubsystemEntry(req, res, next) { +export function redirectSubsystemEntry(req, res, next) { const rawUrl = req.url || ""; const [pathname, query = ""] = rawUrl.split("?"); @@ -19,7 +19,7 @@ function redirectSubsystemEntry(req, res, next) { return; } - if (pathname === "/databi" || pathname === "/finance") { + if (pathname === "/databi" || pathname === "/finance" || pathname === "/external-admin") { res.statusCode = 301; res.setHeader("Location", `${pathname}/${query ? `?${query}` : ""}`); res.end(); @@ -33,6 +33,16 @@ function redirectSubsystemEntry(req, res, next) { return; } + if ( + pathname.startsWith("/external-admin/") && + pathname !== "/external-admin/" && + !pathname.startsWith("/external-admin/src/") && + !pathname.split("/").at(-1).includes(".") + ) { + // Vite's generic SPA fallback points unknown paths at the main admin entry; keep external-admin deep links inside its isolated document. + req.url = `/external-admin/index.html${query ? `?${query}` : ""}`; + } + next(); } @@ -68,6 +78,7 @@ export default defineConfig({ databi: new URL("./databi/index.html", import.meta.url).pathname, databiSocial: new URL("./databi/social/index.html", import.meta.url).pathname, finance: new URL("./finance/index.html", import.meta.url).pathname, + externalAdmin: new URL("./external-admin/index.html", import.meta.url).pathname, moneyRedirect: new URL("./money/index.html", import.meta.url).pathname, opsCenter: new URL("./ops-center/index.html", import.meta.url).pathname } From d302d8a3606155065c69992ce4d5007208d5004d Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 15 Jul 2026 16:51:18 +0800 Subject: [PATCH 02/15] feat(external-admin): add account login and i18n --- external-admin/index.html | 4 +- external-admin/src/api/auth.js | 18 - external-admin/src/api/auth.test.js | 13 +- external-admin/src/api/business.js | 45 +- external-admin/src/api/business.test.js | 4 +- external-admin/src/api/client.js | 4 +- .../src/auth/ExternalAuthProvider.jsx | 7 +- external-admin/src/auth/RouteGuards.jsx | 14 +- .../src/components/BannerCreateDialog.jsx | 73 ++-- .../components/BannerCreateDialog.test.jsx | 10 +- .../src/components/GrantDialogs.jsx | 33 +- .../src/components/RoomEditDialog.jsx | 14 +- external-admin/src/components/UserDialogs.jsx | 35 +- external-admin/src/config/capabilities.js | 40 +- .../src/i18n/ExternalI18nProvider.jsx | 69 +++ .../src/i18n/ExternalI18nProvider.test.jsx | 60 +++ external-admin/src/i18n/LanguageSwitcher.jsx | 26 ++ external-admin/src/i18n/emotionCache.js | 10 + external-admin/src/i18n/emotionCache.test.js | 24 ++ external-admin/src/i18n/errors.js | 51 +++ external-admin/src/i18n/messages.js | 404 ++++++++++++++++++ external-admin/src/i18n/messages.test.js | 27 ++ .../src/layout/ExternalAdminLayout.jsx | 64 +-- .../src/layout/ExternalAdminLayout.test.jsx | 72 ++++ external-admin/src/main.jsx | 44 +- external-admin/src/pages/BannersPage.jsx | 21 +- external-admin/src/pages/BansPage.jsx | 27 +- .../src/pages/ChangePasswordPage.jsx | 23 +- .../src/pages/ChangePasswordPage.test.jsx | 6 +- external-admin/src/pages/GrantsPage.jsx | 37 +- external-admin/src/pages/LoginPage.jsx | 72 +--- external-admin/src/pages/LoginPage.test.jsx | 49 +++ external-admin/src/pages/OrganizationPage.jsx | 48 ++- external-admin/src/pages/OverviewPage.jsx | 14 +- .../src/pages/OverviewPage.test.jsx | 10 +- external-admin/src/pages/RoomsPage.jsx | 25 +- external-admin/src/pages/UsersPage.jsx | 42 +- external-admin/src/shared/ConfirmDialog.jsx | 4 +- .../src/shared/ExternalImageUploadField.jsx | 17 +- external-admin/src/shared/PageComponents.jsx | 26 +- external-admin/src/shared/usePagedResource.js | 7 +- external-admin/src/styles/index.css | 5 + package.json | 3 + pnpm-lock.yaml | 32 ++ src/shared/ui/AppJumpConfigField.jsx | 23 +- src/shared/ui/TimeRangeFilter.jsx | 137 ++++-- 46 files changed, 1395 insertions(+), 398 deletions(-) create mode 100644 external-admin/src/i18n/ExternalI18nProvider.jsx create mode 100644 external-admin/src/i18n/ExternalI18nProvider.test.jsx create mode 100644 external-admin/src/i18n/LanguageSwitcher.jsx create mode 100644 external-admin/src/i18n/emotionCache.js create mode 100644 external-admin/src/i18n/emotionCache.test.js create mode 100644 external-admin/src/i18n/errors.js create mode 100644 external-admin/src/i18n/messages.js create mode 100644 external-admin/src/i18n/messages.test.js create mode 100644 external-admin/src/layout/ExternalAdminLayout.test.jsx create mode 100644 external-admin/src/pages/LoginPage.test.jsx diff --git a/external-admin/index.html b/external-admin/index.html index c7759b6..60bbbf8 100644 --- a/external-admin/index.html +++ b/external-admin/index.html @@ -1,10 +1,10 @@ - + - HYApp 外管 + HYApp External Admin
diff --git a/external-admin/src/api/auth.js b/external-admin/src/api/auth.js index 596ac33..9371100 100644 --- a/external-admin/src/api/auth.js +++ b/external-admin/src/api/auth.js @@ -3,26 +3,16 @@ import { arrayValue, asRecord, textValue } from "./normalizers.js"; // These external-session auth routes intentionally have no main-admin OpenAPI operation. const EXTERNAL_AUTH_PATHS = Object.freeze({ - apps: "/auth/apps", changePassword: "/auth/change-password", login: "/auth/login", logout: "/auth/logout", me: "/auth/me" }); -export async function listExternalApps() { - const data = await externalRequest(EXTERNAL_AUTH_PATHS.apps, { notifyUnauthorized: false }); - const source = asRecord(data); - return arrayValue(data, source.items, source.apps) - .map((item) => normalizeApp(item)) - .filter((item) => item.appCode); -} - export async function loginExternal(payload) { const data = await externalRequest(EXTERNAL_AUTH_PATHS.login, { body: { account: String(payload.account || "").trim(), - appCode: String(payload.appCode || "").trim(), password: String(payload.password || "") }, method: "POST", @@ -84,11 +74,3 @@ export function normalizeSession(data) { ) }; } - -function normalizeApp(value) { - const item = typeof value === "string" ? { appCode: value } : asRecord(value); - return { - appCode: textValue(item.appCode, item.app_code, item.code, item.value), - name: textValue(item.name, item.appName, item.app_name, item.label, item.appCode, item.code) - }; -} diff --git a/external-admin/src/api/auth.test.js b/external-admin/src/api/auth.test.js index b509f0a..1c3584e 100644 --- a/external-admin/src/api/auth.test.js +++ b/external-admin/src/api/auth.test.js @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test, vi } from "vitest"; -import { changeExternalPassword, listExternalApps, normalizeSession } from "./auth.js"; +import { changeExternalPassword, loginExternal, normalizeSession } from "./auth.js"; afterEach(() => { vi.unstubAllGlobals(); @@ -43,9 +43,14 @@ describe("normalizeSession", () => { }); describe("external auth API contract", () => { - test("reads appName from auth/apps items", async () => { - vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ code: 0, data: { items: [{ appCode: "fami", appName: "Fami" }], total: 1 } }))); - await expect(listExternalApps()).resolves.toEqual([{ appCode: "fami", name: "Fami" }]); + test("login sends only account and password so the server resolves the App", async () => { + const fetchMock = vi.fn(async () => jsonResponse({ code: 0, data: { account: "fami-manager", appCode: "fami" } })); + vi.stubGlobal("fetch", fetchMock); + + await loginExternal({ account: " fami-manager ", appCode: "must-not-be-sent", password: " password bytes " }); + + expect(fetchMock.mock.calls[0][0]).toBe("/api/v1/external/auth/login"); + expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ account: "fami-manager", password: " password bytes " }); }); test("change-password sends currentPassword and never oldPassword", async () => { diff --git a/external-admin/src/api/business.js b/external-admin/src/api/business.js index e42aaff..d3fb062 100644 --- a/external-admin/src/api/business.js +++ b/external-admin/src/api/business.js @@ -2,6 +2,7 @@ import { API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoint import { validatePublicAppJumpParam } from "@/shared/app-jump/appJumpConfig.js"; import { externalRequest } from "./client.js"; import { asRecord, booleanValue, entityId, normalizePage, numberValue, textValue } from "./normalizers.js"; +import { externalUiError } from "../i18n/errors.js"; // my-team is external-session scoped and is not part of the main-admin OpenAPI contract yet. const EXTERNAL_ONLY_PATHS = Object.freeze({ myTeamAgencies: "/admin/my-team/agencies" }); @@ -42,10 +43,10 @@ export async function grantUserVip(targetUserId, payload) { const level = Number(payload.level); if (program.status === "disabled") { - throw new Error("当前 App 的 VIP 体系已停用"); + throw externalUiError("errors.vipDisabled"); } if (Array.isArray(levels) && levels.length && !levels.some((item) => Number(item.level) === level)) { - throw new Error("请选择当前 App 已配置的 VIP 等级"); + throw externalUiError("errors.vipLevelInvalid"); } const command = { @@ -74,7 +75,7 @@ export function listOrganizations(kind, query = {}) { team: EXTERNAL_ONLY_PATHS.myTeamAgencies }; if (!routes[kind]) { - throw new Error("该组织列表尚无安全的数据接口"); + throw externalUiError("errors.organizationRoute"); } const scopedQuery = kind === "super-admins" ? { ...query, status: query.status || "active" } : query; return externalRequest(routes[kind], { query: scopedQuery }).then((data) => { @@ -104,7 +105,7 @@ export function buildRoomUpdatePayload(payload) { const regionText = String(payload.visibleRegionId ?? "").trim(); const visibleRegionId = regionText ? Number(regionText) : 0; if (!Number.isSafeInteger(visibleRegionId) || visibleRegionId < 0) { - throw new Error("可见区域 ID 必须是安全的正整数"); + throw externalUiError("errors.regionId"); } return { coverUrl: String(payload.coverUrl || "").trim(), @@ -151,53 +152,53 @@ export function createBanner(payload) { export function buildBannerPayload(payload) { if (!["h5", "app"].includes(payload.bannerType)) { - throw new Error("Banner 类型不正确"); + throw externalUiError("errors.bannerType"); } if (!["android", "ios"].includes(payload.platform)) { - throw new Error("Banner 平台不正确"); + throw externalUiError("errors.bannerPlatform"); } if (!["active", "disabled", "expired"].includes(payload.status)) { - throw new Error("Banner 状态不正确"); + throw externalUiError("errors.bannerStatus"); } const displayScopes = [...new Set((payload.displayScopes || []).filter((scope) => ["home", "room", "recharge", "me"].includes(scope)))]; if (!displayScopes.length) { - throw new Error("请至少选择一个展示范围"); + throw externalUiError("errors.bannerScope"); } if (!payload.coverUrl) { - throw new Error("请上传 Banner 图片"); + throw externalUiError("errors.bannerImage"); } if (payload.bannerType === "h5") { let target; try { target = new URL(payload.param); } catch { - throw new Error("请填写合法的 H5 链接"); + throw externalUiError("errors.h5Url"); } if (!["http:", "https:"].includes(target.protocol)) { - throw new Error("H5 链接仅支持 http 或 https"); + throw externalUiError("errors.h5Protocol"); } } if (payload.bannerType === "app") { const validation = validatePublicAppJumpParam(payload.param); if (!validation.valid) { - throw new Error(validation.message); + throw externalUiError(appJumpValidationKey(validation.message)); } } if (displayScopes.includes("room") && !payload.roomSmallImageUrl) { - throw new Error("房间范围必须上传房间内小屏图"); + throw externalUiError("errors.roomBannerImage"); } const countryCode = String(payload.countryCode || "").trim().toUpperCase(); if (countryCode && !/^[A-Z]{2,3}$/.test(countryCode)) { - throw new Error("国家码必须是 2-3 位字母"); + throw externalUiError("errors.countryCode"); } const regionId = Number(payload.regionId || 0); if (!Number.isSafeInteger(regionId) || regionId < 0) { - throw new Error("区域 ID 不正确"); + throw externalUiError("errors.bannerRegion"); } const startsAtMs = Number(payload.startsAtMs || 0); const endsAtMs = Number(payload.endsAtMs || 0); if (startsAtMs > 0 && endsAtMs > 0 && startsAtMs >= endsAtMs) { - throw new Error("投放结束时间必须晚于开始时间"); + throw externalUiError("errors.bannerSchedule"); } return { @@ -319,3 +320,15 @@ function externalOperationPath(operation, params) { // Generated main-admin paths start with /v1; the external gateway already owns that version prefix in its base URL. return apiEndpointPath(operation, params).replace(/^\/v1(?=\/)/, ""); } + +function appJumpValidationKey(message) { + return { + "APP 跳转参数必须是 JSON 对象": "errors.appParamObject", + "APP 跳转 type 不在公共跳转方案内": "errors.appParamType", + "Explore 游戏 H5 需要选择有效 game_code": "errors.appParamGameCode", + "该 APP 跳转需要填写 game_id": "errors.appParamGame", + "该 APP 跳转需要填写 room_id": "errors.appParamRoom", + "该 APP 跳转需要填写有效 window": "errors.appParamWindow", + "请填写 APP 跳转参数": "errors.appParamRequired" + }[message] || "errors.appParamObject"; +} diff --git a/external-admin/src/api/business.test.js b/external-admin/src/api/business.test.js index f7df65e..77ecece 100644 --- a/external-admin/src/api/business.test.js +++ b/external-admin/src/api/business.test.js @@ -40,7 +40,7 @@ describe("room API payload", () => { expect(buildRoomUpdatePayload({ coverUrl: " cover ", description: " desc ", title: " Room ", visibleRegionId: "18" })) .toEqual({ coverUrl: "cover", description: "desc", title: "Room", visibleRegionId: 18 }); expect(buildRoomUpdatePayload({ visibleRegionId: "" }).visibleRegionId).toBe(0); - expect(() => buildRoomUpdatePayload({ visibleRegionId: "1.5" })).toThrow("可见区域 ID"); + expect(() => buildRoomUpdatePayload({ visibleRegionId: "1.5" })).toThrow("errors.regionId"); }); }); @@ -134,7 +134,7 @@ describe("banner API payload", () => { status: "active" })); expect(() => buildBannerPayload({ bannerType: "link", coverUrl: "x", displayScopes: ["home"], platform: "all", status: "enabled" })) - .toThrow("Banner 类型不正确"); + .toThrow("errors.bannerType"); }); test("create API sends the normalized appconfig payload", async () => { diff --git a/external-admin/src/api/client.js b/external-admin/src/api/client.js index 507b791..f9de9bd 100644 --- a/external-admin/src/api/client.js +++ b/external-admin/src/api/client.js @@ -73,11 +73,11 @@ export async function externalRequest(path, options = {}) { } if (!response.ok) { - throw apiError(payload, response.status, response.statusText || "请求失败"); + throw apiError(payload, response.status, response.statusText || "Request failed"); } if (isEnvelopeFailure(payload)) { - throw apiError(payload, response.status, "请求失败"); + throw apiError(payload, response.status, "Request failed"); } const data = unwrapPayload(payload); diff --git a/external-admin/src/auth/ExternalAuthProvider.jsx b/external-admin/src/auth/ExternalAuthProvider.jsx index b7de90d..e637ccb 100644 --- a/external-admin/src/auth/ExternalAuthProvider.jsx +++ b/external-admin/src/auth/ExternalAuthProvider.jsx @@ -7,10 +7,10 @@ const ExternalAuthContext = createContext(null); export function ExternalAuthProvider({ children }) { const [session, setSession] = useState(null); const [initializing, setInitializing] = useState(true); - const [initializationError, setInitializationError] = useState(""); + const [initializationError, setInitializationError] = useState(null); const refresh = useCallback(async () => { - setInitializationError(""); + setInitializationError(null); try { const nextSession = await getExternalSession(); setSession(nextSession); @@ -18,7 +18,8 @@ export function ExternalAuthProvider({ children }) { } catch (error) { setSession(null); if (!(error instanceof ExternalApiError && error.status === 401)) { - setInitializationError(error.message || "会话校验失败"); + // Preserve the stable API status/code for the UI translation layer; never surface a backend-language message directly. + setInitializationError(error); } return null; } finally { diff --git a/external-admin/src/auth/RouteGuards.jsx b/external-admin/src/auth/RouteGuards.jsx index e47933a..d2ae5d0 100644 --- a/external-admin/src/auth/RouteGuards.jsx +++ b/external-admin/src/auth/RouteGuards.jsx @@ -5,17 +5,20 @@ import CircularProgress from "@mui/material/CircularProgress"; import Typography from "@mui/material/Typography"; import { Navigate, Outlet, useLocation } from "react-router-dom"; import { hasAnyExternalCapability } from "../config/capabilities.js"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; +import { translateExternalError } from "../i18n/errors.js"; import { useExternalAuth } from "./ExternalAuthProvider.jsx"; export function RequireExternalAuth() { const { initializationError, initializing, refresh, session } = useExternalAuth(); + const { t } = useExternalI18n(); const location = useLocation(); if (initializing) { return ( - 正在校验外管会话 + {t("auth.checkingSession")} ); } @@ -24,8 +27,8 @@ export function RequireExternalAuth() { if (initializationError) { return ( - {initializationError} - + {translateExternalError(initializationError, t, "auth.sessionFailed")} + ); } @@ -42,6 +45,7 @@ export function RequirePasswordChanged() { export function RequireExternalCapability({ anyOf }) { const { session } = useExternalAuth(); + const { t } = useExternalI18n(); if (hasAnyExternalCapability(session, anyOf)) { return ; @@ -49,9 +53,9 @@ export function RequireExternalCapability({ anyOf }) { return ( - 当前外管账号没有访问此功能的权限。 + {t("auth.noPermission")} ); diff --git a/external-admin/src/components/BannerCreateDialog.jsx b/external-admin/src/components/BannerCreateDialog.jsx index 98ee4ad..486fec1 100644 --- a/external-admin/src/components/BannerCreateDialog.jsx +++ b/external-admin/src/components/BannerCreateDialog.jsx @@ -12,15 +12,17 @@ import { useEffect, useState } from "react"; import { AppJumpConfigField } from "@/shared/ui/AppJumpConfigField.jsx"; import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx"; import { ExternalImageUploadField } from "../shared/ExternalImageUploadField.jsx"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; const displayScopeOptions = [ - ["home", "首页"], - ["room", "房间"], - ["recharge", "充值"], - ["me", "我的"] + ["home", "banners.scopeHome"], + ["room", "banners.scopeRoom"], + ["recharge", "banners.scopeRecharge"], + ["me", "banners.scopeMe"] ]; export function BannerCreateDialog({ loading, onClose, onSubmit, open }) { + const { locale, t } = useExternalI18n(); const [form, setForm] = useState(initialForm); useEffect(() => setForm(initialForm), [open]); @@ -37,57 +39,60 @@ export function BannerCreateDialog({ loading, onClose, onSubmit, open }) { return ( - 创建 Banner + {t("banners.create")} - setForm({ ...form, coverUrl })} /> - {form.displayScopes.includes("room") ? setForm({ ...form, roomSmallImageUrl })} /> : null} - setForm({ ...form, platform: event.target.value })}> + setForm({ ...form, coverUrl })} /> + {form.displayScopes.includes("room") ? setForm({ ...form, roomSmallImageUrl })} /> : null} + setForm({ ...form, platform: event.target.value })}> Android iOS - setForm({ ...form, description: event.target.value })} /> + setForm({ ...form, description: event.target.value })} /> setForm({ ...form, bannerType: type, param })} /> selected.map((value) => displayScopeOptions.find(([key]) => key === value)?.[1] || value).join("、") } }} + slotProps={{ select: { multiple: true, renderValue: (selected) => selected.map((value) => t(displayScopeOptions.find(([key]) => key === value)?.[1]) || value).join(", ") } }} value={form.displayScopes} onChange={(event) => setForm({ ...form, displayScopes: typeof event.target.value === "string" ? event.target.value.split(",") : event.target.value })} > - {displayScopeOptions.map(([value, label]) => )} + {displayScopeOptions.map(([value, labelKey]) => )} - setForm({ ...form, regionId: event.target.value })} /> - setForm({ ...form, countryCode: event.target.value.toUpperCase() })} /> - setForm({ ...form, sortOrder: event.target.value })} /> + setForm({ ...form, regionId: event.target.value })} /> + setForm({ ...form, countryCode: event.target.value.toUpperCase() })} /> + setForm({ ...form, sortOrder: event.target.value })} /> setForm({ ...form, endsAtMs: endMs, startsAtMs: startMs })} /> - setForm({ ...form, status: event.target.value })}> - 启用 - 禁用 - 过期 + setForm({ ...form, status: event.target.value })}> + {t("common.enabled")} + {t("common.disabled")} + {t("common.expired")} - + ); @@ -108,3 +113,23 @@ const initialForm = { startsAtMs: 0, status: "active" }; + +function appJumpMessages(t) { + return { + appTarget: t("jump.appTarget"), + gameId: t("jump.gameId"), + giftId: t("jump.giftId"), + hint: t("jump.hint"), + redPacketNo: t("jump.redPacketNo"), + roomId: t("jump.roomId"), + targets: Object.fromEntries(["wallet", "room", "room_random", "room_window", "room_random_window", "room_game", "room_random_game", "explore_game", "custom"].map((value) => [value, t(`jump.target.${value}`)])), + window: t("jump.window"), + windows: Object.fromEntries(["game_window", "gift_panel", "red_packet_claim", "wheel"].map((value) => [value, t(`jump.window.${value}`)])) + }; +} + +function timeRangeMessages(t) { + return Object.fromEntries([ + "apply", "clear", "completeRange", "duration", "durationDays", "durationHours", "durationMinutes", "end", "endAfterStart", "endDate", "endDateAfterStart", "endDateLaterShort", "endLaterShort", "endMark", "hour", "lessMinute", "minute", "nextMonth", "pending", "previousMonth", "quickDay", "quickHour", "quickWeek", "sameDay", "start", "startDate", "startMark", "unlimited" + ].map((key) => [key, t(`time.${key}`)])); +} diff --git a/external-admin/src/components/BannerCreateDialog.test.jsx b/external-admin/src/components/BannerCreateDialog.test.jsx index 54b4fd8..bafad3e 100644 --- a/external-admin/src/components/BannerCreateDialog.test.jsx +++ b/external-admin/src/components/BannerCreateDialog.test.jsx @@ -8,13 +8,13 @@ describe("BannerCreateDialog", () => { const user = userEvent.setup(); render(); - expect(screen.getByRole("textbox", { name: "参数(H5 链接)" })).toBeInTheDocument(); - expect(screen.getByText("投放时间")).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: "Parameter (H5 URL)" })).toBeInTheDocument(); + expect(screen.getByText("Schedule")).toBeInTheDocument(); - await user.click(screen.getByRole("combobox", { name: "跳转类型" })); + await user.click(screen.getByRole("combobox", { name: "Destination type" })); await user.click(await screen.findByRole("option", { name: "APP" })); - expect(screen.getByRole("combobox", { name: "APP 目标" })).toBeInTheDocument(); - expect(screen.getByRole("textbox", { name: "参数(APP 公共跳转 JSON)" })).toHaveValue(JSON.stringify({ type: "wallet" })); + expect(screen.getByRole("combobox", { name: "App destination" })).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: "Parameter (App navigation JSON)" })).toHaveValue(JSON.stringify({ type: "wallet" })); }); }); diff --git a/external-admin/src/components/GrantDialogs.jsx b/external-admin/src/components/GrantDialogs.jsx index ab6d34d..6fce0f1 100644 --- a/external-admin/src/components/GrantDialogs.jsx +++ b/external-admin/src/components/GrantDialogs.jsx @@ -8,49 +8,54 @@ import MenuItem from "@mui/material/MenuItem"; import Stack from "@mui/material/Stack"; import TextField from "@mui/material/TextField"; import { useEffect, useState } from "react"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; export function ResourceGrantDialog({ loading, onClose, onSubmit, open, resources }) { + const { t } = useExternalI18n(); const [form, setForm] = useState(resourceGrantInitial); useEffect(() => setForm(resourceGrantInitial), [open]); return ( - onSubmit(form)}> + onSubmit(form)}> - setForm({ ...form, target: event.target.value.trim() })} /> - setForm({ ...form, resourceId: event.target.value })}> - {resources.map((item) => {item.name}({item.id}))} + setForm({ ...form, target: event.target.value.trim() })} /> + setForm({ ...form, resourceId: event.target.value })}> + {resources.map((item) => {item.name} ({item.id}))} - setForm({ ...form, quantity: event.target.value })} /> - setForm({ ...form, durationDays: event.target.value })} /> - setForm({ ...form, reason: event.target.value })} /> + setForm({ ...form, quantity: event.target.value })} /> + setForm({ ...form, durationDays: event.target.value })} /> + setForm({ ...form, reason: event.target.value })} /> ); } export function PrettyIdGrantDialog({ loading, onClose, onSubmit, open }) { + const { t } = useExternalI18n(); const [form, setForm] = useState(prettyGrantInitial); useEffect(() => setForm(prettyGrantInitial), [open]); return ( - onSubmit(form)}> + onSubmit(form)}> - setForm({ ...form, target: event.target.value.trim() })} /> - setForm({ ...form, prettyId: event.target.value.trim() })} /> - setForm({ ...form, durationDays: event.target.value })} /> - setForm({ ...form, reason: event.target.value })} /> + setForm({ ...form, target: event.target.value.trim() })} /> + setForm({ ...form, prettyId: event.target.value.trim() })} /> + setForm({ ...form, durationDays: event.target.value })} /> + setForm({ ...form, reason: event.target.value })} /> ); } function TargetNotice() { - return 提交前会校验当前 App 内的用户,并使用服务端返回的用户 ID 执行发放。; + const { t } = useExternalI18n(); + return {t("grants.notice")}; } function GrantDialog({ children, loading, onClose, onSubmit, open, title }) { + const { t } = useExternalI18n(); return ( { event.preventDefault(); onSubmit(); }}> {title} {children} - + ); diff --git a/external-admin/src/components/RoomEditDialog.jsx b/external-admin/src/components/RoomEditDialog.jsx index da60be6..c3fe458 100644 --- a/external-admin/src/components/RoomEditDialog.jsx +++ b/external-admin/src/components/RoomEditDialog.jsx @@ -7,8 +7,10 @@ import Stack from "@mui/material/Stack"; import TextField from "@mui/material/TextField"; import { useEffect, useState } from "react"; import { ExternalImageUploadField } from "../shared/ExternalImageUploadField.jsx"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; export function RoomEditDialog({ loading, onClose, onSubmit, open, room }) { + const { t } = useExternalI18n(); const [form, setForm] = useState({ coverUrl: "", description: "", title: "", visibleRegionId: "" }); useEffect(() => { setForm({ coverUrl: room?.coverUrl || "", description: room?.description || "", title: room?.title || "", visibleRegionId: room?.visibleRegionId || "" }); @@ -16,14 +18,14 @@ export function RoomEditDialog({ loading, onClose, onSubmit, open, room }) { return ( { event.preventDefault(); onSubmit(form); }}> - 编辑房间 + {t("rooms.editTitle")} - setForm({ ...form, coverUrl })} /> - setForm({ ...form, title: event.target.value })} /> - setForm({ ...form, description: event.target.value })} /> - setForm({ ...form, visibleRegionId: event.target.value.trim() })} /> + setForm({ ...form, coverUrl })} /> + setForm({ ...form, title: event.target.value })} /> + setForm({ ...form, description: event.target.value })} /> + setForm({ ...form, visibleRegionId: event.target.value.trim() })} /> - + ); diff --git a/external-admin/src/components/UserDialogs.jsx b/external-admin/src/components/UserDialogs.jsx index a227b14..3407b57 100644 --- a/external-admin/src/components/UserDialogs.jsx +++ b/external-admin/src/components/UserDialogs.jsx @@ -8,49 +8,54 @@ import Stack from "@mui/material/Stack"; import TextField from "@mui/material/TextField"; import { useEffect, useState } from "react"; import { ExternalImageUploadField } from "../shared/ExternalImageUploadField.jsx"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; export function UserEditDialog({ loading, onClose, onSubmit, open, user }) { + const { t } = useExternalI18n(); const [form, setForm] = useState(emptyEditForm); useEffect(() => { setForm({ avatar: user?.avatar || "", country: user?.country || "", gender: user?.gender || "", username: user?.username || "" }); }, [user]); return ( - onSubmit(form)}> - setForm({ ...form, avatar })} /> - setForm({ ...form, username: event.target.value })} /> - setForm({ ...form, gender: event.target.value })}> - 未设置未知 + onSubmit(form)}> + setForm({ ...form, avatar })} /> + setForm({ ...form, username: event.target.value })} /> + setForm({ ...form, gender: event.target.value })}> + {t("users.notSet")}{t("users.genderMale")}{t("users.genderFemale")}{t("users.genderUnknown")} - setForm({ ...form, country: event.target.value.trim().toUpperCase() })} /> + setForm({ ...form, country: event.target.value.trim().toUpperCase() })} /> ); } export function UserBanDialog({ loading, onClose, onSubmit, open, user }) { + const { t } = useExternalI18n(); const [form, setForm] = useState({ days: "7", reason: "" }); useEffect(() => setForm({ days: "7", reason: "" }), [user]); return ( - onSubmit(form)}> - setForm({ ...form, days: event.target.value })} /> - setForm({ ...form, reason: event.target.value })} /> + onSubmit(form)}> + setForm({ ...form, days: event.target.value })} /> + setForm({ ...form, reason: event.target.value })} /> ); } export function UserLevelDialog({ loading, onClose, onSubmit, open, user }) { + const { t } = useExternalI18n(); const [form, setForm] = useState({ days: "30", level: "", reason: "", track: "wealth" }); useEffect(() => setForm({ days: "30", level: "", reason: "", track: "wealth" }), [user]); return ( - onSubmit(form)}> - setForm({ ...form, track: event.target.value })}>财富等级魅力等级VIP 等级 - setForm({ ...form, level: event.target.value })} /> - setForm({ ...form, days: event.target.value })} /> - setForm({ ...form, reason: event.target.value })} /> + onSubmit(form)}> + setForm({ ...form, track: event.target.value })}>{t("users.wealthLevel")}{t("users.charmLevel")}{t("users.vipLevel")} + setForm({ ...form, level: event.target.value })} /> + setForm({ ...form, days: event.target.value })} /> + setForm({ ...form, reason: event.target.value })} /> ); } function BaseDialog({ children, loading, onClose, onSubmit, open, title }) { + const { t } = useExternalI18n(); const handleSubmit = (event) => { event.preventDefault(); onSubmit(); @@ -60,7 +65,7 @@ function BaseDialog({ children, loading, onClose, onSubmit, open, title }) { {title} {children} - + ); diff --git a/external-admin/src/config/capabilities.js b/external-admin/src/config/capabilities.js index a346f9f..7d16f5a 100644 --- a/external-admin/src/config/capabilities.js +++ b/external-admin/src/config/capabilities.js @@ -1,24 +1,24 @@ export const EXTERNAL_CAPABILITIES = [ - { code: "user:list", label: "用户列表", path: "/users" }, - { code: "user:update", label: "用户信息编辑", path: "/users" }, - { code: "user-ban:list", label: "账号封禁列表", path: "/bans" }, - { code: "user:ban", label: "执行封禁", path: "/users" }, - { code: "user:unban", label: "解除封禁", path: "/bans" }, - { code: "host:list", label: "主播列表", path: "/organization/hosts" }, - { code: "agency:list", label: "公会列表", path: "/organization/agencies" }, - { code: "bd:list", label: "BD 列表", path: "/organization/bds" }, - { code: "bd-manager:list", label: "BD Manager 列表", path: "/organization/bd-leaders" }, - { code: "super-admin:list", label: "Super Admin 列表", path: "/organization/super-admins" }, - { code: "room:list", label: "房间管理", path: "/rooms" }, - { code: "room:update", label: "房间编辑", path: "/rooms" }, - { code: "privilege:list", label: "用户特权道具列表", path: "/grants" }, - { code: "privilege:grant", label: "特权道具发放", path: "/grants" }, - { code: "banner:create", label: "Banner 创建", path: "/banners" }, - { code: "pretty-id:grant", label: "靓号下发", path: "/grants" }, - { code: "user-title:grant", label: "用户称号下发", path: "/grants" }, - { code: "room-background:grant", label: "房间背景图下发", path: "/grants" }, - { code: "user-level:grant", label: "财富/VIP等级下发", path: "/users" }, - { code: "team:view", label: "我的团队", path: "/organization/team" } + { code: "user:list", labelKey: "capability.userList", path: "/users" }, + { code: "user:update", labelKey: "capability.userUpdate", path: "/users" }, + { code: "user-ban:list", labelKey: "capability.banList", path: "/bans" }, + { code: "user:ban", labelKey: "capability.ban", path: "/users" }, + { code: "user:unban", labelKey: "capability.unban", path: "/bans" }, + { code: "host:list", labelKey: "capability.hostList", path: "/organization/hosts" }, + { code: "agency:list", labelKey: "capability.agencyList", path: "/organization/agencies" }, + { code: "bd:list", labelKey: "capability.bdList", path: "/organization/bds" }, + { code: "bd-manager:list", labelKey: "capability.bdManagerList", path: "/organization/bd-leaders" }, + { code: "super-admin:list", labelKey: "capability.superAdminList", path: "/organization/super-admins" }, + { code: "room:list", labelKey: "capability.roomList", path: "/rooms" }, + { code: "room:update", labelKey: "capability.roomUpdate", path: "/rooms" }, + { code: "privilege:list", labelKey: "capability.privilegeList", path: "/grants" }, + { code: "privilege:grant", labelKey: "capability.privilegeGrant", path: "/grants" }, + { code: "banner:create", labelKey: "capability.bannerCreate", path: "/banners" }, + { code: "pretty-id:grant", labelKey: "capability.prettyIdGrant", path: "/grants" }, + { code: "user-title:grant", labelKey: "capability.userTitleGrant", path: "/grants" }, + { code: "room-background:grant", labelKey: "capability.roomBackgroundGrant", path: "/grants" }, + { code: "user-level:grant", labelKey: "capability.userLevelGrant", path: "/users" }, + { code: "team:view", labelKey: "capability.teamView", path: "/organization/team" } ]; export function hasExternalCapability(session, code) { diff --git a/external-admin/src/i18n/ExternalI18nProvider.jsx b/external-admin/src/i18n/ExternalI18nProvider.jsx new file mode 100644 index 0000000..b5fe1ee --- /dev/null +++ b/external-admin/src/i18n/ExternalI18nProvider.jsx @@ -0,0 +1,69 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { + DEFAULT_EXTERNAL_LOCALE, + EXTERNAL_LANGUAGE_OPTIONS, + EXTERNAL_LOCALE_STORAGE_KEY, + externalDirection, + externalMessage, + normalizeExternalLocale +} from "./messages.js"; + +const defaultTranslate = (key, values) => interpolate(externalMessage(DEFAULT_EXTERNAL_LOCALE, key), values); +const ExternalI18nContext = createContext({ + direction: "ltr", + languages: EXTERNAL_LANGUAGE_OPTIONS, + locale: DEFAULT_EXTERNAL_LOCALE, + setLocale: () => {}, + t: defaultTranslate +}); + +export function ExternalI18nProvider({ children }) { + // The product default is deliberately English. Browser language is ignored until the operator explicitly chooses and persists another locale. + const [locale, setLocaleState] = useState(readPersistedLocale); + const direction = externalDirection(locale); + const setLocale = useCallback((nextLocale) => { + const normalized = normalizeExternalLocale(nextLocale); + setLocaleState(normalized); + try { + globalThis.localStorage?.setItem(EXTERNAL_LOCALE_STORAGE_KEY, normalized); + } catch { + // Storage can be unavailable in hardened/private browser contexts; the in-memory locale still changes for the current session. + } + }, []); + const t = useCallback((key, values) => interpolate(externalMessage(locale, key), values), [locale]); + + useEffect(() => { + // Keep native controls, assistive technology, MUI portals and the shell aligned when Arabic changes writing direction. + const root = globalThis.document?.documentElement; + if (!root) { + return; + } + root.lang = locale; + root.dir = direction; + globalThis.document.body?.setAttribute("dir", direction); + globalThis.document.title = t("app.title"); + }, [direction, locale, t]); + + const value = useMemo( + () => ({ direction, languages: EXTERNAL_LANGUAGE_OPTIONS, locale, setLocale, t }), + [direction, locale, setLocale, t] + ); + + return {children}; +} + +export function useExternalI18n() { + return useContext(ExternalI18nContext); +} + +function readPersistedLocale() { + try { + return normalizeExternalLocale(globalThis.localStorage?.getItem(EXTERNAL_LOCALE_STORAGE_KEY)); + } catch { + return DEFAULT_EXTERNAL_LOCALE; + } +} + +function interpolate(message, values = {}) { + return String(message).replace(/\{(\w+)\}/g, (match, key) => (values[key] === undefined ? match : String(values[key]))); +} diff --git a/external-admin/src/i18n/ExternalI18nProvider.test.jsx b/external-admin/src/i18n/ExternalI18nProvider.test.jsx new file mode 100644 index 0000000..abcb047 --- /dev/null +++ b/external-admin/src/i18n/ExternalI18nProvider.test.jsx @@ -0,0 +1,60 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, test } from "vitest"; +import { ExternalI18nProvider, useExternalI18n } from "./ExternalI18nProvider.jsx"; +import { EXTERNAL_LOCALE_STORAGE_KEY } from "./messages.js"; + +describe("ExternalI18nProvider", () => { + beforeEach(() => { + localStorage.removeItem(EXTERNAL_LOCALE_STORAGE_KEY); + document.documentElement.lang = ""; + document.documentElement.dir = ""; + }); + + test("defaults to English instead of the browser locale", () => { + Object.defineProperty(navigator, "language", { configurable: true, value: "zh-CN" }); + render(); + + expect(screen.getByTestId("locale")).toHaveTextContent("en"); + expect(screen.getByTestId("title")).toHaveTextContent("HYApp External Admin"); + expect(document.documentElement).toHaveAttribute("lang", "en"); + expect(document.documentElement).toHaveAttribute("dir", "ltr"); + }); + + test("supports and persists Chinese, Arabic RTL and Turkish", async () => { + const user = userEvent.setup(); + const firstRender = render(); + + await user.click(screen.getByRole("button", { name: "ar" })); + expect(screen.getByTestId("title")).toHaveTextContent("إدارة HYApp الخارجية"); + expect(document.documentElement).toHaveAttribute("lang", "ar"); + expect(document.documentElement).toHaveAttribute("dir", "rtl"); + expect(document.body).toHaveAttribute("dir", "rtl"); + expect(localStorage.getItem(EXTERNAL_LOCALE_STORAGE_KEY)).toBe("ar"); + + await user.click(screen.getByRole("button", { name: "tr" })); + expect(screen.getByTestId("title")).toHaveTextContent("HYApp Harici Yönetim"); + expect(document.documentElement).toHaveAttribute("lang", "tr"); + expect(document.documentElement).toHaveAttribute("dir", "ltr"); + + await user.click(screen.getByRole("button", { name: "zh-CN" })); + expect(screen.getByTestId("title")).toHaveTextContent("HYApp 外管后台"); + expect(localStorage.getItem(EXTERNAL_LOCALE_STORAGE_KEY)).toBe("zh-CN"); + + firstRender.unmount(); + render(); + expect(screen.getByTestId("locale")).toHaveTextContent("zh-CN"); + expect(screen.getByTestId("title")).toHaveTextContent("HYApp 外管后台"); + }); +}); + +function LocaleProbe() { + const { locale, setLocale, t } = useExternalI18n(); + return ( +
+ {locale} + {t("app.title")} + {["zh-CN", "ar", "tr"].map((value) => )} +
+ ); +} diff --git a/external-admin/src/i18n/LanguageSwitcher.jsx b/external-admin/src/i18n/LanguageSwitcher.jsx new file mode 100644 index 0000000..873b822 --- /dev/null +++ b/external-admin/src/i18n/LanguageSwitcher.jsx @@ -0,0 +1,26 @@ +import LanguageOutlined from "@mui/icons-material/LanguageOutlined"; +import InputAdornment from "@mui/material/InputAdornment"; +import MenuItem from "@mui/material/MenuItem"; +import TextField from "@mui/material/TextField"; +import { useExternalI18n } from "./ExternalI18nProvider.jsx"; + +export function LanguageSwitcher({ compact = false }) { + const { languages, locale, setLocale, t } = useExternalI18n(); + + return ( + }, + select: { displayEmpty: true } + }} + value={locale} + onChange={(event) => setLocale(event.target.value)} + > + {languages.map((language) => {language.label})} + + ); +} diff --git a/external-admin/src/i18n/emotionCache.js b/external-admin/src/i18n/emotionCache.js new file mode 100644 index 0000000..7f86a56 --- /dev/null +++ b/external-admin/src/i18n/emotionCache.js @@ -0,0 +1,10 @@ +import createCache from "@emotion/cache"; +import rtlPlugin from "@mui/stylis-plugin-rtl"; +import { prefixer } from "stylis"; + +const externalLtrCache = createCache({ key: "external-ltr", prepend: true }); +const externalRtlCache = createCache({ key: "external-rtl", prepend: true, stylisPlugins: [prefixer, rtlPlugin] }); + +export function externalEmotionCache(direction) { + return direction === "rtl" ? externalRtlCache : externalLtrCache; +} diff --git a/external-admin/src/i18n/emotionCache.test.js b/external-admin/src/i18n/emotionCache.test.js new file mode 100644 index 0000000..62592e8 --- /dev/null +++ b/external-admin/src/i18n/emotionCache.test.js @@ -0,0 +1,24 @@ +import rtlPlugin from "@mui/stylis-plugin-rtl"; +import { compile, middleware, prefixer, serialize, stringify } from "stylis"; +import { describe, expect, test } from "vitest"; +import { externalEmotionCache } from "./emotionCache.js"; + +describe("external admin RTL Emotion cache", () => { + test("uses isolated LTR and RTL caches", () => { + expect(externalEmotionCache("ltr").key).toBe("external-ltr"); + expect(externalEmotionCache("rtl").key).toBe("external-rtl"); + expect(externalEmotionCache("rtl")).not.toBe(externalEmotionCache("ltr")); + }); + + test("mirrors physical MUI-style declarations for Arabic", () => { + const css = serialize( + compile(".control{margin-left:8px;padding-right:12px;text-align:left}"), + middleware([prefixer, rtlPlugin, stringify]) + ); + + expect(css).toContain("margin-right:8px"); + expect(css).toContain("padding-left:12px"); + expect(css).toContain("text-align:right"); + expect(css).not.toContain("margin-left:8px"); + }); +}); diff --git a/external-admin/src/i18n/errors.js b/external-admin/src/i18n/errors.js new file mode 100644 index 0000000..0880b5c --- /dev/null +++ b/external-admin/src/i18n/errors.js @@ -0,0 +1,51 @@ +const ERROR_CODE_KEYS = Object.freeze({ + account_locked: "errors.accountLocked", + app_inactive: "errors.appInactive", + invalid_credentials: "auth.invalidCredentials", + rate_limited: "errors.rateLimited", + too_many_requests: "errors.rateLimited" +}); + +export class ExternalUiError extends Error { + constructor(translationKey) { + super(translationKey); + this.name = "ExternalUiError"; + this.translationKey = translationKey; + } +} + +export function externalUiError(translationKey) { + return new ExternalUiError(translationKey); +} + +export function translateExternalError(error, t, fallbackKey = "errors.requestFailed") { + if (error?.translationKey) { + return t(error.translationKey); + } + const code = String(error?.code || "").trim().toLowerCase().replace(/[.-]/g, "_"); + if (ERROR_CODE_KEYS[code]) { + return t(ERROR_CODE_KEYS[code]); + } + if (error?.status === 401) { + return t("errors.unauthorized"); + } + if (error?.status === 403) { + return t("errors.forbidden"); + } + if (error?.status === 404) { + return t("errors.notFound"); + } + if (error?.status === 409) { + return t("errors.conflict"); + } + if (error?.status === 413) { + return t("errors.tooLarge"); + } + if (error?.status === 429) { + return t("errors.rateLimited"); + } + if (Number(error?.status) >= 500) { + return t("errors.server"); + } + return t(fallbackKey); +} diff --git a/external-admin/src/i18n/messages.js b/external-admin/src/i18n/messages.js new file mode 100644 index 0000000..c442286 --- /dev/null +++ b/external-admin/src/i18n/messages.js @@ -0,0 +1,404 @@ +export const EXTERNAL_LOCALES = Object.freeze(["en", "zh-CN", "ar", "tr"]); +export const DEFAULT_EXTERNAL_LOCALE = "en"; +export const EXTERNAL_LOCALE_STORAGE_KEY = "hyapp_external_admin_locale"; + +export const EXTERNAL_LANGUAGE_OPTIONS = Object.freeze([ + { label: "English", value: "en" }, + { label: "简体中文", value: "zh-CN" }, + { label: "العربية", value: "ar" }, + { label: "Türkçe", value: "tr" } +]); + +const en = { + "app.title": "HYApp External Admin", + "app.shortTitle": "HYApp External Admin", + "language.label": "Language", + "common.actions": "Actions", + "common.active": "Active", + "common.all": "All", + "common.allStatuses": "All statuses", + "common.banned": "Banned", + "common.cancel": "Cancel", + "common.confirm": "Confirm", + "common.create": "Create", + "common.disabled": "Disabled", + "common.edit": "Edit", + "common.enabled": "Enabled", + "common.expired": "Expired", + "common.loading": "Loading", + "common.noData": "No data", + "common.normal": "Normal", + "common.query": "Search", + "common.reason": "Reason", + "common.reload": "Reload", + "common.retry": "Retry", + "common.save": "Save", + "common.status": "Status", + "common.user": "User", + "auth.account": "External admin account", + "auth.changePassword": "Change external admin password", + "auth.checkingSession": "Checking external admin session", + "auth.confirmNewPassword": "Confirm new password", + "auth.currentPassword": "Current password", + "auth.fillAccountPassword": "Enter your account and password", + "auth.firstLoginChange": "You must change your password on first sign-in", + "auth.hidePassword": "Hide password", + "auth.invalidCredentials": "Incorrect account or password", + "auth.login": "Sign in", + "auth.loginFailed": "Sign-in failed", + "auth.logout": "Sign out", + "auth.newPassword": "New password", + "auth.noPermission": "This external admin account does not have access to this feature.", + "auth.password": "Password", + "auth.passwordChangedFailed": "Failed to change password", + "auth.passwordMismatch": "The new passwords do not match", + "auth.passwordMin": "At least 8 characters", + "auth.passwordMinError": "The new password must be at least 8 characters", + "auth.passwordWhitespace": "The new password cannot be empty or contain only whitespace", + "auth.recheck": "Check again", + "auth.returnOverview": "Return to permission overview", + "auth.savePassword": "Save password", + "auth.sessionFailed": "Session verification failed", + "auth.showPassword": "Show password", + "nav.expand": "Open navigation", + "nav.overview": "Permission overview", + "nav.users": "User management", + "nav.bans": "Ban management", + "nav.hosts": "Hosts", + "nav.agencies": "Agencies", + "nav.bds": "BDs", + "nav.bdManagers": "BD Managers", + "nav.superAdmins": "Super Admins", + "nav.rooms": "Room management", + "nav.grants": "Resources & vanity IDs", + "nav.banners": "Banner management", + "nav.team": "My team", + "capability.userList": "User list", + "capability.userUpdate": "Edit user information", + "capability.banList": "Account ban list", + "capability.ban": "Ban users", + "capability.unban": "Unban users", + "capability.hostList": "Host list", + "capability.agencyList": "Agency list", + "capability.bdList": "BD list", + "capability.bdManagerList": "BD Manager list", + "capability.superAdminList": "Super Admin list", + "capability.roomList": "Room management", + "capability.roomUpdate": "Edit rooms", + "capability.privilegeList": "User privilege item list", + "capability.privilegeGrant": "Grant privilege items", + "capability.bannerCreate": "Create banners", + "capability.prettyIdGrant": "Grant vanity IDs", + "capability.userTitleGrant": "Grant user titles", + "capability.roomBackgroundGrant": "Grant room backgrounds", + "capability.userLevelGrant": "Grant wealth/VIP levels", + "capability.teamView": "My team", + "overview.currentApp": "Current App", + "overview.account": "External admin account", + "overview.permissions": "Permissions enabled", + "overview.enabled": "Enabled", + "overview.notEnabled": "Not enabled", + "users.title": "User management", + "users.search": "User ID / vanity ID / name", + "users.prettyId": "Vanity ID", + "users.countryGender": "Country / gender", + "users.level": "Level", + "users.levelSummary": "Wealth {wealth} / Charm {charm} / VIP {vip}", + "users.edit": "Edit", + "users.grantLevel": "Level", + "users.ban": "Ban", + "users.updated": "User information updated", + "users.banned": "User banned", + "users.levelGranted": "User level granted", + "users.operationFailed": "User operation failed", + "users.editTitle": "Edit user", + "users.avatar": "Avatar", + "users.name": "User name", + "users.gender": "Gender", + "users.countryCode": "Country/region code", + "users.notSet": "Not set", + "users.genderMale": "Male", + "users.genderFemale": "Female", + "users.genderUnknown": "Unknown", + "users.banTitle": "Ban user {user}", + "users.banDays": "Ban duration (days)", + "users.banForeverHint": "Enter 0 for a permanent ban", + "users.banReason": "Ban reason", + "users.levelTitle": "Grant level to {user}", + "users.levelType": "Level type", + "users.wealthLevel": "Wealth level", + "users.charmLevel": "Charm level", + "users.vipLevel": "VIP level", + "users.validDays": "Validity (days)", + "users.validDaysMin": "Validity must be at least 1 day", + "users.grantReason": "Grant reason", + "bans.title": "Account ban list", + "bans.reason": "Ban reason", + "bans.unban": "Unban", + "bans.unbanned": "User unbanned", + "bans.unbanFailed": "Failed to unban user", + "bans.confirmTitle": "Unban user", + "bans.confirmContent": "Unban {user}?", + "organization.title": "Organization", + "organization.agencies": "Agency list", + "organization.bds": "BD list", + "organization.bdManagers": "BD Manager list", + "organization.hosts": "Host list", + "organization.managers": "Manager list", + "organization.superAdmins": "Super Admin list", + "organization.team": "My team", + "organization.agency": "Agency", + "organization.owner": "Owner", + "organization.parentBd": "Parent BD", + "organization.member": "Member", + "organization.country": "Country / region", + "organization.userReference": "User {id}", + "organization.agencyReference": "Agency {id}", + "organization.teamSearch": "Agency ID / owner user ID / parent BD ID", + "organization.noPermission": "This account does not have access to this organization list.", + "organization.bdLeaderCount": "BD Leaders: {count}", + "organization.bdCount": "BDs: {count}", + "organization.agencyCount": "Agencies: {count}", + "organization.truncated": "The team exceeds the API limit. These results are incomplete.", + "rooms.title": "Room management", + "rooms.room": "Room", + "rooms.owner": "Owner", + "rooms.visibleRegion": "Visible region", + "rooms.search": "Room ID / name / owner", + "rooms.updated": "Room information updated", + "rooms.updateFailed": "Failed to update room", + "rooms.editTitle": "Edit room", + "rooms.background": "Room background", + "rooms.name": "Room name", + "rooms.description": "Room description", + "rooms.visibleRegionId": "Visible region ID", + "rooms.allRegions": "All", + "grants.title": "Resources & vanity IDs", + "grants.grantResource": "Grant item", + "grants.grantPrettyId": "Grant vanity ID", + "grants.availableResources": "Available resources", + "grants.targetUser": "Target user", + "grants.resource": "Resource", + "grants.quantity": "Quantity", + "grants.resourceGranted": "Privilege item granted", + "grants.prettyIdGranted": "Vanity ID granted", + "grants.failed": "Grant failed", + "grants.resourceTitle": "Grant privilege item", + "grants.prettyIdTitle": "Grant vanity ID", + "grants.target": "User ID / vanity ID", + "grants.currentTarget": "User ID / current vanity ID", + "grants.prettyId": "Vanity ID to grant", + "grants.validDays": "Validity (days)", + "grants.permanentHint": "Enter 0 for permanent validity", + "grants.reason": "Grant reason", + "grants.notice": "Before submission, the user is verified in the current App and the server-returned user ID is used for the grant.", + "grants.confirm": "Confirm grant", + "banners.title": "Banner management", + "banners.banner": "Banner", + "banners.type": "Type", + "banners.platform": "Platform", + "banners.sort": "Sort order", + "banners.create": "Create banner", + "banners.created": "Banner created", + "banners.createFailed": "Failed to create banner", + "banners.image": "Banner image", + "banners.roomImage": "In-room thumbnail", + "banners.description": "Description", + "banners.jumpType": "Destination type", + "banners.h5Param": "Parameter (H5 URL)", + "banners.appParam": "Parameter (App navigation JSON)", + "banners.displayScope": "Display scope", + "banners.scopeHome": "Home", + "banners.scopeRoom": "Room", + "banners.scopeRecharge": "Recharge", + "banners.scopeMe": "Me", + "banners.regionId": "Region ID", + "banners.countryCode": "Country code", + "banners.countryCodeHint": "2–3 letters; leave empty for all", + "banners.schedule": "Schedule", + "upload.preview": "{label} preview", + "upload.empty": "No image", + "upload.uploading": "Uploading", + "upload.replace": "Replace image", + "upload.select": "Upload image", + "upload.imageOnly": "Select an image file", + "upload.sizeLimit": "Images must not exceed 10 MB", + "upload.missingUrl": "The upload response did not contain an image URL", + "upload.failed": "Image upload failed", + "pagination.total": "{count} items", + "pagination.previous": "Previous", + "pagination.next": "Next", + "status.active": "Active", + "status.enabled": "Enabled", + "status.running": "Running", + "status.normal": "Normal", + "status.unbanned": "Unbanned", + "status.banned": "Banned", + "status.disabled": "Disabled", + "status.failed": "Failed", + "status.blocked": "Blocked", + "status.expired": "Expired", + "jump.appTarget": "App destination", + "jump.target.wallet": "Wallet", + "jump.target.room": "Specific room", + "jump.target.room_random": "Random room", + "jump.target.room_window": "Window in a specific room", + "jump.target.room_random_window": "Window in a random room", + "jump.target.room_game": "Game in a specific room", + "jump.target.room_random_game": "Game in a random room", + "jump.target.explore_game": "Explore game H5", + "jump.target.custom": "Custom JSON", + "jump.roomId": "Room ID", + "jump.window": "In-room window", + "jump.window.game_window": "Game window", + "jump.window.gift_panel": "Gift panel", + "jump.window.red_packet_claim": "Red packet dialog", + "jump.window.wheel": "Wheel window", + "jump.gameId": "Game ID", + "jump.giftId": "Gift ID", + "jump.redPacketNo": "Red packet number", + "jump.hint": "App navigation parameters are generated as JSON. You can edit the JSON directly for custom parameters.", + "time.unlimited": "Any time", + "time.quickHour": "Last hour", + "time.quickDay": "Last 24 hours", + "time.quickWeek": "Last 7 days", + "time.start": "Start time", + "time.end": "End time", + "time.startDate": "Start date", + "time.endDate": "End date", + "time.pending": "Not selected", + "time.startMark": "S", + "time.endMark": "E", + "time.previousMonth": "Previous month", + "time.nextMonth": "Next month", + "time.hour": "Hour", + "time.minute": "Minute", + "time.clear": "Clear", + "time.apply": "Apply", + "time.endAfterStart": "End time must be later than start time", + "time.endDateAfterStart": "End date cannot be earlier than start date", + "time.completeRange": "Select the full range", + "time.endLaterShort": "End must be later than start", + "time.endDateLaterShort": "End cannot be earlier than start", + "time.durationDays": "{count} days", + "time.durationHours": "{count} hours", + "time.durationMinutes": "{count} minutes", + "time.duration": "{value}", + "time.sameDay": "Same day", + "time.lessMinute": "Less than 1 minute", + "errors.loadFailed": "Failed to load data", + "errors.requestFailed": "Request failed", + "errors.unauthorized": "Your session has expired. Sign in again.", + "errors.forbidden": "You do not have permission to perform this operation.", + "errors.notFound": "The requested resource was not found.", + "errors.conflict": "This operation conflicts with the current data. Refresh and try again.", + "errors.tooLarge": "The request is too large.", + "errors.rateLimited": "Too many requests. Try again later.", + "errors.server": "The service is temporarily unavailable. Try again later.", + "errors.accountLocked": "This account is temporarily locked. Try again later.", + "errors.appInactive": "The App linked to this account is inactive.", + "errors.vipDisabled": "The VIP program for the current App is disabled", + "errors.vipLevelInvalid": "Select a VIP level configured for the current App", + "errors.organizationRoute": "This organization list has no secure data endpoint", + "errors.regionId": "Visible region ID must be a safe non-negative integer", + "errors.grantTarget": "No target user was found in the current App", + "errors.bannerType": "Invalid banner type", + "errors.bannerPlatform": "Invalid banner platform", + "errors.bannerStatus": "Invalid banner status", + "errors.bannerScope": "Select at least one display scope", + "errors.bannerImage": "Upload a banner image", + "errors.h5Url": "Enter a valid H5 URL", + "errors.h5Protocol": "H5 URLs must use http or https", + "errors.appParamRequired": "Enter App navigation parameters", + "errors.appParamObject": "App navigation parameters must be a JSON object", + "errors.appParamType": "The App navigation type is not supported", + "errors.appParamRoom": "This App destination requires room_id", + "errors.appParamGame": "This App destination requires game_id", + "errors.appParamWindow": "This App destination requires a valid window", + "errors.appParamGameCode": "Explore game H5 requires a valid game_code", + "errors.roomBannerImage": "Upload an in-room thumbnail for the room scope", + "errors.countryCode": "Country code must contain 2–3 letters", + "errors.bannerRegion": "Invalid region ID", + "errors.bannerSchedule": "The schedule end time must be later than the start time" +}; + +const zhCN = { + "app.title": "HYApp 外管后台", + "app.shortTitle": "HYApp 外管", + "language.label": "语言", + "common.actions": "操作", "common.active": "正常", "common.all": "全部", "common.allStatuses": "全部状态", "common.banned": "已封禁", "common.cancel": "取消", "common.confirm": "确认", "common.create": "创建", "common.disabled": "禁用", "common.edit": "编辑", "common.enabled": "启用", "common.expired": "过期", "common.loading": "正在加载", "common.noData": "当前无数据", "common.normal": "正常", "common.query": "查询", "common.reason": "原因", "common.reload": "重新加载", "common.retry": "重试", "common.save": "保存", "common.status": "状态", "common.user": "用户", + "auth.account": "外管账号", "auth.changePassword": "修改外管密码", "auth.checkingSession": "正在校验外管会话", "auth.confirmNewPassword": "确认新密码", "auth.currentPassword": "当前密码", "auth.fillAccountPassword": "请填写账号和密码", "auth.firstLoginChange": "首次登录必须修改密码", "auth.hidePassword": "隐藏密码", "auth.invalidCredentials": "账号或密码错误", "auth.login": "登录", "auth.loginFailed": "登录失败", "auth.logout": "退出登录", "auth.newPassword": "新密码", "auth.noPermission": "当前外管账号没有访问此功能的权限。", "auth.password": "密码", "auth.passwordChangedFailed": "密码修改失败", "auth.passwordMismatch": "两次输入的新密码不一致", "auth.passwordMin": "至少 8 个字符", "auth.passwordMinError": "新密码至少 8 个字符", "auth.passwordWhitespace": "新密码不能为空或全为空白字符", "auth.recheck": "重新校验", "auth.returnOverview": "返回权限总览", "auth.savePassword": "保存密码", "auth.sessionFailed": "会话校验失败", "auth.showPassword": "显示密码", + "nav.expand": "展开菜单", "nav.overview": "权限总览", "nav.users": "用户管理", "nav.bans": "封禁管理", "nav.hosts": "主播列表", "nav.agencies": "公会列表", "nav.bds": "BD 列表", "nav.bdManagers": "BD Manager 列表", "nav.superAdmins": "Super Admin 列表", "nav.rooms": "房间管理", "nav.grants": "资源与靓号", "nav.banners": "Banner 管理", "nav.team": "我的团队", + "capability.userList": "用户列表", "capability.userUpdate": "用户信息编辑", "capability.banList": "账号封禁列表", "capability.ban": "执行封禁", "capability.unban": "解除封禁", "capability.hostList": "主播列表", "capability.agencyList": "公会列表", "capability.bdList": "BD 列表", "capability.bdManagerList": "BD Manager 列表", "capability.superAdminList": "Super Admin 列表", "capability.roomList": "房间管理", "capability.roomUpdate": "房间编辑", "capability.privilegeList": "用户特权道具列表", "capability.privilegeGrant": "特权道具发放", "capability.bannerCreate": "Banner 创建", "capability.prettyIdGrant": "靓号下发", "capability.userTitleGrant": "用户称号下发", "capability.roomBackgroundGrant": "房间背景图下发", "capability.userLevelGrant": "财富/VIP 等级下发", "capability.teamView": "我的团队", + "overview.currentApp": "当前 App", "overview.account": "外管账号", "overview.permissions": "已开通权限", "overview.enabled": "已开通", "overview.notEnabled": "未开通", + "users.title": "用户管理", "users.search": "用户 ID / 短 ID / 名称", "users.prettyId": "短 ID(靓号)", "users.countryGender": "国家/性别", "users.level": "等级", "users.levelSummary": "财富 {wealth} / 魅力 {charm} / VIP {vip}", "users.edit": "编辑", "users.grantLevel": "等级", "users.ban": "封禁", "users.updated": "用户信息已更新", "users.banned": "用户已封禁", "users.levelGranted": "用户等级已下发", "users.operationFailed": "用户操作失败", "users.editTitle": "编辑用户", "users.avatar": "头像", "users.name": "用户名称", "users.gender": "性别", "users.countryCode": "国家/地区码", "users.notSet": "未设置", "users.genderMale": "男", "users.genderFemale": "女", "users.genderUnknown": "未知", "users.banTitle": "封禁用户 {user}", "users.banDays": "封禁天数", "users.banForeverHint": "填 0 表示永久封禁", "users.banReason": "封禁原因", "users.levelTitle": "等级下发 {user}", "users.levelType": "等级类型", "users.wealthLevel": "财富等级", "users.charmLevel": "魅力等级", "users.vipLevel": "VIP 等级", "users.validDays": "有效天数", "users.validDaysMin": "有效期至少 1 天", "users.grantReason": "下发原因", + "bans.title": "账号封禁列表", "bans.reason": "封禁原因", "bans.unban": "解除封禁", "bans.unbanned": "用户已解除封禁", "bans.unbanFailed": "解除封禁失败", "bans.confirmTitle": "解除封禁", "bans.confirmContent": "确认解除用户 {user} 的封禁?", + "organization.title": "组织管理", "organization.agencies": "公会列表", "organization.bds": "BD 列表", "organization.bdManagers": "BD Manager 列表", "organization.hosts": "主播列表", "organization.managers": "管理员列表", "organization.superAdmins": "Super Admin 列表", "organization.team": "我的团队", "organization.agency": "公会", "organization.owner": "负责人", "organization.parentBd": "上级 BD", "organization.member": "成员", "organization.country": "国家/地区", "organization.userReference": "用户 {id}", "organization.agencyReference": "公会 {id}", "organization.teamSearch": "公会 ID / 负责人用户 ID / 上级 BD ID", "organization.noPermission": "当前外管账号没有访问该组织列表的权限。", "organization.bdLeaderCount": "BD Leader:{count}", "organization.bdCount": "BD:{count}", "organization.agencyCount": "公会:{count}", "organization.truncated": "团队规模超过接口上限,当前结果不完整", + "rooms.title": "房间管理", "rooms.room": "房间", "rooms.owner": "房主", "rooms.visibleRegion": "可见区域", "rooms.search": "房间 ID / 名称 / 房主", "rooms.updated": "房间信息已更新", "rooms.updateFailed": "房间更新失败", "rooms.editTitle": "编辑房间", "rooms.background": "房间背景图", "rooms.name": "房间名称", "rooms.description": "房间介绍", "rooms.visibleRegionId": "可见区域 ID", "rooms.allRegions": "全部", + "grants.title": "资源与靓号", "grants.grantResource": "发放道具", "grants.grantPrettyId": "下发靓号", "grants.availableResources": "可发放资源", "grants.targetUser": "目标用户", "grants.resource": "资源", "grants.quantity": "数量", "grants.resourceGranted": "特权道具已发放", "grants.prettyIdGranted": "靓号已下发", "grants.failed": "发放失败", "grants.resourceTitle": "特权道具发放", "grants.prettyIdTitle": "靓号下发", "grants.target": "用户 ID / 短 ID(靓号)", "grants.currentTarget": "用户 ID / 当前短 ID", "grants.prettyId": "下发靓号", "grants.validDays": "有效天数", "grants.permanentHint": "填 0 表示永久有效", "grants.reason": "发放原因", "grants.notice": "提交前会校验当前 App 内的用户,并使用服务端返回的用户 ID 执行发放。", "grants.confirm": "确认发放", + "banners.title": "Banner 管理", "banners.banner": "Banner", "banners.type": "类型", "banners.platform": "平台", "banners.sort": "排序", "banners.create": "创建 Banner", "banners.created": "Banner 已创建", "banners.createFailed": "Banner 创建失败", "banners.image": "Banner 图片", "banners.roomImage": "房间内小屏图", "banners.description": "描述", "banners.jumpType": "跳转类型", "banners.h5Param": "参数(H5 链接)", "banners.appParam": "参数(APP 公共跳转 JSON)", "banners.displayScope": "展示范围", "banners.scopeHome": "首页", "banners.scopeRoom": "房间", "banners.scopeRecharge": "充值", "banners.scopeMe": "我的", "banners.regionId": "区域 ID", "banners.countryCode": "国家码", "banners.countryCodeHint": "2-3 位字母,留空为全部", "banners.schedule": "投放时间", + "upload.preview": "{label}预览", "upload.empty": "暂无图片", "upload.uploading": "上传中", "upload.replace": "更换图片", "upload.select": "上传图片", "upload.imageOnly": "请选择图片文件", "upload.sizeLimit": "图片不能超过 10MB", "upload.missingUrl": "上传结果缺少图片地址", "upload.failed": "图片上传失败", + "pagination.total": "共 {count} 条", "pagination.previous": "上一页", "pagination.next": "下一页", + "status.active": "正常", "status.enabled": "启用", "status.running": "运行中", "status.normal": "正常", "status.unbanned": "未封禁", "status.banned": "已封禁", "status.disabled": "禁用", "status.failed": "失败", "status.blocked": "已拦截", "status.expired": "过期", + "jump.appTarget": "APP 目标", "jump.target.wallet": "钱包", "jump.target.room": "指定房间", "jump.target.room_random": "随机房间", "jump.target.room_window": "指定房内拉起窗口", "jump.target.room_random_window": "随机房内拉起窗口", "jump.target.room_game": "指定房内拉起游戏", "jump.target.room_random_game": "随机房内拉起游戏", "jump.target.explore_game": "Explore 游戏 H5", "jump.target.custom": "自定义 JSON", "jump.roomId": "房间 ID", "jump.window": "房内窗口", "jump.window.game_window": "游戏窗口", "jump.window.gift_panel": "礼物面板", "jump.window.red_packet_claim": "领红包弹窗", "jump.window.wheel": "转盘窗口", "jump.gameId": "游戏 ID", "jump.giftId": "礼物 ID", "jump.redPacketNo": "红包编号", "jump.hint": "APP 参数会按公共跳转方案生成 JSON;需要特殊参数时可直接编辑 JSON。", + "time.unlimited": "不限", "time.quickHour": "近 1 小时", "time.quickDay": "近 24 小时", "time.quickWeek": "近 7 天", "time.start": "开始时间", "time.end": "结束时间", "time.startDate": "开始日期", "time.endDate": "结束日期", "time.pending": "待选择", "time.startMark": "始", "time.endMark": "末", "time.previousMonth": "上个月", "time.nextMonth": "下个月", "time.hour": "时", "time.minute": "分", "time.clear": "清空", "time.apply": "确定", "time.endAfterStart": "结束时间必须晚于开始时间", "time.endDateAfterStart": "结束日期不能早于开始日期", "time.completeRange": "选择完整区间", "time.endLaterShort": "结束需晚于开始", "time.endDateLaterShort": "结束不能早于开始", "time.durationDays": "{count} 天", "time.durationHours": "{count} 小时", "time.durationMinutes": "{count} 分钟", "time.duration": "间隔 {value}", "time.sameDay": "同一天", "time.lessMinute": "间隔少于 1 分钟", + "errors.loadFailed": "数据加载失败", "errors.requestFailed": "请求失败", "errors.unauthorized": "会话已失效,请重新登录。", "errors.forbidden": "你没有执行此操作的权限。", "errors.notFound": "未找到请求的资源。", "errors.conflict": "当前数据状态存在冲突,请刷新后重试。", "errors.tooLarge": "请求内容过大。", "errors.rateLimited": "请求过于频繁,请稍后重试。", "errors.server": "服务暂时不可用,请稍后重试。", "errors.accountLocked": "账号已临时锁定,请稍后重试。", "errors.appInactive": "该账号关联的 App 已停用。", "errors.vipDisabled": "当前 App 的 VIP 体系已停用", "errors.vipLevelInvalid": "请选择当前 App 已配置的 VIP 等级", "errors.organizationRoute": "该组织列表尚无安全的数据接口", "errors.regionId": "可见区域 ID 必须是安全的非负整数", "errors.grantTarget": "未找到当前 App 内的目标用户", "errors.bannerType": "Banner 类型不正确", "errors.bannerPlatform": "Banner 平台不正确", "errors.bannerStatus": "Banner 状态不正确", "errors.bannerScope": "请至少选择一个展示范围", "errors.bannerImage": "请上传 Banner 图片", "errors.h5Url": "请填写合法的 H5 链接", "errors.h5Protocol": "H5 链接仅支持 http 或 https", "errors.appParamRequired": "请填写 APP 跳转参数", "errors.appParamObject": "APP 跳转参数必须是 JSON 对象", "errors.appParamType": "APP 跳转 type 不在公共跳转方案内", "errors.appParamRoom": "该 APP 跳转需要填写 room_id", "errors.appParamGame": "该 APP 跳转需要填写 game_id", "errors.appParamWindow": "该 APP 跳转需要填写有效 window", "errors.appParamGameCode": "Explore 游戏 H5 需要选择有效 game_code", "errors.roomBannerImage": "房间范围必须上传房间内小屏图", "errors.countryCode": "国家码必须是 2-3 位字母", "errors.bannerRegion": "区域 ID 不正确", "errors.bannerSchedule": "投放结束时间必须晚于开始时间" +}; + +const ar = { + ...en, + "app.title": "إدارة HYApp الخارجية", "app.shortTitle": "إدارة HYApp الخارجية", "language.label": "اللغة", + "common.actions": "الإجراءات", "common.active": "نشط", "common.all": "الكل", "common.allStatuses": "كل الحالات", "common.banned": "محظور", "common.cancel": "إلغاء", "common.confirm": "تأكيد", "common.create": "إنشاء", "common.disabled": "معطّل", "common.edit": "تعديل", "common.enabled": "مفعّل", "common.expired": "منتهي", "common.loading": "جارٍ التحميل", "common.noData": "لا توجد بيانات", "common.normal": "طبيعي", "common.query": "بحث", "common.reason": "السبب", "common.reload": "إعادة التحميل", "common.retry": "إعادة المحاولة", "common.save": "حفظ", "common.status": "الحالة", "common.user": "المستخدم", + "auth.account": "حساب الإدارة الخارجية", "auth.changePassword": "تغيير كلمة مرور الإدارة الخارجية", "auth.checkingSession": "جارٍ التحقق من الجلسة", "auth.confirmNewPassword": "تأكيد كلمة المرور الجديدة", "auth.currentPassword": "كلمة المرور الحالية", "auth.fillAccountPassword": "أدخل الحساب وكلمة المرور", "auth.firstLoginChange": "يجب تغيير كلمة المرور عند تسجيل الدخول لأول مرة", "auth.hidePassword": "إخفاء كلمة المرور", "auth.invalidCredentials": "الحساب أو كلمة المرور غير صحيحة", "auth.login": "تسجيل الدخول", "auth.loginFailed": "فشل تسجيل الدخول", "auth.logout": "تسجيل الخروج", "auth.newPassword": "كلمة المرور الجديدة", "auth.noPermission": "لا يملك هذا الحساب صلاحية الوصول إلى هذه الميزة.", "auth.password": "كلمة المرور", "auth.passwordChangedFailed": "فشل تغيير كلمة المرور", "auth.passwordMismatch": "كلمتا المرور الجديدتان غير متطابقتين", "auth.passwordMin": "8 أحرف على الأقل", "auth.passwordMinError": "يجب أن تتكون كلمة المرور الجديدة من 8 أحرف على الأقل", "auth.passwordWhitespace": "لا يمكن أن تكون كلمة المرور الجديدة فارغة أو مسافات فقط", "auth.recheck": "إعادة التحقق", "auth.returnOverview": "العودة إلى ملخص الصلاحيات", "auth.savePassword": "حفظ كلمة المرور", "auth.sessionFailed": "فشل التحقق من الجلسة", "auth.showPassword": "إظهار كلمة المرور", + "nav.expand": "فتح القائمة", "nav.overview": "ملخص الصلاحيات", "nav.users": "إدارة المستخدمين", "nav.bans": "إدارة الحظر", "nav.hosts": "المضيفون", "nav.agencies": "الوكالات", "nav.bds": "قائمة BD", "nav.bdManagers": "مديرو BD", "nav.superAdmins": "المشرفون العامون", "nav.rooms": "إدارة الغرف", "nav.grants": "الموارد والمعرّفات المميزة", "nav.banners": "إدارة اللافتات", "nav.team": "فريقي", + "capability.userList": "قائمة المستخدمين", "capability.userUpdate": "تعديل بيانات المستخدم", "capability.banList": "قائمة الحسابات المحظورة", "capability.ban": "حظر المستخدمين", "capability.unban": "إلغاء حظر المستخدمين", "capability.hostList": "قائمة المضيفين", "capability.agencyList": "قائمة الوكالات", "capability.bdList": "قائمة BD", "capability.bdManagerList": "قائمة مديري BD", "capability.superAdminList": "قائمة المشرفين العامين", "capability.roomList": "إدارة الغرف", "capability.roomUpdate": "تعديل الغرف", "capability.privilegeList": "قائمة عناصر الامتياز", "capability.privilegeGrant": "منح عناصر الامتياز", "capability.bannerCreate": "إنشاء اللافتات", "capability.prettyIdGrant": "منح المعرّفات المميزة", "capability.userTitleGrant": "منح ألقاب المستخدمين", "capability.roomBackgroundGrant": "منح خلفيات الغرف", "capability.userLevelGrant": "منح مستويات الثروة/VIP", "capability.teamView": "فريقي", + "overview.currentApp": "التطبيق الحالي", "overview.account": "حساب الإدارة الخارجية", "overview.permissions": "الصلاحيات المفعّلة", "overview.enabled": "مفعّلة", "overview.notEnabled": "غير مفعّلة", + "users.title": "إدارة المستخدمين", "users.search": "معرّف المستخدم / المعرّف المميز / الاسم", "users.prettyId": "المعرّف المميز", "users.countryGender": "الدولة / الجنس", "users.level": "المستوى", "users.levelSummary": "الثروة {wealth} / الجاذبية {charm} / VIP {vip}", "users.edit": "تعديل", "users.grantLevel": "المستوى", "users.ban": "حظر", "users.updated": "تم تحديث بيانات المستخدم", "users.banned": "تم حظر المستخدم", "users.levelGranted": "تم منح مستوى المستخدم", "users.operationFailed": "فشلت عملية المستخدم", "users.editTitle": "تعديل المستخدم", "users.avatar": "الصورة الشخصية", "users.name": "اسم المستخدم", "users.gender": "الجنس", "users.countryCode": "رمز الدولة/المنطقة", "users.notSet": "غير محدد", "users.genderMale": "ذكر", "users.genderFemale": "أنثى", "users.genderUnknown": "غير معروف", "users.banTitle": "حظر المستخدم {user}", "users.banDays": "مدة الحظر (بالأيام)", "users.banForeverHint": "أدخل 0 للحظر الدائم", "users.banReason": "سبب الحظر", "users.levelTitle": "منح مستوى إلى {user}", "users.levelType": "نوع المستوى", "users.wealthLevel": "مستوى الثروة", "users.charmLevel": "مستوى الجاذبية", "users.vipLevel": "مستوى VIP", "users.validDays": "الصلاحية (بالأيام)", "users.validDaysMin": "يجب ألا تقل الصلاحية عن يوم واحد", "users.grantReason": "سبب المنح", + "bans.title": "قائمة الحسابات المحظورة", "bans.reason": "سبب الحظر", "bans.unban": "إلغاء الحظر", "bans.unbanned": "تم إلغاء حظر المستخدم", "bans.unbanFailed": "فشل إلغاء حظر المستخدم", "bans.confirmTitle": "إلغاء حظر المستخدم", "bans.confirmContent": "هل تريد إلغاء حظر {user}؟", + "organization.title": "المؤسسة", "organization.agencies": "قائمة الوكالات", "organization.bds": "قائمة BD", "organization.bdManagers": "قائمة مديري BD", "organization.hosts": "قائمة المضيفين", "organization.managers": "قائمة المديرين", "organization.superAdmins": "قائمة المشرفين العامين", "organization.team": "فريقي", "organization.agency": "الوكالة", "organization.owner": "المالك", "organization.parentBd": "BD الأعلى", "organization.member": "العضو", "organization.country": "الدولة / المنطقة", "organization.userReference": "المستخدم {id}", "organization.agencyReference": "الوكالة {id}", "organization.teamSearch": "معرّف الوكالة / معرّف المالك / معرّف BD الأعلى", "organization.noPermission": "لا يملك هذا الحساب صلاحية الوصول إلى قائمة المؤسسة هذه.", "organization.bdLeaderCount": "قادة BD: {count}", "organization.bdCount": "BD: {count}", "organization.agencyCount": "الوكالات: {count}", "organization.truncated": "يتجاوز حجم الفريق حد الواجهة. النتائج الحالية غير مكتملة.", + "rooms.title": "إدارة الغرف", "rooms.room": "الغرفة", "rooms.owner": "المالك", "rooms.visibleRegion": "المنطقة المرئية", "rooms.search": "معرّف الغرفة / الاسم / المالك", "rooms.updated": "تم تحديث بيانات الغرفة", "rooms.updateFailed": "فشل تحديث الغرفة", "rooms.editTitle": "تعديل الغرفة", "rooms.background": "خلفية الغرفة", "rooms.name": "اسم الغرفة", "rooms.description": "وصف الغرفة", "rooms.visibleRegionId": "معرّف المنطقة المرئية", "rooms.allRegions": "الكل", + "grants.title": "الموارد والمعرّفات المميزة", "grants.grantResource": "منح عنصر", "grants.grantPrettyId": "منح معرّف مميز", "grants.availableResources": "الموارد المتاحة", "grants.targetUser": "المستخدم المستهدف", "grants.resource": "المورد", "grants.quantity": "الكمية", "grants.resourceGranted": "تم منح عنصر الامتياز", "grants.prettyIdGranted": "تم منح المعرّف المميز", "grants.failed": "فشل المنح", "grants.resourceTitle": "منح عنصر امتياز", "grants.prettyIdTitle": "منح معرّف مميز", "grants.target": "معرّف المستخدم / المعرّف المميز", "grants.currentTarget": "معرّف المستخدم / المعرّف المميز الحالي", "grants.prettyId": "المعرّف المميز المراد منحه", "grants.validDays": "الصلاحية (بالأيام)", "grants.permanentHint": "أدخل 0 للصلاحية الدائمة", "grants.reason": "سبب المنح", "grants.notice": "قبل الإرسال، يتم التحقق من المستخدم داخل التطبيق الحالي ويُستخدم معرّف المستخدم الذي يعيده الخادم.", "grants.confirm": "تأكيد المنح", + "banners.title": "إدارة اللافتات", "banners.banner": "اللافتة", "banners.type": "النوع", "banners.platform": "المنصة", "banners.sort": "الترتيب", "banners.create": "إنشاء لافتة", "banners.created": "تم إنشاء اللافتة", "banners.createFailed": "فشل إنشاء اللافتة", "banners.image": "صورة اللافتة", "banners.roomImage": "الصورة المصغرة داخل الغرفة", "banners.description": "الوصف", "banners.jumpType": "نوع الوجهة", "banners.h5Param": "المعامل (رابط H5)", "banners.appParam": "المعامل (JSON للتنقل داخل التطبيق)", "banners.displayScope": "نطاق العرض", "banners.scopeHome": "الرئيسية", "banners.scopeRoom": "الغرفة", "banners.scopeRecharge": "الشحن", "banners.scopeMe": "حسابي", "banners.regionId": "معرّف المنطقة", "banners.countryCode": "رمز الدولة", "banners.countryCodeHint": "حرفان أو 3؛ اتركه فارغًا للكل", "banners.schedule": "جدول العرض", + "upload.preview": "معاينة {label}", "upload.empty": "لا توجد صورة", "upload.uploading": "جارٍ الرفع", "upload.replace": "استبدال الصورة", "upload.select": "رفع صورة", "upload.imageOnly": "اختر ملف صورة", "upload.sizeLimit": "يجب ألا يتجاوز حجم الصورة 10 ميغابايت", "upload.missingUrl": "لا تحتوي استجابة الرفع على رابط للصورة", "upload.failed": "فشل رفع الصورة", + "pagination.total": "{count} عنصر", "pagination.previous": "السابق", "pagination.next": "التالي", + "status.active": "نشط", "status.enabled": "مفعّل", "status.running": "قيد التشغيل", "status.normal": "طبيعي", "status.unbanned": "غير محظور", "status.banned": "محظور", "status.disabled": "معطّل", "status.failed": "فشل", "status.blocked": "محجوب", "status.expired": "منتهي", + "jump.appTarget": "وجهة التطبيق", "jump.target.wallet": "المحفظة", "jump.target.room": "غرفة محددة", "jump.target.room_random": "غرفة عشوائية", "jump.target.room_window": "نافذة في غرفة محددة", "jump.target.room_random_window": "نافذة في غرفة عشوائية", "jump.target.room_game": "لعبة في غرفة محددة", "jump.target.room_random_game": "لعبة في غرفة عشوائية", "jump.target.explore_game": "لعبة Explore H5", "jump.target.custom": "JSON مخصص", "jump.roomId": "معرّف الغرفة", "jump.window": "نافذة داخل الغرفة", "jump.window.game_window": "نافذة اللعبة", "jump.window.gift_panel": "لوحة الهدايا", "jump.window.red_packet_claim": "نافذة الظرف الأحمر", "jump.window.wheel": "نافذة العجلة", "jump.gameId": "معرّف اللعبة", "jump.giftId": "معرّف الهدية", "jump.redPacketNo": "رقم الظرف الأحمر", "jump.hint": "يتم إنشاء معاملات التنقل داخل التطبيق بصيغة JSON، ويمكنك تعديلها مباشرة للمعاملات المخصصة.", + "time.unlimited": "أي وقت", "time.quickHour": "آخر ساعة", "time.quickDay": "آخر 24 ساعة", "time.quickWeek": "آخر 7 أيام", "time.start": "وقت البدء", "time.end": "وقت الانتهاء", "time.startDate": "تاريخ البدء", "time.endDate": "تاريخ الانتهاء", "time.pending": "غير محدد", "time.startMark": "ب", "time.endMark": "ن", "time.previousMonth": "الشهر السابق", "time.nextMonth": "الشهر التالي", "time.hour": "الساعة", "time.minute": "الدقيقة", "time.clear": "مسح", "time.apply": "تطبيق", "time.endAfterStart": "يجب أن يكون وقت الانتهاء بعد وقت البدء", "time.endDateAfterStart": "لا يمكن أن يسبق تاريخ الانتهاء تاريخ البدء", "time.completeRange": "حدد النطاق الكامل", "time.endLaterShort": "يجب أن تكون النهاية بعد البداية", "time.endDateLaterShort": "لا يمكن أن تسبق النهاية البداية", "time.durationDays": "{count} يوم", "time.durationHours": "{count} ساعة", "time.durationMinutes": "{count} دقيقة", "time.duration": "{value}", "time.sameDay": "اليوم نفسه", "time.lessMinute": "أقل من دقيقة واحدة", + "errors.loadFailed": "فشل تحميل البيانات", "errors.requestFailed": "فشل الطلب", "errors.unauthorized": "انتهت الجلسة. سجّل الدخول مرة أخرى.", "errors.forbidden": "ليس لديك إذن لتنفيذ هذه العملية.", "errors.notFound": "لم يتم العثور على المورد المطلوب.", "errors.conflict": "تتعارض العملية مع البيانات الحالية. حدّث الصفحة وحاول مجددًا.", "errors.tooLarge": "حجم الطلب كبير جدًا.", "errors.rateLimited": "طلبات كثيرة جدًا. حاول لاحقًا.", "errors.server": "الخدمة غير متاحة مؤقتًا. حاول لاحقًا.", "errors.accountLocked": "الحساب مقفل مؤقتًا. حاول لاحقًا.", "errors.appInactive": "التطبيق المرتبط بهذا الحساب غير نشط.", "errors.vipDisabled": "نظام VIP معطّل في التطبيق الحالي", "errors.vipLevelInvalid": "اختر مستوى VIP معدًا للتطبيق الحالي", "errors.organizationRoute": "لا توجد نقطة بيانات آمنة لقائمة المؤسسة هذه", "errors.regionId": "يجب أن يكون معرّف المنطقة عددًا صحيحًا آمنًا وغير سالب", "errors.grantTarget": "لم يتم العثور على المستخدم المستهدف في التطبيق الحالي", "errors.bannerType": "نوع اللافتة غير صالح", "errors.bannerPlatform": "منصة اللافتة غير صالحة", "errors.bannerStatus": "حالة اللافتة غير صالحة", "errors.bannerScope": "اختر نطاق عرض واحدًا على الأقل", "errors.bannerImage": "ارفع صورة للافتة", "errors.h5Url": "أدخل رابط H5 صالحًا", "errors.h5Protocol": "يجب أن يستخدم رابط H5 بروتوكول http أو https", "errors.appParamRequired": "أدخل معاملات التنقل داخل التطبيق", "errors.appParamObject": "يجب أن تكون معاملات التطبيق كائن JSON", "errors.appParamType": "نوع التنقل داخل التطبيق غير مدعوم", "errors.appParamRoom": "تتطلب هذه الوجهة room_id", "errors.appParamGame": "تتطلب هذه الوجهة game_id", "errors.appParamWindow": "تتطلب هذه الوجهة نافذة صالحة", "errors.appParamGameCode": "تتطلب لعبة Explore H5 قيمة game_code صالحة", "errors.roomBannerImage": "ارفع صورة مصغرة لنطاق الغرفة", "errors.countryCode": "يجب أن يتكون رمز الدولة من حرفين أو 3", "errors.bannerRegion": "معرّف المنطقة غير صالح", "errors.bannerSchedule": "يجب أن يكون وقت انتهاء العرض بعد وقت البدء" +}; + +const tr = { + ...en, + "app.title": "HYApp Harici Yönetim", "app.shortTitle": "HYApp Harici Yönetim", "language.label": "Dil", + "common.actions": "İşlemler", "common.active": "Aktif", "common.all": "Tümü", "common.allStatuses": "Tüm durumlar", "common.banned": "Yasaklı", "common.cancel": "İptal", "common.confirm": "Onayla", "common.create": "Oluştur", "common.disabled": "Devre dışı", "common.edit": "Düzenle", "common.enabled": "Etkin", "common.expired": "Süresi dolmuş", "common.loading": "Yükleniyor", "common.noData": "Veri yok", "common.normal": "Normal", "common.query": "Ara", "common.reason": "Neden", "common.reload": "Yeniden yükle", "common.retry": "Tekrar dene", "common.save": "Kaydet", "common.status": "Durum", "common.user": "Kullanıcı", + "auth.account": "Harici yönetim hesabı", "auth.changePassword": "Harici yönetim parolasını değiştir", "auth.checkingSession": "Oturum kontrol ediliyor", "auth.confirmNewPassword": "Yeni parolayı doğrula", "auth.currentPassword": "Mevcut parola", "auth.fillAccountPassword": "Hesabınızı ve parolanızı girin", "auth.firstLoginChange": "İlk girişte parolanızı değiştirmeniz gerekir", "auth.hidePassword": "Parolayı gizle", "auth.invalidCredentials": "Hesap veya parola hatalı", "auth.login": "Giriş yap", "auth.loginFailed": "Giriş başarısız", "auth.logout": "Çıkış yap", "auth.newPassword": "Yeni parola", "auth.noPermission": "Bu harici yönetim hesabının bu özelliğe erişimi yok.", "auth.password": "Parola", "auth.passwordChangedFailed": "Parola değiştirilemedi", "auth.passwordMismatch": "Yeni parolalar eşleşmiyor", "auth.passwordMin": "En az 8 karakter", "auth.passwordMinError": "Yeni parola en az 8 karakter olmalıdır", "auth.passwordWhitespace": "Yeni parola boş veya yalnızca boşluk olamaz", "auth.recheck": "Tekrar kontrol et", "auth.returnOverview": "Yetki özetine dön", "auth.savePassword": "Parolayı kaydet", "auth.sessionFailed": "Oturum doğrulanamadı", "auth.showPassword": "Parolayı göster", + "nav.expand": "Menüyü aç", "nav.overview": "Yetki özeti", "nav.users": "Kullanıcı yönetimi", "nav.bans": "Yasak yönetimi", "nav.hosts": "Yayıncılar", "nav.agencies": "Ajanslar", "nav.bds": "BD listesi", "nav.bdManagers": "BD Yöneticileri", "nav.superAdmins": "Süper Yöneticiler", "nav.rooms": "Oda yönetimi", "nav.grants": "Kaynaklar ve özel ID'ler", "nav.banners": "Banner yönetimi", "nav.team": "Ekibim", + "capability.userList": "Kullanıcı listesi", "capability.userUpdate": "Kullanıcı bilgilerini düzenleme", "capability.banList": "Yasaklı hesap listesi", "capability.ban": "Kullanıcı yasaklama", "capability.unban": "Kullanıcı yasağını kaldırma", "capability.hostList": "Yayıncı listesi", "capability.agencyList": "Ajans listesi", "capability.bdList": "BD listesi", "capability.bdManagerList": "BD Yöneticisi listesi", "capability.superAdminList": "Süper Yönetici listesi", "capability.roomList": "Oda yönetimi", "capability.roomUpdate": "Oda düzenleme", "capability.privilegeList": "Ayrıcalık öğesi listesi", "capability.privilegeGrant": "Ayrıcalık öğesi verme", "capability.bannerCreate": "Banner oluşturma", "capability.prettyIdGrant": "Özel ID verme", "capability.userTitleGrant": "Kullanıcı unvanı verme", "capability.roomBackgroundGrant": "Oda arka planı verme", "capability.userLevelGrant": "Servet/VIP seviyesi verme", "capability.teamView": "Ekibim", + "overview.currentApp": "Geçerli uygulama", "overview.account": "Harici yönetim hesabı", "overview.permissions": "Etkin yetkiler", "overview.enabled": "Etkin", "overview.notEnabled": "Etkin değil", + "users.title": "Kullanıcı yönetimi", "users.search": "Kullanıcı ID / özel ID / ad", "users.prettyId": "Özel ID", "users.countryGender": "Ülke / cinsiyet", "users.level": "Seviye", "users.levelSummary": "Servet {wealth} / Cazibe {charm} / VIP {vip}", "users.edit": "Düzenle", "users.grantLevel": "Seviye", "users.ban": "Yasakla", "users.updated": "Kullanıcı bilgileri güncellendi", "users.banned": "Kullanıcı yasaklandı", "users.levelGranted": "Kullanıcı seviyesi verildi", "users.operationFailed": "Kullanıcı işlemi başarısız", "users.editTitle": "Kullanıcıyı düzenle", "users.avatar": "Avatar", "users.name": "Kullanıcı adı", "users.gender": "Cinsiyet", "users.countryCode": "Ülke/bölge kodu", "users.notSet": "Ayarlanmadı", "users.genderMale": "Erkek", "users.genderFemale": "Kadın", "users.genderUnknown": "Bilinmiyor", "users.banTitle": "{user} kullanıcısını yasakla", "users.banDays": "Yasak süresi (gün)", "users.banForeverHint": "Kalıcı yasak için 0 girin", "users.banReason": "Yasak nedeni", "users.levelTitle": "{user} kullanıcısına seviye ver", "users.levelType": "Seviye türü", "users.wealthLevel": "Servet seviyesi", "users.charmLevel": "Cazibe seviyesi", "users.vipLevel": "VIP seviyesi", "users.validDays": "Geçerlilik (gün)", "users.validDaysMin": "Geçerlilik en az 1 gün olmalıdır", "users.grantReason": "Verme nedeni", + "bans.title": "Yasaklı hesap listesi", "bans.reason": "Yasak nedeni", "bans.unban": "Yasağı kaldır", "bans.unbanned": "Kullanıcı yasağı kaldırıldı", "bans.unbanFailed": "Kullanıcı yasağı kaldırılamadı", "bans.confirmTitle": "Kullanıcı yasağını kaldır", "bans.confirmContent": "{user} kullanıcısının yasağı kaldırılsın mı?", + "organization.title": "Organizasyon", "organization.agencies": "Ajans listesi", "organization.bds": "BD listesi", "organization.bdManagers": "BD Yöneticisi listesi", "organization.hosts": "Yayıncı listesi", "organization.managers": "Yönetici listesi", "organization.superAdmins": "Süper Yönetici listesi", "organization.team": "Ekibim", "organization.agency": "Ajans", "organization.owner": "Sahip", "organization.parentBd": "Üst BD", "organization.member": "Üye", "organization.country": "Ülke / bölge", "organization.userReference": "Kullanıcı {id}", "organization.agencyReference": "Ajans {id}", "organization.teamSearch": "Ajans ID / sahip kullanıcı ID / üst BD ID", "organization.noPermission": "Bu hesabın bu organizasyon listesine erişimi yok.", "organization.bdLeaderCount": "BD Liderleri: {count}", "organization.bdCount": "BD: {count}", "organization.agencyCount": "Ajanslar: {count}", "organization.truncated": "Ekip API sınırını aşıyor. Mevcut sonuçlar eksik.", + "rooms.title": "Oda yönetimi", "rooms.room": "Oda", "rooms.owner": "Sahip", "rooms.visibleRegion": "Görünür bölge", "rooms.search": "Oda ID / ad / sahip", "rooms.updated": "Oda bilgileri güncellendi", "rooms.updateFailed": "Oda güncellenemedi", "rooms.editTitle": "Odayı düzenle", "rooms.background": "Oda arka planı", "rooms.name": "Oda adı", "rooms.description": "Oda açıklaması", "rooms.visibleRegionId": "Görünür bölge ID", "rooms.allRegions": "Tümü", + "grants.title": "Kaynaklar ve özel ID'ler", "grants.grantResource": "Öğe ver", "grants.grantPrettyId": "Özel ID ver", "grants.availableResources": "Kullanılabilir kaynaklar", "grants.targetUser": "Hedef kullanıcı", "grants.resource": "Kaynak", "grants.quantity": "Miktar", "grants.resourceGranted": "Ayrıcalık öğesi verildi", "grants.prettyIdGranted": "Özel ID verildi", "grants.failed": "Verme işlemi başarısız", "grants.resourceTitle": "Ayrıcalık öğesi ver", "grants.prettyIdTitle": "Özel ID ver", "grants.target": "Kullanıcı ID / özel ID", "grants.currentTarget": "Kullanıcı ID / mevcut özel ID", "grants.prettyId": "Verilecek özel ID", "grants.validDays": "Geçerlilik (gün)", "grants.permanentHint": "Kalıcı geçerlilik için 0 girin", "grants.reason": "Verme nedeni", "grants.notice": "Göndermeden önce kullanıcı geçerli uygulamada doğrulanır ve sunucunun döndürdüğü kullanıcı ID kullanılır.", "grants.confirm": "Vermeyi onayla", + "banners.title": "Banner yönetimi", "banners.banner": "Banner", "banners.type": "Tür", "banners.platform": "Platform", "banners.sort": "Sıralama", "banners.create": "Banner oluştur", "banners.created": "Banner oluşturuldu", "banners.createFailed": "Banner oluşturulamadı", "banners.image": "Banner görseli", "banners.roomImage": "Oda içi küçük görsel", "banners.description": "Açıklama", "banners.jumpType": "Hedef türü", "banners.h5Param": "Parametre (H5 bağlantısı)", "banners.appParam": "Parametre (uygulama yönlendirme JSON'u)", "banners.displayScope": "Gösterim kapsamı", "banners.scopeHome": "Ana sayfa", "banners.scopeRoom": "Oda", "banners.scopeRecharge": "Yükleme", "banners.scopeMe": "Ben", "banners.regionId": "Bölge ID", "banners.countryCode": "Ülke kodu", "banners.countryCodeHint": "2–3 harf; tümü için boş bırakın", "banners.schedule": "Gösterim zamanı", + "upload.preview": "{label} önizlemesi", "upload.empty": "Görsel yok", "upload.uploading": "Yükleniyor", "upload.replace": "Görseli değiştir", "upload.select": "Görsel yükle", "upload.imageOnly": "Bir görsel dosyası seçin", "upload.sizeLimit": "Görseller 10 MB'ı aşamaz", "upload.missingUrl": "Yükleme yanıtında görsel URL'si yok", "upload.failed": "Görsel yüklenemedi", + "pagination.total": "{count} öğe", "pagination.previous": "Önceki", "pagination.next": "Sonraki", + "status.active": "Aktif", "status.enabled": "Etkin", "status.running": "Çalışıyor", "status.normal": "Normal", "status.unbanned": "Yasaksız", "status.banned": "Yasaklı", "status.disabled": "Devre dışı", "status.failed": "Başarısız", "status.blocked": "Engelli", "status.expired": "Süresi dolmuş", + "jump.appTarget": "Uygulama hedefi", "jump.target.wallet": "Cüzdan", "jump.target.room": "Belirli oda", "jump.target.room_random": "Rastgele oda", "jump.target.room_window": "Belirli odada pencere", "jump.target.room_random_window": "Rastgele odada pencere", "jump.target.room_game": "Belirli odada oyun", "jump.target.room_random_game": "Rastgele odada oyun", "jump.target.explore_game": "Explore oyunu H5", "jump.target.custom": "Özel JSON", "jump.roomId": "Oda ID", "jump.window": "Oda içi pencere", "jump.window.game_window": "Oyun penceresi", "jump.window.gift_panel": "Hediye paneli", "jump.window.red_packet_claim": "Kırmızı paket penceresi", "jump.window.wheel": "Çark penceresi", "jump.gameId": "Oyun ID", "jump.giftId": "Hediye ID", "jump.redPacketNo": "Kırmızı paket numarası", "jump.hint": "Uygulama yönlendirme parametreleri JSON olarak oluşturulur; özel parametreler için JSON'u doğrudan düzenleyebilirsiniz.", + "time.unlimited": "Sınırsız", "time.quickHour": "Son 1 saat", "time.quickDay": "Son 24 saat", "time.quickWeek": "Son 7 gün", "time.start": "Başlangıç zamanı", "time.end": "Bitiş zamanı", "time.startDate": "Başlangıç tarihi", "time.endDate": "Bitiş tarihi", "time.pending": "Seçilmedi", "time.startMark": "B", "time.endMark": "S", "time.previousMonth": "Önceki ay", "time.nextMonth": "Sonraki ay", "time.hour": "Saat", "time.minute": "Dakika", "time.clear": "Temizle", "time.apply": "Uygula", "time.endAfterStart": "Bitiş zamanı başlangıçtan sonra olmalıdır", "time.endDateAfterStart": "Bitiş tarihi başlangıçtan önce olamaz", "time.completeRange": "Tam aralığı seçin", "time.endLaterShort": "Bitiş başlangıçtan sonra olmalı", "time.endDateLaterShort": "Bitiş başlangıçtan önce olamaz", "time.durationDays": "{count} gün", "time.durationHours": "{count} saat", "time.durationMinutes": "{count} dakika", "time.duration": "{value}", "time.sameDay": "Aynı gün", "time.lessMinute": "1 dakikadan az", + "errors.loadFailed": "Veriler yüklenemedi", "errors.requestFailed": "İstek başarısız", "errors.unauthorized": "Oturumunuz sona erdi. Tekrar giriş yapın.", "errors.forbidden": "Bu işlemi yapma yetkiniz yok.", "errors.notFound": "İstenen kaynak bulunamadı.", "errors.conflict": "İşlem mevcut verilerle çakışıyor. Yenileyip tekrar deneyin.", "errors.tooLarge": "İstek çok büyük.", "errors.rateLimited": "Çok fazla istek gönderildi. Daha sonra tekrar deneyin.", "errors.server": "Hizmet geçici olarak kullanılamıyor. Daha sonra tekrar deneyin.", "errors.accountLocked": "Hesap geçici olarak kilitlendi. Daha sonra tekrar deneyin.", "errors.appInactive": "Bu hesaba bağlı uygulama etkin değil.", "errors.vipDisabled": "Geçerli uygulamanın VIP programı devre dışı", "errors.vipLevelInvalid": "Geçerli uygulama için yapılandırılmış bir VIP seviyesi seçin", "errors.organizationRoute": "Bu organizasyon listesi için güvenli veri uç noktası yok", "errors.regionId": "Görünür bölge ID güvenli ve negatif olmayan bir tam sayı olmalıdır", "errors.grantTarget": "Geçerli uygulamada hedef kullanıcı bulunamadı", "errors.bannerType": "Geçersiz banner türü", "errors.bannerPlatform": "Geçersiz banner platformu", "errors.bannerStatus": "Geçersiz banner durumu", "errors.bannerScope": "En az bir gösterim kapsamı seçin", "errors.bannerImage": "Bir banner görseli yükleyin", "errors.h5Url": "Geçerli bir H5 bağlantısı girin", "errors.h5Protocol": "H5 bağlantıları http veya https kullanmalıdır", "errors.appParamRequired": "Uygulama yönlendirme parametrelerini girin", "errors.appParamObject": "Uygulama parametreleri bir JSON nesnesi olmalıdır", "errors.appParamType": "Uygulama yönlendirme türü desteklenmiyor", "errors.appParamRoom": "Bu hedef room_id gerektirir", "errors.appParamGame": "Bu hedef game_id gerektirir", "errors.appParamWindow": "Bu hedef geçerli bir window gerektirir", "errors.appParamGameCode": "Explore oyunu H5 geçerli bir game_code gerektirir", "errors.roomBannerImage": "Oda kapsamı için oda içi küçük görsel yükleyin", "errors.countryCode": "Ülke kodu 2–3 harften oluşmalıdır", "errors.bannerRegion": "Geçersiz bölge ID", "errors.bannerSchedule": "Gösterim bitiş zamanı başlangıçtan sonra olmalıdır" +}; + +export const externalMessages = Object.freeze({ ar, en, tr, "zh-CN": zhCN }); + +export function normalizeExternalLocale(value) { + return EXTERNAL_LOCALES.includes(value) ? value : DEFAULT_EXTERNAL_LOCALE; +} + +export function externalDirection(locale) { + return locale === "ar" ? "rtl" : "ltr"; +} + +export function externalMessage(locale, key) { + return externalMessages[normalizeExternalLocale(locale)]?.[key] ?? en[key] ?? key; +} diff --git a/external-admin/src/i18n/messages.test.js b/external-admin/src/i18n/messages.test.js new file mode 100644 index 0000000..7fa4dbb --- /dev/null +++ b/external-admin/src/i18n/messages.test.js @@ -0,0 +1,27 @@ +import { describe, expect, test } from "vitest"; +import { translateExternalError } from "./errors.js"; +import { EXTERNAL_LANGUAGE_OPTIONS, externalDirection, externalMessage, externalMessages } from "./messages.js"; + +describe("external admin translations", () => { + test("keeps all four locale catalogs structurally complete", () => { + const englishKeys = Object.keys(externalMessages.en).sort(); + expect(englishKeys.length).toBeGreaterThan(250); + for (const locale of ["zh-CN", "ar", "tr"]) { + expect(Object.keys(externalMessages[locale]).sort()).toEqual(englishKeys); + } + }); + + test("uses native language names and Arabic RTL only", () => { + expect(EXTERNAL_LANGUAGE_OPTIONS.map((item) => item.label)).toEqual(["English", "简体中文", "العربية", "Türkçe"]); + expect(externalDirection("ar")).toBe("rtl"); + expect(externalDirection("en")).toBe("ltr"); + expect(externalDirection("zh-CN")).toBe("ltr"); + expect(externalDirection("tr")).toBe("ltr"); + }); + + test("never exposes an unknown backend-language message", () => { + const t = (key) => externalMessage("en", key); + expect(translateExternalError({ message: "服务端中文错误" }, t)).toBe("Request failed"); + expect(translateExternalError({ message: "服务端中文错误", status: 429 }, t)).toBe("Too many requests. Try again later."); + }); +}); diff --git a/external-admin/src/layout/ExternalAdminLayout.jsx b/external-admin/src/layout/ExternalAdminLayout.jsx index 59a5787..3ff4361 100644 --- a/external-admin/src/layout/ExternalAdminLayout.jsx +++ b/external-admin/src/layout/ExternalAdminLayout.jsx @@ -31,21 +31,23 @@ import { useMemo, useState } from "react"; import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom"; import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; import { hasAnyExternalCapability } from "../config/capabilities.js"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; +import { LanguageSwitcher } from "../i18n/LanguageSwitcher.jsx"; const drawerWidth = 248; const navigation = [ - { icon: DashboardOutlined, label: "权限总览", path: "/overview" }, - { capabilities: ["user:list", "user:update", "user:ban", "user-level:grant"], icon: PeopleOutlineOutlined, label: "用户管理", path: "/users" }, - { capabilities: ["user-ban:list", "user:unban"], icon: PersonOffOutlined, label: "封禁管理", path: "/bans" }, - { capabilities: ["host:list"], icon: BadgeOutlined, label: "主播列表", path: "/organization/hosts" }, - { capabilities: ["agency:list"], icon: ApartmentOutlined, label: "公会列表", path: "/organization/agencies" }, - { capabilities: ["bd:list"], icon: GroupsOutlined, label: "BD 列表", path: "/organization/bds" }, - { capabilities: ["bd-manager:list"], icon: SecurityOutlined, label: "BD Manager", path: "/organization/bd-leaders" }, - { capabilities: ["super-admin:list"], icon: SecurityOutlined, label: "Super Admin", path: "/organization/super-admins" }, - { capabilities: ["room:list", "room:update"], icon: MeetingRoomOutlined, label: "房间管理", path: "/rooms" }, - { capabilities: ["privilege:list", "privilege:grant", "pretty-id:grant", "user-title:grant", "room-background:grant"], icon: RedeemOutlined, label: "资源与靓号", path: "/grants" }, - { capabilities: ["banner:create"], icon: ImageOutlined, label: "Banner 管理", path: "/banners" }, - { capabilities: ["team:view"], icon: GroupsOutlined, label: "我的团队", path: "/organization/team" } + { icon: DashboardOutlined, labelKey: "nav.overview", path: "/overview" }, + { capabilities: ["user:list", "user:update", "user:ban", "user-level:grant"], icon: PeopleOutlineOutlined, labelKey: "nav.users", path: "/users" }, + { capabilities: ["user-ban:list", "user:unban"], icon: PersonOffOutlined, labelKey: "nav.bans", path: "/bans" }, + { capabilities: ["host:list"], icon: BadgeOutlined, labelKey: "nav.hosts", path: "/organization/hosts" }, + { capabilities: ["agency:list"], icon: ApartmentOutlined, labelKey: "nav.agencies", path: "/organization/agencies" }, + { capabilities: ["bd:list"], icon: GroupsOutlined, labelKey: "nav.bds", path: "/organization/bds" }, + { capabilities: ["bd-manager:list"], icon: SecurityOutlined, labelKey: "nav.bdManagers", path: "/organization/bd-leaders" }, + { capabilities: ["super-admin:list"], icon: SecurityOutlined, labelKey: "nav.superAdmins", path: "/organization/super-admins" }, + { capabilities: ["room:list", "room:update"], icon: MeetingRoomOutlined, labelKey: "nav.rooms", path: "/rooms" }, + { capabilities: ["privilege:list", "privilege:grant", "pretty-id:grant", "user-title:grant", "room-background:grant"], icon: RedeemOutlined, labelKey: "nav.grants", path: "/grants" }, + { capabilities: ["banner:create"], icon: ImageOutlined, labelKey: "nav.banners", path: "/banners" }, + { capabilities: ["team:view"], icon: GroupsOutlined, labelKey: "nav.team", path: "/organization/team" } ]; export function ExternalAdminLayout() { @@ -53,6 +55,7 @@ export function ExternalAdminLayout() { const mobile = useMediaQuery(theme.breakpoints.down("md")); const [drawerOpen, setDrawerOpen] = useState(false); const { logout, session } = useExternalAuth(); + const { direction, t } = useExternalI18n(); const location = useLocation(); const navigate = useNavigate(); const visibleNavigation = useMemo( @@ -71,7 +74,7 @@ export function ExternalAdminLayout() { HY - HYApp 外管 + {t("app.shortTitle")} {session?.appName || session?.appCode} @@ -88,7 +91,7 @@ export function ExternalAdminLayout() { onClick={() => setDrawerOpen(false)} > - + ); })} @@ -98,28 +101,39 @@ export function ExternalAdminLayout() { return ( - + - {mobile ? setDrawerOpen(true)}> : null} - {current?.label || "外管后台"} + {mobile ? setDrawerOpen(true)}> : null} + {current ? t(current.labelKey) : t("app.title")} - - {String(session?.account || "管").slice(0, 1).toUpperCase()} + + + {String(session?.account || "A").slice(0, 1).toUpperCase()} {!mobile ? ( - {session?.account} + {session?.account} {session?.appCode}{session?.displayName ? ` · ${session.displayName}` : ""} ) : null} - - + + - setDrawerOpen(false)} ModalProps={{ keepMounted: true }} sx={{ display: { md: "none" }, "& .MuiDrawer-paper": { width: drawerWidth } }}>{drawer} - {drawer} - + setDrawerOpen(false)} ModalProps={{ keepMounted: true }} sx={{ display: { md: "none" }, "& .MuiDrawer-paper": { width: drawerWidth } }}>{drawer} + {drawer} + diff --git a/external-admin/src/layout/ExternalAdminLayout.test.jsx b/external-admin/src/layout/ExternalAdminLayout.test.jsx new file mode 100644 index 0000000..525e67b --- /dev/null +++ b/external-admin/src/layout/ExternalAdminLayout.test.jsx @@ -0,0 +1,72 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { ExternalI18nProvider } from "../i18n/ExternalI18nProvider.jsx"; +import { EXTERNAL_LOCALE_STORAGE_KEY } from "../i18n/messages.js"; +import { ExternalAdminLayout } from "./ExternalAdminLayout.jsx"; + +const auth = vi.hoisted(() => ({ + logout: vi.fn(), + session: { + account: "fami-manager", + appCode: "fami", + appName: "Fami", + capabilities: ["user:list", "room:list"] + } +})); +const viewport = vi.hoisted(() => ({ mobile: false })); + +vi.mock("../auth/ExternalAuthProvider.jsx", () => ({ useExternalAuth: () => auth })); +vi.mock("@mui/material/useMediaQuery", () => ({ default: () => viewport.mobile })); + +describe("ExternalAdminLayout RTL", () => { + beforeEach(() => { + localStorage.setItem(EXTERNAL_LOCALE_STORAGE_KEY, "ar"); + viewport.mobile = false; + }); + + test("opens the mobile navigation from the right in Arabic", async () => { + const user = userEvent.setup(); + viewport.mobile = true; + renderLayout(); + + await user.click(screen.getByRole("button", { name: "فتح القائمة" })); + + const openTemporaryDrawer = Array.from(document.querySelectorAll(".MuiDrawer-paper")).find((node) => node.closest(".MuiModal-root")); + expect(openTemporaryDrawer).toBeInTheDocument(); + expect(openTemporaryDrawer.parentElement).not.toHaveStyle({ visibility: "hidden" }); + expect(screen.getAllByRole("link", { name: "ملخص الصلاحيات" }).length).toBeGreaterThan(0); + }); + + test("places the desktop navigation drawer on the right for Arabic", () => { + renderLayout(); + + expect(document.documentElement).toHaveAttribute("dir", "rtl"); + expect(document.documentElement).toHaveAttribute("lang", "ar"); + const permanentDrawer = document.querySelectorAll(".MuiDrawer-paper")[0]; + const generatedClass = Array.from(permanentDrawer.classList).find((className) => className.startsWith("css-")); + const drawerRule = Array.from(document.styleSheets) + .flatMap((sheet) => Array.from(sheet.cssRules || [])) + .map((rule) => rule.cssText) + .find((cssText) => cssText.includes(`.${generatedClass}`)); + expect(drawerRule).toContain("right: 0px"); + expect(drawerRule).toContain("border-left:"); + expect(screen.getAllByText("ملخص الصلاحيات").length).toBeGreaterThan(0); + expect(screen.getByRole("combobox", { name: "اللغة" })).toHaveTextContent("العربية"); + }); +}); + +function renderLayout() { + return render( + + + + }> + overview content} path="/overview" /> + + + + + ); +} diff --git a/external-admin/src/main.jsx b/external-admin/src/main.jsx index 353822a..7cad114 100644 --- a/external-admin/src/main.jsx +++ b/external-admin/src/main.jsx @@ -1,27 +1,47 @@ +import { CacheProvider } from "@emotion/react"; import CssBaseline from "@mui/material/CssBaseline"; -import { ThemeProvider } from "@mui/material/styles"; -import React from "react"; +import { createTheme, ThemeProvider } from "@mui/material/styles"; +import React, { useMemo } from "react"; import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import { ToastProvider } from "@/shared/ui/ToastProvider.jsx"; import { theme } from "@/theme.js"; import { ExternalAdminApp } from "./ExternalAdminApp.jsx"; import { ExternalAuthProvider } from "./auth/ExternalAuthProvider.jsx"; +import { ExternalI18nProvider, useExternalI18n } from "./i18n/ExternalI18nProvider.jsx"; +import { externalEmotionCache } from "./i18n/emotionCache.js"; import "@/styles/tokens.css"; import "@/styles/shared-ui.css"; import "./styles/index.css"; createRoot(document.getElementById("external-admin-root")).render( - - - - - - - - - - + + + ); + +function ExternalThemeRoot() { + const { direction } = useExternalI18n(); + // A direction-aware MUI theme keeps inputs, menus and dialog controls consistent with the Arabic document direction. + const externalTheme = useMemo(() => createTheme(theme, { + direction, + typography: { fontFamily: 'Inter, "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif' } + }), [direction]); + + return ( + + + + + + + + + + + + + ); +} diff --git a/external-admin/src/pages/BannersPage.jsx b/external-admin/src/pages/BannersPage.jsx index 5c0144d..29ec076 100644 --- a/external-admin/src/pages/BannersPage.jsx +++ b/external-admin/src/pages/BannersPage.jsx @@ -7,40 +7,43 @@ import { useCallback, useMemo, useState } from "react"; import { useToast } from "@/shared/ui/ToastProvider.jsx"; import { createBanner, listBanners } from "../api/business.js"; import { BannerCreateDialog } from "../components/BannerCreateDialog.jsx"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; +import { translateExternalError } from "../i18n/errors.js"; import { ExternalPage, ExternalPageState, PagePagination, ResponsiveDataList, StatusChip } from "../shared/PageComponents.jsx"; import { usePagedResource } from "../shared/usePagedResource.js"; export default function BannersPage() { const { showToast } = useToast(); + const { t } = useExternalI18n(); const [open, setOpen] = useState(false); const [page, setPage] = useState(1); const [submitting, setSubmitting] = useState(false); const load = useCallback(() => listBanners({ page, page_size: 20 }), [page]); const { data, error, loading, reload } = usePagedResource(load); const columns = useMemo(() => [ - { key: "banner", label: "Banner", render: (item) => {item.coverUrl ? : null}{item.description || "-"}ID {item.id || "-"} }, - { key: "type", label: "类型", render: (item) => item.bannerType || "-" }, - { key: "platform", label: "平台", render: (item) => item.platform || "all" }, - { key: "sort", label: "排序", render: (item) => item.sortOrder }, - { key: "status", label: "状态", render: (item) => } - ], []); + { key: "banner", label: t("banners.banner"), render: (item) => {item.coverUrl ? : null}{item.description || "-"}ID {item.id || "-"} }, + { key: "type", label: t("banners.type"), render: (item) => item.bannerType || "-" }, + { key: "platform", label: t("banners.platform"), render: (item) => item.platform || t("common.all") }, + { key: "sort", label: t("banners.sort"), render: (item) => item.sortOrder }, + { key: "status", label: t("common.status"), render: (item) => } + ], [t]); const submit = async (form) => { setSubmitting(true); try { await createBanner(form); - showToast("Banner 已创建", "success"); + showToast(t("banners.created"), "success"); setOpen(false); reload(); } catch (requestError) { - showToast(requestError.message || "Banner 创建失败", "error"); + showToast(translateExternalError(requestError, t, "banners.createFailed"), "error"); } finally { setSubmitting(false); } }; return ( - setOpen(true)} startIcon={} variant="contained">创建 Banner} title="Banner 管理"> + setOpen(true)} startIcon={} variant="contained">{t("banners.create")}} title={t("banners.title")}> {!loading && !error ? <> item.id} /> : null} setOpen(false)} onSubmit={submit} /> diff --git a/external-admin/src/pages/BansPage.jsx b/external-admin/src/pages/BansPage.jsx index 773edfb..1efaf84 100644 --- a/external-admin/src/pages/BansPage.jsx +++ b/external-admin/src/pages/BansPage.jsx @@ -10,6 +10,8 @@ import { useToast } from "@/shared/ui/ToastProvider.jsx"; import { listBannedUsers, unbanUser } from "../api/business.js"; import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; import { hasExternalCapability } from "../config/capabilities.js"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; +import { translateExternalError } from "../i18n/errors.js"; import { ConfirmDialog } from "../shared/ConfirmDialog.jsx"; import { ExternalPage, ExternalPageState, PagePagination, ResponsiveDataList, StatusChip } from "../shared/PageComponents.jsx"; import { usePagedResource } from "../shared/usePagedResource.js"; @@ -17,6 +19,7 @@ import { usePagedResource } from "../shared/usePagedResource.js"; export default function BansPage() { const { session } = useExternalAuth(); const { showToast } = useToast(); + const { t } = useExternalI18n(); const [keyword, setKeyword] = useState(""); const [query, setQuery] = useState({ keyword: "", page: 1 }); const [target, setTarget] = useState(null); @@ -29,33 +32,33 @@ export default function BansPage() { setSubmitting(true); try { await unbanUser(target.id, { reason: "external-admin manual unban" }); - showToast("用户已解除封禁", "success"); + showToast(t("bans.unbanned"), "success"); setTarget(null); reload(); } catch (requestError) { - showToast(requestError.message || "解除封禁失败", "error"); + showToast(translateExternalError(requestError, t, "bans.unbanFailed"), "error"); } finally { setSubmitting(false); } }; const columns = useMemo(() => [ - { key: "user", label: "用户", render: (user) => {user.username.slice(0, 1)}{user.username || "-"}ID {user.id} }, - { key: "prettyId", label: "短 ID(靓号)", render: (user) => user.prettyId || "-" }, - { key: "reason", label: "封禁原因", render: (user) => user.reason || "-" }, - { key: "status", label: "状态", render: () => }, - { key: "actions", label: "操作", render: (user) => canUnban ? : "-" } - ], [canUnban]); + { key: "user", label: t("common.user"), render: (user) => {(user.username || "-").slice(0, 1)}{user.username || "-"}ID {user.id} }, + { key: "prettyId", label: t("users.prettyId"), render: (user) => user.prettyId || "-" }, + { key: "reason", label: t("bans.reason"), render: (user) => user.reason || "-" }, + { key: "status", label: t("common.status"), render: () => }, + { key: "actions", label: t("common.actions"), render: (user) => canUnban ? : "-" } + ], [canUnban, t]); return ( - + { event.preventDefault(); setQuery({ keyword, page: 1 }); }}> - setKeyword(event.target.value)} /> - + setKeyword(event.target.value)} /> + {!loading && !error ? <> item.id} /> setQuery({ ...query, page })} /> : null} - setTarget(null)} onConfirm={handleUnban} /> + setTarget(null)} onConfirm={handleUnban} /> ); } diff --git a/external-admin/src/pages/ChangePasswordPage.jsx b/external-admin/src/pages/ChangePasswordPage.jsx index 36d1eb9..ca8c517 100644 --- a/external-admin/src/pages/ChangePasswordPage.jsx +++ b/external-admin/src/pages/ChangePasswordPage.jsx @@ -11,9 +11,13 @@ import Typography from "@mui/material/Typography"; import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; +import { LanguageSwitcher } from "../i18n/LanguageSwitcher.jsx"; +import { translateExternalError } from "../i18n/errors.js"; export default function ChangePasswordPage() { const { changePassword, logout, session } = useExternalAuth(); + const { t } = useExternalI18n(); const [form, setForm] = useState({ confirmPassword: "", newPassword: "", oldPassword: "" }); const [error, setError] = useState(""); const [submitting, setSubmitting] = useState(false); @@ -22,15 +26,15 @@ export default function ChangePasswordPage() { const submit = async (event) => { event.preventDefault(); if (!form.newPassword.trim()) { - setError("新密码不能为空或全为空白字符"); + setError(t("auth.passwordWhitespace")); return; } if (form.newPassword.length < 8) { - setError("新密码至少 8 个字符"); + setError(t("auth.passwordMinError")); return; } if (form.newPassword !== form.confirmPassword) { - setError("两次输入的新密码不一致"); + setError(t("auth.passwordMismatch")); return; } setSubmitting(true); @@ -39,7 +43,7 @@ export default function ChangePasswordPage() { await changePassword({ newPassword: form.newPassword, oldPassword: form.oldPassword }); navigate("/overview", { replace: true }); } catch (requestError) { - setError(requestError.message || "密码修改失败"); + setError(translateExternalError(requestError, t, "auth.passwordChangedFailed")); } finally { setSubmitting(false); } @@ -54,13 +58,14 @@ export default function ChangePasswordPage() { - 修改外管密码{session?.passwordChangeRequired ? "首次登录必须修改密码" : session?.account} + + {t("auth.changePassword")}{session?.passwordChangeRequired ? t("auth.firstLoginChange") : session?.account} {error ? {error} : null} - setForm({ ...form, oldPassword: event.target.value })} /> - setForm({ ...form, newPassword: event.target.value })} /> - setForm({ ...form, confirmPassword: event.target.value })} /> - + setForm({ ...form, oldPassword: event.target.value })} /> + setForm({ ...form, newPassword: event.target.value })} /> + setForm({ ...form, confirmPassword: event.target.value })} /> + diff --git a/external-admin/src/pages/ChangePasswordPage.test.jsx b/external-admin/src/pages/ChangePasswordPage.test.jsx index a724534..51351d7 100644 --- a/external-admin/src/pages/ChangePasswordPage.test.jsx +++ b/external-admin/src/pages/ChangePasswordPage.test.jsx @@ -26,9 +26,9 @@ describe("ChangePasswordPage", () => { fireEvent.change(container.querySelector('input[autocomplete="current-password"]'), { target: { value: "old-password" } }); fireEvent.change(newPassword, { target: { value: " " } }); fireEvent.change(confirmPassword, { target: { value: " " } }); - await user.click(screen.getByRole("button", { name: "保存密码" })); + await user.click(screen.getByRole("button", { name: "Save password" })); - expect(screen.getByText("新密码不能为空或全为空白字符")).toBeInTheDocument(); + expect(screen.getByText("The new password cannot be empty or contain only whitespace")).toBeInTheDocument(); expect(auth.changePassword).not.toHaveBeenCalled(); }); @@ -40,7 +40,7 @@ describe("ChangePasswordPage", () => { fireEvent.change(container.querySelector('input[autocomplete="current-password"]'), { target: { value: " current-password " } }); fireEvent.change(newPassword, { target: { value: " pass word " } }); fireEvent.change(confirmPassword, { target: { value: " pass word " } }); - await user.click(screen.getByRole("button", { name: "保存密码" })); + await user.click(screen.getByRole("button", { name: "Save password" })); expect(auth.changePassword).toHaveBeenCalledWith({ newPassword: " pass word ", oldPassword: " current-password " }); }); diff --git a/external-admin/src/pages/GrantsPage.jsx b/external-admin/src/pages/GrantsPage.jsx index 4fe3522..9f33fbd 100644 --- a/external-admin/src/pages/GrantsPage.jsx +++ b/external-admin/src/pages/GrantsPage.jsx @@ -11,12 +11,15 @@ import { grantPrettyId, grantResource, listResourceGrants, listResources, lookup import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; import { PrettyIdGrantDialog, ResourceGrantDialog } from "../components/GrantDialogs.jsx"; import { hasAnyExternalCapability, hasExternalCapability } from "../config/capabilities.js"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; +import { externalUiError, translateExternalError } from "../i18n/errors.js"; import { ExternalPage, ExternalPageState, PagePagination, ResponsiveDataList, StatusChip } from "../shared/PageComponents.jsx"; import { usePagedResource } from "../shared/usePagedResource.js"; export default function GrantsPage() { const { session } = useExternalAuth(); const { showToast } = useToast(); + const { t } = useExternalI18n(); const [dialog, setDialog] = useState(""); const [page, setPage] = useState(1); const [submitting, setSubmitting] = useState(false); @@ -28,15 +31,15 @@ export default function GrantsPage() { const resourcesState = usePagedResource(loadResources); const grantsState = usePagedResource(loadGrants); - const execute = async (request, successMessage) => { + const execute = async (request, successKey) => { setSubmitting(true); try { await request(); - showToast(successMessage, "success"); + showToast(t(successKey), "success"); setDialog(""); grantsState.reload(); } catch (requestError) { - showToast(requestError.message || "发放失败", "error"); + showToast(translateExternalError(requestError, t, "grants.failed"), "error"); } finally { setSubmitting(false); } @@ -45,7 +48,7 @@ export default function GrantsPage() { const resolveTarget = async (input) => { const target = await lookupGrantTarget(input); if (!target.id) { - throw new Error("未找到当前 App 内的目标用户"); + throw externalUiError("errors.grantTarget"); } return target; }; @@ -60,7 +63,7 @@ export default function GrantsPage() { resourceId: Number(form.resourceId), targetUserId: target.id }); - }, "特权道具已发放"); + }, "grants.resourceGranted"); const submitPrettyId = (form) => execute(async () => { const target = await resolveTarget(form.target); @@ -70,27 +73,27 @@ export default function GrantsPage() { reason: form.reason.trim(), target_user_id: target.id }); - }, "靓号已下发"); + }, "grants.prettyIdGranted"); const columns = useMemo(() => [ - { key: "target", label: "目标用户", render: (item) => String(item.targetUserId || item.target_user_id || item.userId || item.user_id || "-") }, - { key: "resource", label: "资源", render: (item) => String(item.resourceName || item.resource_name || item.resourceId || item.resource_id || item.subject || "-") }, - { key: "quantity", label: "数量", render: (item) => String(item.quantity ?? item.amount ?? "-") }, - { key: "status", label: "状态", render: (item) => }, - { key: "reason", label: "原因", render: (item) => String(item.reason || "-") } - ], []); + { key: "target", label: t("grants.targetUser"), render: (item) => String(item.targetUserId || item.target_user_id || item.userId || item.user_id || "-") }, + { key: "resource", label: t("grants.resource"), render: (item) => String(item.resourceName || item.resource_name || item.resourceId || item.resource_id || item.subject || "-") }, + { key: "quantity", label: t("grants.quantity"), render: (item) => String(item.quantity ?? item.amount ?? "-") }, + { key: "status", label: t("common.status"), render: (item) => }, + { key: "reason", label: t("common.reason"), render: (item) => String(item.reason || "-") } + ], [t]); return ( {canGrant ? : null}{canGrantPrettyId ? : null}} - title="资源与靓号" + actions={<>{canGrant ? : null}{canGrantPrettyId ? : null}} + title={t("grants.title")} > {canGrant ? ( - 可发放资源 + {t("grants.availableResources")} - {resourcesState.data.items.slice(0, 12).map((resource) => {resource.name})} - {!resourcesState.loading && !resourcesState.data.items.length ? 当前无数据 : null} + {resourcesState.data.items.slice(0, 12).map((resource) => R{resource.name})} + {!resourcesState.loading && !resourcesState.data.items.length ? {t("common.noData")} : null} ) : null} diff --git a/external-admin/src/pages/LoginPage.jsx b/external-admin/src/pages/LoginPage.jsx index ba44b37..b58ff84 100644 --- a/external-admin/src/pages/LoginPage.jsx +++ b/external-admin/src/pages/LoginPage.jsx @@ -8,53 +8,28 @@ import Button from "@mui/material/Button"; import Card from "@mui/material/Card"; import CardContent from "@mui/material/CardContent"; import CircularProgress from "@mui/material/CircularProgress"; -import FormControl from "@mui/material/FormControl"; import IconButton from "@mui/material/IconButton"; import InputAdornment from "@mui/material/InputAdornment"; -import InputLabel from "@mui/material/InputLabel"; -import MenuItem from "@mui/material/MenuItem"; -import Select from "@mui/material/Select"; import Stack from "@mui/material/Stack"; import TextField from "@mui/material/TextField"; import Typography from "@mui/material/Typography"; -import { useCallback, useEffect, useState } from "react"; -import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; -import { listExternalApps } from "../api/auth.js"; +import { useEffect, useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; +import { LanguageSwitcher } from "../i18n/LanguageSwitcher.jsx"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; +import { translateExternalError } from "../i18n/errors.js"; export default function LoginPage() { const { initializing, login, session } = useExternalAuth(); - const [apps, setApps] = useState([]); - const [appsError, setAppsError] = useState(""); - const [appsLoading, setAppsLoading] = useState(true); - const [form, setForm] = useState({ account: "", appCode: "", password: "" }); + const { t } = useExternalI18n(); + const [form, setForm] = useState({ account: "", password: "" }); const [error, setError] = useState(""); const [submitting, setSubmitting] = useState(false); const [showPassword, setShowPassword] = useState(false); - const [searchParams] = useSearchParams(); const navigate = useNavigate(); const location = useLocation(); - const loadApps = useCallback(async () => { - setAppsLoading(true); - setAppsError(""); - try { - const items = await listExternalApps(); - setApps(items); - const requestedApp = searchParams.get("app") || ""; - const preferredApp = items.find((item) => item.appCode === requestedApp)?.appCode || items[0]?.appCode || requestedApp; - setForm((current) => ({ ...current, appCode: current.appCode || preferredApp })); - } catch (requestError) { - setAppsError(requestError.message || "App 列表加载失败"); - } finally { - setAppsLoading(false); - } - }, [searchParams]); - - useEffect(() => { - loadApps(); - }, [loadApps]); - useEffect(() => { if (!initializing && session) { const destination = session.passwordChangeRequired ? "/change-password" : location.state?.from?.pathname || "/overview"; @@ -64,8 +39,8 @@ export default function LoginPage() { const handleSubmit = async (event) => { event.preventDefault(); - if (!form.appCode || !form.account.trim() || !form.password) { - setError("请选择 App 并填写账号、密码"); + if (!form.account.trim() || !form.password) { + setError(t("auth.fillAccountPassword")); return; } setSubmitting(true); @@ -74,7 +49,8 @@ export default function LoginPage() { const nextSession = await login(form); navigate(nextSession.passwordChangeRequired ? "/change-password" : "/overview", { replace: true }); } catch (requestError) { - setError(requestError.message || "登录失败"); + // Every login 401 uses one generic credential message so unknown, disabled and locked accounts cannot be distinguished. + setError(requestError?.status === 401 ? t("auth.invalidCredentials") : translateExternalError(requestError, t, "auth.loginFailed")); } finally { setSubmitting(false); } @@ -84,36 +60,26 @@ export default function LoginPage() { - + + - 外管后台登录 + {t("app.title")} {error ? {error} : null} - {appsError ? 重试} severity="error">{appsError} : null} - {apps.length || appsLoading ? ( - - App - - - ) : ( - setForm({ ...form, appCode: event.target.value.trim() })} /> - )} - setForm({ ...form, account: event.target.value })} /> + setForm({ ...form, account: event.target.value })} /> setForm({ ...form, password: event.target.value })} - slotProps={{ input: { endAdornment: setShowPassword((current) => !current)}>{showPassword ? : } } }} + slotProps={{ input: { endAdornment: setShowPassword((current) => !current)}>{showPassword ? : } } }} /> - diff --git a/external-admin/src/pages/LoginPage.test.jsx b/external-admin/src/pages/LoginPage.test.jsx new file mode 100644 index 0000000..bac99f1 --- /dev/null +++ b/external-admin/src/pages/LoginPage.test.jsx @@ -0,0 +1,49 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { ExternalI18nProvider } from "../i18n/ExternalI18nProvider.jsx"; +import { EXTERNAL_LOCALE_STORAGE_KEY } from "../i18n/messages.js"; +import LoginPage from "./LoginPage.jsx"; + +const auth = vi.hoisted(() => ({ initializing: false, login: vi.fn(), session: null })); + +vi.mock("../auth/ExternalAuthProvider.jsx", () => ({ useExternalAuth: () => auth })); + +describe("LoginPage", () => { + beforeEach(() => { + localStorage.removeItem(EXTERNAL_LOCALE_STORAGE_KEY); + auth.login.mockReset().mockResolvedValue({ appCode: "fami", passwordChangeRequired: false }); + }); + + test("defaults to English, has no App field and submits only account and password", async () => { + const user = userEvent.setup(); + const { container } = renderLogin(); + + expect(screen.getByRole("heading", { name: "HYApp External Admin" })).toBeInTheDocument(); + expect(screen.queryByRole("combobox", { name: "App" })).not.toBeInTheDocument(); + await user.type(screen.getByRole("textbox", { name: "External admin account" }), "fami-manager"); + await user.type(container.querySelector('input[autocomplete="current-password"]'), "secret-password"); + await user.click(screen.getByRole("button", { name: "Sign in" })); + + expect(auth.login).toHaveBeenCalledWith({ account: "fami-manager", password: "secret-password" }); + }); + + test("localizes a numeric login 401 without exposing the backend message", async () => { + const user = userEvent.setup(); + auth.login.mockRejectedValueOnce({ code: 40100, message: "账号已禁用", status: 401 }); + const { container } = renderLogin(); + + await user.type(screen.getByRole("textbox", { name: "External admin account" }), "unknown-account"); + await user.type(container.querySelector('input[autocomplete="current-password"]'), "bad-password"); + await user.click(screen.getByRole("button", { name: "Sign in" })); + + expect(await screen.findByText("Incorrect account or password")).toBeInTheDocument(); + expect(screen.queryByText("账号已禁用")).not.toBeInTheDocument(); + expect(screen.queryByText("Your session has expired. Sign in again.")).not.toBeInTheDocument(); + }); +}); + +function renderLogin() { + return render(); +} diff --git a/external-admin/src/pages/OrganizationPage.jsx b/external-admin/src/pages/OrganizationPage.jsx index 57eb724..6f03ee5 100644 --- a/external-admin/src/pages/OrganizationPage.jsx +++ b/external-admin/src/pages/OrganizationPage.jsx @@ -10,58 +10,60 @@ import { useParams } from "react-router-dom"; import { listOrganizations } from "../api/business.js"; import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; import { hasExternalCapability } from "../config/capabilities.js"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; import { ExternalPage, ExternalPageState, PagePagination, ResponsiveDataList, StatusChip } from "../shared/PageComponents.jsx"; import { usePagedResource } from "../shared/usePagedResource.js"; const organizationKinds = { - agencies: { capability: "agency:list", title: "公会列表" }, - bds: { capability: "bd:list", title: "BD 列表" }, - "bd-leaders": { capability: "bd-manager:list", title: "BD Manager 列表" }, - hosts: { capability: "host:list", title: "主播列表" }, - managers: { capability: "bd-manager:list", title: "管理员列表" }, - "super-admins": { capability: "super-admin:list", title: "Super Admin 列表" }, - team: { capability: "team:view", title: "我的团队" } + agencies: { capability: "agency:list", titleKey: "organization.agencies" }, + bds: { capability: "bd:list", titleKey: "organization.bds" }, + "bd-leaders": { capability: "bd-manager:list", titleKey: "organization.bdManagers" }, + hosts: { capability: "host:list", titleKey: "organization.hosts" }, + managers: { capability: "bd-manager:list", titleKey: "organization.managers" }, + "super-admins": { capability: "super-admin:list", titleKey: "organization.superAdmins" }, + team: { capability: "team:view", titleKey: "organization.team" } }; export default function OrganizationPage() { const { kind = "hosts" } = useParams(); const config = organizationKinds[kind]; const { session } = useExternalAuth(); + const { t } = useExternalI18n(); const [keyword, setKeyword] = useState(""); const [query, setQuery] = useState({ keyword: "", page: 1 }); const allowed = Boolean(config && hasExternalCapability(session, config.capability)); const load = useCallback(() => allowed ? listOrganizations(kind, { keyword: query.keyword, page: query.page, page_size: 20 }) : Promise.resolve({ items: [], page: 1, pageSize: 20, total: 0 }), [allowed, kind, query]); const { data, error, loading, reload } = usePagedResource(load); const columns = useMemo(() => kind === "team" ? [ - { key: "agency", label: "公会", render: (item) => 公会 {item.id || "-"}Owner {item.userId || "-"} }, - { key: "parent", label: "上级 BD", render: (item) => item.parentBdUserId || "-" }, - { key: "status", label: "状态", render: (item) => } + { key: "agency", label: t("organization.agency"), render: (item) => {t("organization.agencyReference", { id: item.id || "-" })}{t("organization.owner")} {item.userId || "-"} }, + { key: "parent", label: t("organization.parentBd"), render: (item) => item.parentBdUserId || "-" }, + { key: "status", label: t("common.status"), render: (item) => } ] : [ - { key: "identity", label: "成员", render: (item) => {(item.name || item.username || "-").slice(0, 1)}{item.name || item.username || "-"}ID {item.id || "-"}{item.userId && item.userId !== item.id ? ` / 用户 ${item.userId}` : ""} }, - { key: "prettyId", label: "短 ID(靓号)", render: (item) => item.prettyId || "-" }, - { key: "country", label: "国家/地区", render: (item) => item.country || "-" }, - { key: "status", label: "状态", render: (item) => } - ], [kind]); + { key: "identity", label: t("organization.member"), render: (item) => {(item.name || item.username || "-").slice(0, 1)}{item.name || item.username || "-"}ID {item.id || "-"}{item.userId && item.userId !== item.id ? ` / ${t("organization.userReference", { id: item.userId })}` : ""} }, + { key: "prettyId", label: t("users.prettyId"), render: (item) => item.prettyId || "-" }, + { key: "country", label: t("organization.country"), render: (item) => item.country || "-" }, + { key: "status", label: t("common.status"), render: (item) => } + ], [kind, t]); if (!config || !allowed) { - return 当前外管账号没有访问该组织列表的权限。; + return {t("organization.noPermission")}; } return ( - + { event.preventDefault(); setQuery({ keyword, page: 1 }); }}> - setKeyword(event.target.value)} /> - + setKeyword(event.target.value)} /> + {kind === "team" && !loading && !error ? ( - BD Leader:{data.totalBdLeaders || 0} - BD:{data.totalBds || 0} - 公会:{data.total || data.items.length} + {t("organization.bdLeaderCount", { count: data.totalBdLeaders || 0 })} + {t("organization.bdCount", { count: data.totalBds || 0 })} + {t("organization.agencyCount", { count: data.total || data.items.length })} - {data.truncated ? 团队规模超过接口上限,当前结果不完整 : null} + {data.truncated ? {t("organization.truncated")} : null} ) : null} {!loading && !error ? <> item.id || index} /> setQuery({ ...query, page })} /> : null} diff --git a/external-admin/src/pages/OverviewPage.jsx b/external-admin/src/pages/OverviewPage.jsx index c2bfc3a..965b803 100644 --- a/external-admin/src/pages/OverviewPage.jsx +++ b/external-admin/src/pages/OverviewPage.jsx @@ -10,19 +10,21 @@ import Typography from "@mui/material/Typography"; import { Link } from "react-router-dom"; import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; import { EXTERNAL_CAPABILITIES, hasExternalCapability } from "../config/capabilities.js"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; import { ExternalPage } from "../shared/PageComponents.jsx"; export default function OverviewPage() { const { session } = useExternalAuth(); + const { t } = useExternalI18n(); return ( - + - 当前 App{session.appName || session.appCode}({session.appCode}) - 外管账号{session.account} - 已开通权限{EXTERNAL_CAPABILITIES.filter((item) => hasExternalCapability(session, item.code)).length} / {EXTERNAL_CAPABILITIES.length} + {t("overview.currentApp")}{session.appName || session.appCode} ({session.appCode}) + {t("overview.account")}{session.account} + {t("overview.permissions")}{EXTERNAL_CAPABILITIES.filter((item) => hasExternalCapability(session, item.code)).length} / {EXTERNAL_CAPABILITIES.length} @@ -33,8 +35,8 @@ export default function OverviewPage() { {enabled ? : } - {item.label} - + {t(item.labelKey)} + ); diff --git a/external-admin/src/pages/OverviewPage.test.jsx b/external-admin/src/pages/OverviewPage.test.jsx index 90540d9..07827d5 100644 --- a/external-admin/src/pages/OverviewPage.test.jsx +++ b/external-admin/src/pages/OverviewPage.test.jsx @@ -22,10 +22,10 @@ describe("OverviewPage", () => { render(); expect(EXTERNAL_CAPABILITIES).toHaveLength(20); - expect(screen.getAllByText("已开通")).toHaveLength(1); - expect(screen.getAllByText("未开通")).toHaveLength(19); - expect(screen.getByText("用户列表").closest("a")).toHaveAttribute("href", "/users"); - expect(screen.getByText("用户信息编辑").closest("a")).toBeNull(); - expect(screen.getByText("我的团队")).toBeInTheDocument(); + expect(screen.getAllByText("Enabled")).toHaveLength(1); + expect(screen.getAllByText("Not enabled")).toHaveLength(19); + expect(screen.getByText("User list").closest("a")).toHaveAttribute("href", "/users"); + expect(screen.getByText("Edit user information").closest("a")).toBeNull(); + expect(screen.getByText("My team")).toBeInTheDocument(); }); }); diff --git a/external-admin/src/pages/RoomsPage.jsx b/external-admin/src/pages/RoomsPage.jsx index 706fecd..63c883e 100644 --- a/external-admin/src/pages/RoomsPage.jsx +++ b/external-admin/src/pages/RoomsPage.jsx @@ -11,12 +11,15 @@ import { listRooms, updateRoom } from "../api/business.js"; import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; import { RoomEditDialog } from "../components/RoomEditDialog.jsx"; import { hasExternalCapability } from "../config/capabilities.js"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; +import { externalUiError, translateExternalError } from "../i18n/errors.js"; import { ExternalPage, ExternalPageState, PagePagination, ResponsiveDataList, StatusChip } from "../shared/PageComponents.jsx"; import { usePagedResource } from "../shared/usePagedResource.js"; export default function RoomsPage() { const { session } = useExternalAuth(); const { showToast } = useToast(); + const { t } = useExternalI18n(); const [keyword, setKeyword] = useState(""); const [query, setQuery] = useState({ keyword: "", page: 1 }); const [room, setRoom] = useState(null); @@ -26,12 +29,12 @@ export default function RoomsPage() { const canEdit = hasExternalCapability(session, "room:update"); const columns = useMemo(() => [ - { key: "room", label: "房间", render: (item) => {item.title || "-"}ID {item.id} }, - { key: "owner", label: "房主", render: (item) => item.ownerName || "-" }, - { key: "region", label: "可见区域", render: (item) => item.visibleRegionId || "全部" }, - { key: "status", label: "状态", render: (item) => }, - { key: "actions", label: "操作", render: (item) => canEdit ? : "-" } - ], [canEdit]); + { key: "room", label: t("rooms.room"), render: (item) => R{item.title || "-"}ID {item.id} }, + { key: "owner", label: t("rooms.owner"), render: (item) => item.ownerName || "-" }, + { key: "region", label: t("rooms.visibleRegion"), render: (item) => item.visibleRegionId || t("rooms.allRegions") }, + { key: "status", label: t("common.status"), render: (item) => }, + { key: "actions", label: t("common.actions"), render: (item) => canEdit ? : "-" } + ], [canEdit, t]); const submit = async (form) => { setSubmitting(true); @@ -39,22 +42,22 @@ export default function RoomsPage() { const regionText = String(form.visibleRegionId || "").trim(); const visibleRegionId = regionText ? Number(regionText) : 0; if (!Number.isSafeInteger(visibleRegionId) || visibleRegionId < 0) { - throw new Error("可见区域 ID 必须是安全的正整数"); + throw externalUiError("errors.regionId"); } await updateRoom(room.id, { ...form, visibleRegionId }); - showToast("房间信息已更新", "success"); + showToast(t("rooms.updated"), "success"); setRoom(null); reload(); } catch (requestError) { - showToast(requestError.message || "房间更新失败", "error"); + showToast(translateExternalError(requestError, t, "rooms.updateFailed"), "error"); } finally { setSubmitting(false); } }; return ( - - { event.preventDefault(); setQuery({ keyword, page: 1 }); }}> setKeyword(event.target.value)} /> + + { event.preventDefault(); setQuery({ keyword, page: 1 }); }}> setKeyword(event.target.value)} /> {!loading && !error ? <> item.id} /> setQuery({ ...query, page })} /> : null} setRoom(null)} onSubmit={submit} /> diff --git a/external-admin/src/pages/UsersPage.jsx b/external-admin/src/pages/UsersPage.jsx index 72db4b7..020c76d 100644 --- a/external-admin/src/pages/UsersPage.jsx +++ b/external-admin/src/pages/UsersPage.jsx @@ -14,12 +14,15 @@ import { banUser, grantUserVip, listUsers, updateUser, updateUserLevels } from " import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; import { UserBanDialog, UserEditDialog, UserLevelDialog } from "../components/UserDialogs.jsx"; import { hasExternalCapability } from "../config/capabilities.js"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; +import { translateExternalError } from "../i18n/errors.js"; import { ExternalPage, ExternalPageState, PagePagination, ResponsiveDataList, StatusChip } from "../shared/PageComponents.jsx"; import { usePagedResource } from "../shared/usePagedResource.js"; export default function UsersPage() { const { session } = useExternalAuth(); const { showToast } = useToast(); + const { t } = useExternalI18n(); const [filters, setFilters] = useState({ keyword: "", status: "" }); const [query, setQuery] = useState({ keyword: "", page: 1, status: "" }); const [dialog, setDialog] = useState({ type: "", user: null }); @@ -31,47 +34,52 @@ export default function UsersPage() { const canGrantLevel = hasExternalCapability(session, "user-level:grant"); const closeDialog = () => setDialog({ type: "", user: null }); - const execute = async (request, successMessage) => { + const execute = async (request, successKey) => { setSubmitting(true); try { await request(); - showToast(successMessage, "success"); + showToast(t(successKey), "success"); closeDialog(); reload(); } catch (requestError) { - showToast(requestError.message || "用户操作失败", "error"); + showToast(translateExternalError(requestError, t, "users.operationFailed"), "error"); } finally { setSubmitting(false); } }; const columns = useMemo(() => [ - { key: "user", label: "用户", render: (user) => {user.username.slice(0, 1)}{user.username || "-"}ID {user.id} }, - { key: "prettyId", label: "短 ID(靓号)", render: (user) => user.prettyId || "-" }, - { key: "country", label: "国家/性别", render: (user) => `${user.country || "-"} / ${user.gender || "-"}` }, - { key: "level", label: "等级", render: (user) => `财富 ${user.wealthLevel} / 魅力 ${user.charmLevel} / VIP ${user.vipLevel}` }, - { key: "status", label: "状态", render: (user) => }, - { key: "actions", label: "操作", render: (user) => {canEdit ? : null}{canGrantLevel ? : null}{canBan && !user.banned ? : null} } - ], [canBan, canEdit, canGrantLevel]); + { key: "user", label: t("common.user"), render: (user) => {(user.username || "-").slice(0, 1)}{user.username || "-"}ID {user.id} }, + { key: "prettyId", label: t("users.prettyId"), render: (user) => user.prettyId || "-" }, + { key: "country", label: t("users.countryGender"), render: (user) => `${user.country || "-"} / ${translatedGender(user.gender, t)}` }, + { key: "level", label: t("users.level"), render: (user) => t("users.levelSummary", { wealth: user.wealthLevel, charm: user.charmLevel, vip: user.vipLevel }) }, + { key: "status", label: t("common.status"), render: (user) => }, + { key: "actions", label: t("common.actions"), render: (user) => {canEdit ? : null}{canGrantLevel ? : null}{canBan && !user.banned ? : null} } + ], [canBan, canEdit, canGrantLevel, t]); return ( - + { event.preventDefault(); setQuery({ ...filters, page: 1 }); }}> - setFilters({ ...filters, keyword: event.target.value })} /> - setFilters({ ...filters, status: event.target.value })}>全部状态正常已封禁 - + setFilters({ ...filters, keyword: event.target.value })} /> + setFilters({ ...filters, status: event.target.value })}>{t("common.allStatuses")}{t("common.normal")}{t("common.banned")} + {!loading && !error ? <> item.id} /> setQuery({ ...query, page })} /> : null} - execute(() => updateUser(dialog.user.id, form), "用户信息已更新")} /> - execute(() => banUser(dialog.user.id, { expires_at_ms: Number(form.days) === 0 ? 0 : Date.now() + Number(form.days) * 86400000, reason: form.reason.trim() }), "用户已封禁")} /> + execute(() => updateUser(dialog.user.id, form), "users.updated")} /> + execute(() => banUser(dialog.user.id, { expires_at_ms: Number(form.days) === 0 ? 0 : Date.now() + Number(form.days) * 86400000, reason: form.reason.trim() }), "users.banned")} /> execute(() => form.track === "vip" ? grantUserVip(dialog.user.id, { commandId: createLevelCommandId(), durationMs: Number(form.days) * 86400000, level: Number(form.level), reason: form.reason.trim() }) - : updateUserLevels(dialog.user.id, { adjustments: [{ duration_days: Number(form.days), level: Number(form.level), track: form.track }], reason: form.reason.trim() }), "用户等级已下发")} /> + : updateUserLevels(dialog.user.id, { adjustments: [{ duration_days: Number(form.days), level: Number(form.level), track: form.track }], reason: form.reason.trim() }), "users.levelGranted")} /> ); } +function translatedGender(gender, t) { + const key = { female: "users.genderFemale", male: "users.genderMale", unknown: "users.genderUnknown" }[String(gender || "").toLowerCase()]; + return key ? t(key) : "-"; +} + function createLevelCommandId() { return `external-level-grant-${globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`}`; } diff --git a/external-admin/src/shared/ConfirmDialog.jsx b/external-admin/src/shared/ConfirmDialog.jsx index e947974..72be4ee 100644 --- a/external-admin/src/shared/ConfirmDialog.jsx +++ b/external-admin/src/shared/ConfirmDialog.jsx @@ -4,13 +4,15 @@ import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogContentText from "@mui/material/DialogContentText"; import DialogTitle from "@mui/material/DialogTitle"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; export function ConfirmDialog({ confirmColor = "primary", content, loading, onClose, onConfirm, open, title }) { + const { t } = useExternalI18n(); return ( {title} {content} - + ); } diff --git a/external-admin/src/shared/ExternalImageUploadField.jsx b/external-admin/src/shared/ExternalImageUploadField.jsx index 4851533..6d6f653 100644 --- a/external-admin/src/shared/ExternalImageUploadField.jsx +++ b/external-admin/src/shared/ExternalImageUploadField.jsx @@ -7,8 +7,11 @@ import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import { useRef, useState } from "react"; import { uploadExternalImage } from "../api/business.js"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; +import { translateExternalError } from "../i18n/errors.js"; export function ExternalImageUploadField({ disabled, label, onChange, value }) { + const { t } = useExternalI18n(); const inputRef = useRef(null); const [error, setError] = useState(""); const [uploading, setUploading] = useState(false); @@ -20,11 +23,11 @@ export function ExternalImageUploadField({ disabled, label, onChange, value }) { return; } if (!file.type.startsWith("image/")) { - setError("请选择图片文件"); + setError(t("upload.imageOnly")); return; } if (file.size > 10 * 1024 * 1024) { - setError("图片不能超过 10MB"); + setError(t("upload.sizeLimit")); return; } @@ -33,11 +36,13 @@ export function ExternalImageUploadField({ disabled, label, onChange, value }) { try { const url = await uploadExternalImage(file); if (!url) { - throw new Error("上传结果缺少图片地址"); + const missingUrlError = new Error("missing upload URL"); + missingUrlError.translationKey = "upload.missingUrl"; + throw missingUrlError; } onChange(url); } catch (uploadError) { - setError(uploadError.message || "图片上传失败"); + setError(translateExternalError(uploadError, t, "upload.failed")); } finally { setUploading(false); } @@ -47,14 +52,14 @@ export function ExternalImageUploadField({ disabled, label, onChange, value }) { {label} - {value ? {`${label}预览`} : 暂无图片} + {value ? {t("upload.preview", : {t("upload.empty")}} diff --git a/external-admin/src/shared/PageComponents.jsx b/external-admin/src/shared/PageComponents.jsx index 86da941..fa1a04e 100644 --- a/external-admin/src/shared/PageComponents.jsx +++ b/external-admin/src/shared/PageComponents.jsx @@ -15,6 +15,8 @@ import TableRow from "@mui/material/TableRow"; import Typography from "@mui/material/Typography"; import useMediaQuery from "@mui/material/useMediaQuery"; import { useTheme } from "@mui/material/styles"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; +import { translateExternalError } from "../i18n/errors.js"; export function ExternalPage({ actions, children, title }) { return ( @@ -31,9 +33,10 @@ export function ExternalPage({ actions, children, title }) { } export function ExternalPageState({ error, loading, onRetry }) { + const { t } = useExternalI18n(); if (loading) { return ( - + @@ -42,20 +45,21 @@ export function ExternalPageState({ error, loading, onRetry }) { if (error) { return ( - {error} - + {translateExternalError(error, t, "errors.loadFailed")} + ); } return null; } -export function ResponsiveDataList({ columns, emptyText = "当前无数据", items, rowKey }) { +export function ResponsiveDataList({ columns, emptyText, items, rowKey }) { const theme = useTheme(); const mobile = useMediaQuery(theme.breakpoints.down("sm")); + const { t } = useExternalI18n(); if (!items.length) { - return {emptyText}; + return {emptyText || t("common.noData")}; } if (mobile) { @@ -96,21 +100,25 @@ export function ResponsiveDataList({ columns, emptyText = "当前无数据", ite } export function StatusChip({ status }) { + const { t } = useExternalI18n(); const normalized = String(status || "-").toLowerCase(); const enabled = ["active", "enabled", "running", "normal", "unbanned"].includes(normalized); const dangerous = ["banned", "disabled", "failed", "blocked"].includes(normalized); - return ; + const statusKey = `status.${normalized}`; + const translated = t(statusKey); + return ; } export function PagePagination({ page, pageSize, total, onChange }) { + const { t } = useExternalI18n(); const totalPages = Math.max(1, Math.ceil(total / pageSize)); return ( - 共 {total} 条 + {t("pagination.total", { count: total })} - + {page} / {totalPages} - + ); diff --git a/external-admin/src/shared/usePagedResource.js b/external-admin/src/shared/usePagedResource.js index af442f8..df74d65 100644 --- a/external-admin/src/shared/usePagedResource.js +++ b/external-admin/src/shared/usePagedResource.js @@ -4,7 +4,7 @@ const EMPTY_PAGE = { items: [], page: 1, pageSize: 20, total: 0 }; export function usePagedResource(loader) { const [data, setData] = useState(EMPTY_PAGE); - const [error, setError] = useState(""); + const [error, setError] = useState(null); const [loading, setLoading] = useState(true); const [version, setVersion] = useState(0); @@ -13,7 +13,7 @@ export function usePagedResource(loader) { useEffect(() => { let active = true; setLoading(true); - setError(""); + setError(null); // Start inside the promise chain so synchronous validation errors use the same visible error state as network failures. Promise.resolve() .then(loader) @@ -24,7 +24,8 @@ export function usePagedResource(loader) { }) .catch((requestError) => { if (active) { - setError(requestError.message || "数据加载失败"); + // Keep the structured error so the rendering locale, not the backend message language, determines what users see. + setError(requestError); } }) .finally(() => { diff --git a/external-admin/src/styles/index.css b/external-admin/src/styles/index.css index 2969394..df45aae 100644 --- a/external-admin/src/styles/index.css +++ b/external-admin/src/styles/index.css @@ -31,6 +31,10 @@ body { padding: 32px; } +.external-language-switcher { + min-width: 140px !important; +} + .external-login-avatar, .external-logo-mark { background: var(--brand-gradient) !important; @@ -285,6 +289,7 @@ body { .external-capability-card { min-height: 128px; } .external-filter-bar .MuiTextField-root { width: 100%; } .external-image-field { align-items: flex-start; flex-direction: column; } + .external-header .external-language-switcher { min-width: 94px !important; max-width: 112px; } } @media (prefers-reduced-motion: reduce) { diff --git a/package.json b/package.json index ffdb0c5..0236793 100644 --- a/package.json +++ b/package.json @@ -18,16 +18,19 @@ "preview": "vite preview --host 0.0.0.0" }, "dependencies": { + "@emotion/cache": "11.14.0", "@emotion/react": "11.14.0", "@emotion/styled": "11.14.1", "@mui/icons-material": "9.0.0", "@mui/material": "9.0.0", + "@mui/stylis-plugin-rtl": "9.1.1", "@tanstack/react-query": "^5.100.6", "echarts": "6.0.0", "libpag": "4.5.52", "react": "19.2.5", "react-dom": "19.2.5", "react-router-dom": "^7.14.2", + "stylis": "4.4.0", "svgaplayerweb": "2.3.2", "zod": "^4.4.1" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22758b6..3385623 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@emotion/cache': + specifier: 11.14.0 + version: 11.14.0 '@emotion/react': specifier: 11.14.0 version: 11.14.0(@types/react@19.2.14)(react@19.2.5) @@ -20,6 +23,9 @@ importers: '@mui/material': specifier: 9.0.0 version: 9.0.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@mui/stylis-plugin-rtl': + specifier: 9.1.1 + version: 9.1.1(stylis@4.4.0) '@tanstack/react-query': specifier: ^5.100.6 version: 5.100.6(react@19.2.5) @@ -38,6 +44,9 @@ importers: react-router-dom: specifier: ^7.14.2 version: 7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + stylis: + specifier: 4.4.0 + version: 4.4.0 svgaplayerweb: specifier: 2.3.2 version: 2.3.2 @@ -594,6 +603,12 @@ packages: '@emotion/styled': optional: true + '@mui/stylis-plugin-rtl@9.1.1': + resolution: {integrity: sha512-UeWnUOeZCCy8knYIRULs5/s1hzWVtuIKKDYqtr2lrYBCliAENYQJ8yFUtdqIXHQUrTbAWkmHDEFQIz5nEba8TQ==} + engines: {node: '>=14.0.0'} + peerDependencies: + stylis: 4.x + '@mui/system@9.0.0': resolution: {integrity: sha512-YnC5Zg6j04IxiLc/boAKs0464jfZlLFVa7mf5E8lF0XOtZVUvG6R6gJK50lgUYdaaLdyLfxF6xR7LaPuEpeT/g==} engines: {node: '>=14.0.0'} @@ -1036,6 +1051,10 @@ packages: css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssjanus@2.3.1: + resolution: {integrity: sha512-gWZQ/S0tthU2KCc55C5zbjxQN0XPK1sT0qufCqwCNUauGIsPDOfdBMBWosP+Kd4wf2A2Nxm/z6JAP6yNGnS7QQ==} + engines: {node: '>=10.0.0'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -1857,6 +1876,9 @@ packages: stylis@4.2.0: resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} + stylis@4.4.0: + resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + supports-color@10.2.2: resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} engines: {node: '>=18'} @@ -2569,6 +2591,12 @@ snapshots: '@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.5) '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5) + '@mui/stylis-plugin-rtl@9.1.1(stylis@4.4.0)': + dependencies: + '@babel/runtime': 7.29.2 + cssjanus: 2.3.1 + stylis: 4.4.0 + '@mui/system@9.0.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5)': dependencies: '@babel/runtime': 7.29.2 @@ -2964,6 +2992,8 @@ snapshots: css.escape@1.5.1: {} + cssjanus@2.3.1: {} + csstype@3.2.3: {} data-urls@7.0.0: @@ -3814,6 +3844,8 @@ snapshots: stylis@4.2.0: {} + stylis@4.4.0: {} + supports-color@10.2.2: {} supports-preserve-symlinks-flag@1.0.0: {} diff --git a/src/shared/ui/AppJumpConfigField.jsx b/src/shared/ui/AppJumpConfigField.jsx index 5a240df..64a3121 100644 --- a/src/shared/ui/AppJumpConfigField.jsx +++ b/src/shared/ui/AppJumpConfigField.jsx @@ -19,6 +19,7 @@ export function AppJumpConfigField({ className = "", disabled = false, h5Label = "H5 链接", + messages = {}, onChange, paramValue, required = false, @@ -75,7 +76,7 @@ export function AppJumpConfigField({ <> {appTargetOptions.map(([value, label]) => ( - {label} + {messages.targets?.[value] || label} ))} - + {type === "app" ? ( -

APP 参数会按公共跳转方案生成 JSON;需要特殊参数时可直接编辑 JSON。

+

{messages.hint || "APP 参数会按公共跳转方案生成 JSON;需要特殊参数时可直接编辑 JSON。"}

) : null} ); } -function AppJumpStructuredFields({ appJump, disabled, onChange }) { +function AppJumpStructuredFields({ appJump, disabled, messages, onChange }) { if (appJump.target === "custom" || appJump.target === "wallet" || appJump.target === "room_random") { return null; } @@ -118,7 +119,7 @@ function AppJumpStructuredFields({ appJump, disabled, onChange }) { {needsRoomId(appJump.target) ? ( onChange({ roomId: event.target.value })} @@ -127,7 +128,7 @@ function AppJumpStructuredFields({ appJump, disabled, onChange }) { {needsWindow(appJump.target) ? ( {appJumpWindowOptions.map(([value, label]) => ( - {label} + {messages.windows?.[value] || label} ))} @@ -143,7 +144,7 @@ function AppJumpStructuredFields({ appJump, disabled, onChange }) { {needsGameId(appJump.target, appJump.window) ? ( onChange({ gameId: event.target.value })} @@ -168,7 +169,7 @@ function AppJumpStructuredFields({ appJump, disabled, onChange }) { {needsGiftId(appJump.target, appJump.window) ? ( onChange({ giftId: event.target.value })} /> @@ -176,7 +177,7 @@ function AppJumpStructuredFields({ appJump, disabled, onChange }) { {needsRedPacketNo(appJump.target, appJump.window) ? ( onChange({ redPacketNo: event.target.value })} /> diff --git a/src/shared/ui/TimeRangeFilter.jsx b/src/shared/ui/TimeRangeFilter.jsx index 430a356..cf369df 100644 --- a/src/shared/ui/TimeRangeFilter.jsx +++ b/src/shared/ui/TimeRangeFilter.jsx @@ -12,15 +12,45 @@ import styles from "@/shared/ui/TimeRangeFilter.module.css"; const emptyRange = { endMs: "", startMs: "" }; const quickRanges = [ - ["近 1 小时", 60 * 60 * 1000], - ["近 24 小时", 24 * 60 * 60 * 1000], - ["近 7 天", 7 * 24 * 60 * 60 * 1000], + ["quickHour", 60 * 60 * 1000], + ["quickDay", 24 * 60 * 60 * 1000], + ["quickWeek", 7 * 24 * 60 * 60 * 1000], ]; -const weekLabels = ["一", "二", "三", "四", "五", "六", "日"]; +const defaultMessages = { + apply: "确定", + clear: "清空", + completeRange: "选择完整区间", + duration: "间隔 {value}", + durationDays: "{count} 天", + durationHours: "{count} 小时", + durationMinutes: "{count} 分钟", + end: "结束时间", + endAfterStart: "结束时间必须晚于开始时间", + endDate: "结束日期", + endDateAfterStart: "结束日期不能早于开始日期", + endDateLaterShort: "结束不能早于开始", + endLaterShort: "结束需晚于开始", + endMark: "末", + hour: "时", + lessMinute: "间隔少于 1 分钟", + minute: "分", + nextMonth: "下个月", + pending: "待选择", + previousMonth: "上个月", + quickDay: "近 24 小时", + quickHour: "近 1 小时", + quickWeek: "近 7 天", + sameDay: "同一天", + start: "开始时间", + startDate: "开始日期", + startMark: "始", + unlimited: "不限", +}; // withTime=false 时是纯日期区间选择器:隐藏时/分与小时级快捷区间, // 选中的开始/结束日分别取当天 00:00:00.000 与 23:59:59.999 的本地毫秒。 -export function TimeRangeFilter({ className = "", disabled = false, label = "时间", onChange, value, withTime = true }) { +export function TimeRangeFilter({ className = "", disabled = false, label = "时间", locale = "zh-CN", messages, onChange, value, withTime = true }) { + const copy = useMemo(() => ({ ...defaultMessages, ...messages }), [messages]); const range = useMemo(() => normalizeRange(value), [value]); const [anchorEl, setAnchorEl] = useState(null); const [draft, setDraft] = useState(() => range); @@ -36,7 +66,8 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时 activeField, activeField === "end" && draft.startMs ? new Date(draft.startMs) : viewDate, ); - const rangeDistance = formatRangeDistance(draft.startMs, draft.endMs, withTime); + const rangeDistance = formatLocalizedRangeDistance(draft.startMs, draft.endMs, withTime, copy); + const weekLabels = useMemo(() => localizedWeekLabels(locale), [locale]); const hasRange = Boolean(range.startMs || range.endMs); useEffect(() => { @@ -53,8 +84,8 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时 () => !range.startMs && !range.endMs ? label - : `${formatPart(range.startMs) || "不限"} - ${formatPart(range.endMs) || "不限"}`, - [formatPart, label, range], + : `${formatPart(range.startMs) || copy.unlimited} - ${formatPart(range.endMs) || copy.unlimited}`, + [copy.unlimited, formatPart, label, range], ); const openPopover = (event) => { @@ -127,7 +158,7 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时 event.stopPropagation(); const nextRange = normalizeRange(draft); if (nextRange.startMs && nextRange.endMs && nextRange.endMs <= nextRange.startMs) { - setError(withTime ? "结束时间必须晚于开始时间" : "结束日期不能早于开始日期"); + setError(withTime ? copy.endAfterStart : copy.endDateAfterStart); return; } applyRange(nextRange); @@ -146,7 +177,7 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时 {label} - {hasRange ? displayLabel : "不限"} + {hasRange ? displayLabel : copy.unlimited} activateField("start")} /> activateField("end")} />
- - {formatDayPosition(draft.startMs) || "待选择"} + {copy.startMark} + {formatDayPosition(draft.startMs) || copy.pending} {rangeDistance} - {formatDayPosition(draft.endMs) || "待选择"} - + {formatDayPosition(draft.endMs) || copy.pending} + {copy.endMark}
- changeMonth(-1)}> + changeMonth(-1)}> - {monthTitle(viewDate)} - changeMonth(1)}> + {monthTitle(viewDate, locale)} + changeMonth(1)}>
@@ -245,7 +278,7 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时 {withTime ? (
- 确定 + {copy.apply}
@@ -285,7 +318,7 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时 ); } -function RangeFieldButton({ active, formatPart = formatRangePart, label, onClick, value }) { +function RangeFieldButton({ active, formatPart = formatRangePart, label, onClick, unlimited = "不限", value }) { return ( ); } @@ -375,8 +408,11 @@ export function sameDay(left, right) { ); } -export function monthTitle(date) { - return `${date.getFullYear()}年${pad2(date.getMonth() + 1)}月`; +export function monthTitle(date, locale = "zh-CN") { + if (locale === "zh-CN") { + return `${date.getFullYear()}年${pad2(date.getMonth() + 1)}月`; + } + return new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(date); } export function formatRangePart(value) { @@ -437,6 +473,47 @@ export function formatRangeDistance(startValue, endValue, withTime = true) { return `间隔 ${parts.join(" ")}`; } +function formatLocalizedRangeDistance(startValue, endValue, withTime, messages) { + const startMs = normalizeMs(startValue); + const endMs = normalizeMs(endValue); + if (!startMs || !endMs) { + return messages.completeRange; + } + if (endMs <= startMs) { + return withTime ? messages.endLaterShort : messages.endDateLaterShort; + } + if (!withTime) { + const start = new Date(startMs); + const end = new Date(endMs); + const startDay = Date.UTC(start.getFullYear(), start.getMonth(), start.getDate()); + const endDay = Date.UTC(end.getFullYear(), end.getMonth(), end.getDate()); + const days = Math.round((endDay - startDay) / (24 * 60 * 60 * 1000)); + return days === 0 ? messages.sameDay : template(messages.duration, { value: template(messages.durationDays, { count: days }) }); + } + const totalMinutes = Math.floor((endMs - startMs) / (60 * 1000)); + if (totalMinutes < 1) { + return messages.lessMinute; + } + const days = Math.floor(totalMinutes / (24 * 60)); + const hours = Math.floor((totalMinutes % (24 * 60)) / 60); + const minutes = totalMinutes % 60; + const parts = [ + days ? template(messages.durationDays, { count: days }) : "", + hours ? template(messages.durationHours, { count: hours }) : "", + minutes ? template(messages.durationMinutes, { count: minutes }) : "", + ].filter(Boolean); + return template(messages.duration, { value: parts.join(" ") }); +} + +function localizedWeekLabels(locale) { + const formatter = new Intl.DateTimeFormat(locale, { weekday: "narrow" }); + return Array.from({ length: 7 }, (_, index) => formatter.format(new Date(2024, 0, 1 + index))); +} + +function template(message, values) { + return String(message).replace(/\{(\w+)\}/g, (match, key) => (values[key] === undefined ? match : String(values[key]))); +} + function sameDayMs(day, value) { const ms = normalizeMs(value); return Boolean(ms) && sameDay(day, new Date(ms)); From c99c94db8efdcedf470494bceb6d3ba0b534049d Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 15 Jul 2026 17:01:47 +0800 Subject: [PATCH 03/15] fix(external-admin): keep RTL styles mounted --- .../i18n/emotionCache.integration.test.jsx | 25 +++++++++++++++++++ package.json | 2 +- pnpm-lock.yaml | 15 ++++------- 3 files changed, 31 insertions(+), 11 deletions(-) create mode 100644 external-admin/src/i18n/emotionCache.integration.test.jsx diff --git a/external-admin/src/i18n/emotionCache.integration.test.jsx b/external-admin/src/i18n/emotionCache.integration.test.jsx new file mode 100644 index 0000000..18acb18 --- /dev/null +++ b/external-admin/src/i18n/emotionCache.integration.test.jsx @@ -0,0 +1,25 @@ +import { CacheProvider } from "@emotion/react"; +import Button from "@mui/material/Button"; +import { createTheme, ThemeProvider } from "@mui/material/styles"; +import { render, screen } from "@testing-library/react"; +import { expect, test } from "vitest"; +import { externalEmotionCache } from "./emotionCache.js"; + +test("keeps MUI mounted when the live page switches from LTR to RTL", () => { + const view = render(); + expect(screen.getByRole("button", { name: "ltr" })).toBeInTheDocument(); + + // Exercise Emotion's real insert pipeline: a standalone Stylis serializer test cannot detect a plugin resolved from a second, incompatible Stylis version. + expect(() => view.rerender()).not.toThrow(); + expect(screen.getByRole("button", { name: "rtl" })).toBeInTheDocument(); +}); + +function DirectionProbe({ direction }) { + return ( + + + + + + ); +} diff --git a/package.json b/package.json index 0236793..97dad83 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "react": "19.2.5", "react-dom": "19.2.5", "react-router-dom": "^7.14.2", - "stylis": "4.4.0", + "stylis": "4.2.0", "svgaplayerweb": "2.3.2", "zod": "^4.4.1" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3385623..58abe24 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,7 +25,7 @@ importers: version: 9.0.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@mui/stylis-plugin-rtl': specifier: 9.1.1 - version: 9.1.1(stylis@4.4.0) + version: 9.1.1(stylis@4.2.0) '@tanstack/react-query': specifier: ^5.100.6 version: 5.100.6(react@19.2.5) @@ -45,8 +45,8 @@ importers: specifier: ^7.14.2 version: 7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5) stylis: - specifier: 4.4.0 - version: 4.4.0 + specifier: 4.2.0 + version: 4.2.0 svgaplayerweb: specifier: 2.3.2 version: 2.3.2 @@ -1876,9 +1876,6 @@ packages: stylis@4.2.0: resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} - stylis@4.4.0: - resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} - supports-color@10.2.2: resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} engines: {node: '>=18'} @@ -2591,11 +2588,11 @@ snapshots: '@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.5) '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5) - '@mui/stylis-plugin-rtl@9.1.1(stylis@4.4.0)': + '@mui/stylis-plugin-rtl@9.1.1(stylis@4.2.0)': dependencies: '@babel/runtime': 7.29.2 cssjanus: 2.3.1 - stylis: 4.4.0 + stylis: 4.2.0 '@mui/system@9.0.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5)': dependencies: @@ -3844,8 +3841,6 @@ snapshots: stylis@4.2.0: {} - stylis@4.4.0: {} - supports-color@10.2.2: {} supports-preserve-symlinks-flag@1.0.0: {} From 490e36b2cf86af880a9c525f29592b113b59da73 Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 15 Jul 2026 18:05:16 +0800 Subject: [PATCH 04/15] feat(external-admin): use database app branding --- external-admin/index.html | 2 +- external-admin/src/api/auth.js | 1 + external-admin/src/api/auth.test.js | 3 +- .../src/components/BannerCreateDialog.jsx | 2 +- .../components/BannerCreateDialog.test.jsx | 1 + .../src/components/GrantDialogs.jsx | 8 ---- .../src/components/GrantDialogs.test.jsx | 17 +++++++ .../src/i18n/ExternalI18nProvider.jsx | 1 - .../src/i18n/ExternalI18nProvider.test.jsx | 10 ++--- external-admin/src/i18n/messages.js | 26 +++++------ external-admin/src/i18n/messages.test.js | 4 ++ .../src/layout/ExternalAdminLayout.jsx | 45 ++++++++++++++++--- .../src/layout/ExternalAdminLayout.test.jsx | 39 +++++++++++++++- external-admin/src/pages/LoginPage.jsx | 3 -- external-admin/src/pages/LoginPage.test.jsx | 3 +- external-admin/src/styles/index.css | 14 ++---- src/shared/ui/AppIdentity.jsx | 11 +++-- src/shared/ui/AppIdentity.test.jsx | 10 +++++ src/shared/ui/AppJumpConfigField.jsx | 3 +- 19 files changed, 147 insertions(+), 56 deletions(-) create mode 100644 external-admin/src/components/GrantDialogs.test.jsx diff --git a/external-admin/index.html b/external-admin/index.html index 60bbbf8..a83fee8 100644 --- a/external-admin/index.html +++ b/external-admin/index.html @@ -4,7 +4,7 @@ - HYApp External Admin + Fami
diff --git a/external-admin/src/api/auth.js b/external-admin/src/api/auth.js index 9371100..f6dcae6 100644 --- a/external-admin/src/api/auth.js +++ b/external-admin/src/api/auth.js @@ -66,6 +66,7 @@ export function normalizeSession(data) { account: textValue(accountRecord.username, accountRecord.account, legacyAccount.username, legacyAccount.account, root.accountName, typeof root.account === "string" ? root.account : "", root.username), appCode: textValue(root.appCode, root.app_code, app.appCode, app.app_code, accountRecord.appCode, accountRecord.app_code, linkedUser.appCode, linkedUser.app_code), appName: textValue(app.name, app.appName, app.app_name, root.appName, root.app_name), + logoUrl: textValue(app.logoUrl, app.logo_url, root.logoUrl, root.logo_url), capabilities: [...new Set(capabilities)], displayName: textValue(linkedUser.displayName, linkedUser.display_name, linkedUser.name, linkedUser.username, accountRecord.username, legacyAccount.username, root.displayName), id: textValue(accountRecord.id, accountRecord.externalAdminUserId, accountRecord.external_admin_user_id, legacyAccount.id, root.accountId, root.account_id), diff --git a/external-admin/src/api/auth.test.js b/external-admin/src/api/auth.test.js index 1c3584e..f732676 100644 --- a/external-admin/src/api/auth.test.js +++ b/external-admin/src/api/auth.test.js @@ -9,13 +9,14 @@ describe("normalizeSession", () => { test("normalizes account objects, fixed app and capabilities without stringifying objects", () => { expect(normalizeSession({ account: { id: "ext-1", username: "fami-manager" }, - app: { appCode: "fami", appName: "Fami" }, + app: { appCode: "fami", appName: "Fami", logoUrl: "https://media.example.com/fami.png" }, capabilities: ["user:list"], passwordChangeRequired: true })).toEqual(expect.objectContaining({ account: "fami-manager", appCode: "fami", appName: "Fami", + logoUrl: "https://media.example.com/fami.png", capabilities: ["user:list"], passwordChangeRequired: true })); diff --git a/external-admin/src/components/BannerCreateDialog.jsx b/external-admin/src/components/BannerCreateDialog.jsx index 486fec1..340ecc2 100644 --- a/external-admin/src/components/BannerCreateDialog.jsx +++ b/external-admin/src/components/BannerCreateDialog.jsx @@ -57,6 +57,7 @@ export function BannerCreateDialog({ loading, onClose, onSubmit, open }) { messages={appJumpMessages(t)} paramValue={form.param} required + showHint={false} typeLabel={t("banners.jumpType")} typeValue={form.bannerType} onChange={({ param, type }) => setForm({ ...form, bannerType: type, param })} @@ -119,7 +120,6 @@ function appJumpMessages(t) { appTarget: t("jump.appTarget"), gameId: t("jump.gameId"), giftId: t("jump.giftId"), - hint: t("jump.hint"), redPacketNo: t("jump.redPacketNo"), roomId: t("jump.roomId"), targets: Object.fromEntries(["wallet", "room", "room_random", "room_window", "room_random_window", "room_game", "room_random_game", "explore_game", "custom"].map((value) => [value, t(`jump.target.${value}`)])), diff --git a/external-admin/src/components/BannerCreateDialog.test.jsx b/external-admin/src/components/BannerCreateDialog.test.jsx index bafad3e..f07ecc1 100644 --- a/external-admin/src/components/BannerCreateDialog.test.jsx +++ b/external-admin/src/components/BannerCreateDialog.test.jsx @@ -16,5 +16,6 @@ describe("BannerCreateDialog", () => { expect(screen.getByRole("combobox", { name: "App destination" })).toBeInTheDocument(); expect(screen.getByRole("textbox", { name: "Parameter (App navigation JSON)" })).toHaveValue(JSON.stringify({ type: "wallet" })); + expect(screen.queryByText("App navigation parameters are generated as JSON. You can edit the JSON directly for custom parameters.")).not.toBeInTheDocument(); }); }); diff --git a/external-admin/src/components/GrantDialogs.jsx b/external-admin/src/components/GrantDialogs.jsx index 6fce0f1..7f986ee 100644 --- a/external-admin/src/components/GrantDialogs.jsx +++ b/external-admin/src/components/GrantDialogs.jsx @@ -1,4 +1,3 @@ -import Alert from "@mui/material/Alert"; import Button from "@mui/material/Button"; import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; @@ -16,7 +15,6 @@ export function ResourceGrantDialog({ loading, onClose, onSubmit, open, resource useEffect(() => setForm(resourceGrantInitial), [open]); return ( onSubmit(form)}> - setForm({ ...form, target: event.target.value.trim() })} /> setForm({ ...form, resourceId: event.target.value })}> {resources.map((item) => {item.name} ({item.id}))} @@ -34,7 +32,6 @@ export function PrettyIdGrantDialog({ loading, onClose, onSubmit, open }) { useEffect(() => setForm(prettyGrantInitial), [open]); return ( onSubmit(form)}> - setForm({ ...form, target: event.target.value.trim() })} /> setForm({ ...form, prettyId: event.target.value.trim() })} /> setForm({ ...form, durationDays: event.target.value })} /> @@ -43,11 +40,6 @@ export function PrettyIdGrantDialog({ loading, onClose, onSubmit, open }) { ); } -function TargetNotice() { - const { t } = useExternalI18n(); - return {t("grants.notice")}; -} - function GrantDialog({ children, loading, onClose, onSubmit, open, title }) { const { t } = useExternalI18n(); return ( diff --git a/external-admin/src/components/GrantDialogs.test.jsx b/external-admin/src/components/GrantDialogs.test.jsx new file mode 100644 index 0000000..3c72d63 --- /dev/null +++ b/external-admin/src/components/GrantDialogs.test.jsx @@ -0,0 +1,17 @@ +import { render, screen } from "@testing-library/react"; +import { expect, test, vi } from "vitest"; +import { ExternalI18nProvider } from "../i18n/ExternalI18nProvider.jsx"; +import { ResourceGrantDialog } from "./GrantDialogs.jsx"; + +test("shows only actionable grant fields without a descriptive notice", () => { + render( + + + + ); + + expect(screen.getByRole("dialog", { name: "Grant privilege item" })).toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(screen.queryByText("Before submission, the user is verified in the current App and the server-returned user ID is used for the grant.")).not.toBeInTheDocument(); + expect(screen.getByText("Enter 0 for permanent validity")).toBeInTheDocument(); +}); diff --git a/external-admin/src/i18n/ExternalI18nProvider.jsx b/external-admin/src/i18n/ExternalI18nProvider.jsx index b5fe1ee..3c244c4 100644 --- a/external-admin/src/i18n/ExternalI18nProvider.jsx +++ b/external-admin/src/i18n/ExternalI18nProvider.jsx @@ -41,7 +41,6 @@ export function ExternalI18nProvider({ children }) { root.lang = locale; root.dir = direction; globalThis.document.body?.setAttribute("dir", direction); - globalThis.document.title = t("app.title"); }, [direction, locale, t]); const value = useMemo( diff --git a/external-admin/src/i18n/ExternalI18nProvider.test.jsx b/external-admin/src/i18n/ExternalI18nProvider.test.jsx index abcb047..86af77f 100644 --- a/external-admin/src/i18n/ExternalI18nProvider.test.jsx +++ b/external-admin/src/i18n/ExternalI18nProvider.test.jsx @@ -16,7 +16,7 @@ describe("ExternalI18nProvider", () => { render(); expect(screen.getByTestId("locale")).toHaveTextContent("en"); - expect(screen.getByTestId("title")).toHaveTextContent("HYApp External Admin"); + expect(screen.getByTestId("title")).toHaveTextContent("Fami"); expect(document.documentElement).toHaveAttribute("lang", "en"); expect(document.documentElement).toHaveAttribute("dir", "ltr"); }); @@ -26,25 +26,25 @@ describe("ExternalI18nProvider", () => { const firstRender = render(); await user.click(screen.getByRole("button", { name: "ar" })); - expect(screen.getByTestId("title")).toHaveTextContent("إدارة HYApp الخارجية"); + expect(screen.getByTestId("title")).toHaveTextContent("Fami"); expect(document.documentElement).toHaveAttribute("lang", "ar"); expect(document.documentElement).toHaveAttribute("dir", "rtl"); expect(document.body).toHaveAttribute("dir", "rtl"); expect(localStorage.getItem(EXTERNAL_LOCALE_STORAGE_KEY)).toBe("ar"); await user.click(screen.getByRole("button", { name: "tr" })); - expect(screen.getByTestId("title")).toHaveTextContent("HYApp Harici Yönetim"); + expect(screen.getByTestId("title")).toHaveTextContent("Fami"); expect(document.documentElement).toHaveAttribute("lang", "tr"); expect(document.documentElement).toHaveAttribute("dir", "ltr"); await user.click(screen.getByRole("button", { name: "zh-CN" })); - expect(screen.getByTestId("title")).toHaveTextContent("HYApp 外管后台"); + expect(screen.getByTestId("title")).toHaveTextContent("Fami"); expect(localStorage.getItem(EXTERNAL_LOCALE_STORAGE_KEY)).toBe("zh-CN"); firstRender.unmount(); render(); expect(screen.getByTestId("locale")).toHaveTextContent("zh-CN"); - expect(screen.getByTestId("title")).toHaveTextContent("HYApp 外管后台"); + expect(screen.getByTestId("title")).toHaveTextContent("Fami"); }); }); diff --git a/external-admin/src/i18n/messages.js b/external-admin/src/i18n/messages.js index c442286..cb399f3 100644 --- a/external-admin/src/i18n/messages.js +++ b/external-admin/src/i18n/messages.js @@ -10,8 +10,8 @@ export const EXTERNAL_LANGUAGE_OPTIONS = Object.freeze([ ]); const en = { - "app.title": "HYApp External Admin", - "app.shortTitle": "HYApp External Admin", + "app.title": "Fami", + "app.shortTitle": "Fami", "language.label": "Language", "common.actions": "Actions", "common.active": "Active", @@ -191,7 +191,6 @@ const en = { "grants.validDays": "Validity (days)", "grants.permanentHint": "Enter 0 for permanent validity", "grants.reason": "Grant reason", - "grants.notice": "Before submission, the user is verified in the current App and the server-returned user ID is used for the grant.", "grants.confirm": "Confirm grant", "banners.title": "Banner management", "banners.banner": "Banner", @@ -257,7 +256,6 @@ const en = { "jump.gameId": "Game ID", "jump.giftId": "Gift ID", "jump.redPacketNo": "Red packet number", - "jump.hint": "App navigation parameters are generated as JSON. You can edit the JSON directly for custom parameters.", "time.unlimited": "Any time", "time.quickHour": "Last hour", "time.quickDay": "Last 24 hours", @@ -323,8 +321,8 @@ const en = { }; const zhCN = { - "app.title": "HYApp 外管后台", - "app.shortTitle": "HYApp 外管", + "app.title": "Fami", + "app.shortTitle": "Fami", "language.label": "语言", "common.actions": "操作", "common.active": "正常", "common.all": "全部", "common.allStatuses": "全部状态", "common.banned": "已封禁", "common.cancel": "取消", "common.confirm": "确认", "common.create": "创建", "common.disabled": "禁用", "common.edit": "编辑", "common.enabled": "启用", "common.expired": "过期", "common.loading": "正在加载", "common.noData": "当前无数据", "common.normal": "正常", "common.query": "查询", "common.reason": "原因", "common.reload": "重新加载", "common.retry": "重试", "common.save": "保存", "common.status": "状态", "common.user": "用户", "auth.account": "外管账号", "auth.changePassword": "修改外管密码", "auth.checkingSession": "正在校验外管会话", "auth.confirmNewPassword": "确认新密码", "auth.currentPassword": "当前密码", "auth.fillAccountPassword": "请填写账号和密码", "auth.firstLoginChange": "首次登录必须修改密码", "auth.hidePassword": "隐藏密码", "auth.invalidCredentials": "账号或密码错误", "auth.login": "登录", "auth.loginFailed": "登录失败", "auth.logout": "退出登录", "auth.newPassword": "新密码", "auth.noPermission": "当前外管账号没有访问此功能的权限。", "auth.password": "密码", "auth.passwordChangedFailed": "密码修改失败", "auth.passwordMismatch": "两次输入的新密码不一致", "auth.passwordMin": "至少 8 个字符", "auth.passwordMinError": "新密码至少 8 个字符", "auth.passwordWhitespace": "新密码不能为空或全为空白字符", "auth.recheck": "重新校验", "auth.returnOverview": "返回权限总览", "auth.savePassword": "保存密码", "auth.sessionFailed": "会话校验失败", "auth.showPassword": "显示密码", @@ -335,19 +333,19 @@ const zhCN = { "bans.title": "账号封禁列表", "bans.reason": "封禁原因", "bans.unban": "解除封禁", "bans.unbanned": "用户已解除封禁", "bans.unbanFailed": "解除封禁失败", "bans.confirmTitle": "解除封禁", "bans.confirmContent": "确认解除用户 {user} 的封禁?", "organization.title": "组织管理", "organization.agencies": "公会列表", "organization.bds": "BD 列表", "organization.bdManagers": "BD Manager 列表", "organization.hosts": "主播列表", "organization.managers": "管理员列表", "organization.superAdmins": "Super Admin 列表", "organization.team": "我的团队", "organization.agency": "公会", "organization.owner": "负责人", "organization.parentBd": "上级 BD", "organization.member": "成员", "organization.country": "国家/地区", "organization.userReference": "用户 {id}", "organization.agencyReference": "公会 {id}", "organization.teamSearch": "公会 ID / 负责人用户 ID / 上级 BD ID", "organization.noPermission": "当前外管账号没有访问该组织列表的权限。", "organization.bdLeaderCount": "BD Leader:{count}", "organization.bdCount": "BD:{count}", "organization.agencyCount": "公会:{count}", "organization.truncated": "团队规模超过接口上限,当前结果不完整", "rooms.title": "房间管理", "rooms.room": "房间", "rooms.owner": "房主", "rooms.visibleRegion": "可见区域", "rooms.search": "房间 ID / 名称 / 房主", "rooms.updated": "房间信息已更新", "rooms.updateFailed": "房间更新失败", "rooms.editTitle": "编辑房间", "rooms.background": "房间背景图", "rooms.name": "房间名称", "rooms.description": "房间介绍", "rooms.visibleRegionId": "可见区域 ID", "rooms.allRegions": "全部", - "grants.title": "资源与靓号", "grants.grantResource": "发放道具", "grants.grantPrettyId": "下发靓号", "grants.availableResources": "可发放资源", "grants.targetUser": "目标用户", "grants.resource": "资源", "grants.quantity": "数量", "grants.resourceGranted": "特权道具已发放", "grants.prettyIdGranted": "靓号已下发", "grants.failed": "发放失败", "grants.resourceTitle": "特权道具发放", "grants.prettyIdTitle": "靓号下发", "grants.target": "用户 ID / 短 ID(靓号)", "grants.currentTarget": "用户 ID / 当前短 ID", "grants.prettyId": "下发靓号", "grants.validDays": "有效天数", "grants.permanentHint": "填 0 表示永久有效", "grants.reason": "发放原因", "grants.notice": "提交前会校验当前 App 内的用户,并使用服务端返回的用户 ID 执行发放。", "grants.confirm": "确认发放", + "grants.title": "资源与靓号", "grants.grantResource": "发放道具", "grants.grantPrettyId": "下发靓号", "grants.availableResources": "可发放资源", "grants.targetUser": "目标用户", "grants.resource": "资源", "grants.quantity": "数量", "grants.resourceGranted": "特权道具已发放", "grants.prettyIdGranted": "靓号已下发", "grants.failed": "发放失败", "grants.resourceTitle": "特权道具发放", "grants.prettyIdTitle": "靓号下发", "grants.target": "用户 ID / 短 ID(靓号)", "grants.currentTarget": "用户 ID / 当前短 ID", "grants.prettyId": "下发靓号", "grants.validDays": "有效天数", "grants.permanentHint": "填 0 表示永久有效", "grants.reason": "发放原因", "grants.confirm": "确认发放", "banners.title": "Banner 管理", "banners.banner": "Banner", "banners.type": "类型", "banners.platform": "平台", "banners.sort": "排序", "banners.create": "创建 Banner", "banners.created": "Banner 已创建", "banners.createFailed": "Banner 创建失败", "banners.image": "Banner 图片", "banners.roomImage": "房间内小屏图", "banners.description": "描述", "banners.jumpType": "跳转类型", "banners.h5Param": "参数(H5 链接)", "banners.appParam": "参数(APP 公共跳转 JSON)", "banners.displayScope": "展示范围", "banners.scopeHome": "首页", "banners.scopeRoom": "房间", "banners.scopeRecharge": "充值", "banners.scopeMe": "我的", "banners.regionId": "区域 ID", "banners.countryCode": "国家码", "banners.countryCodeHint": "2-3 位字母,留空为全部", "banners.schedule": "投放时间", "upload.preview": "{label}预览", "upload.empty": "暂无图片", "upload.uploading": "上传中", "upload.replace": "更换图片", "upload.select": "上传图片", "upload.imageOnly": "请选择图片文件", "upload.sizeLimit": "图片不能超过 10MB", "upload.missingUrl": "上传结果缺少图片地址", "upload.failed": "图片上传失败", "pagination.total": "共 {count} 条", "pagination.previous": "上一页", "pagination.next": "下一页", "status.active": "正常", "status.enabled": "启用", "status.running": "运行中", "status.normal": "正常", "status.unbanned": "未封禁", "status.banned": "已封禁", "status.disabled": "禁用", "status.failed": "失败", "status.blocked": "已拦截", "status.expired": "过期", - "jump.appTarget": "APP 目标", "jump.target.wallet": "钱包", "jump.target.room": "指定房间", "jump.target.room_random": "随机房间", "jump.target.room_window": "指定房内拉起窗口", "jump.target.room_random_window": "随机房内拉起窗口", "jump.target.room_game": "指定房内拉起游戏", "jump.target.room_random_game": "随机房内拉起游戏", "jump.target.explore_game": "Explore 游戏 H5", "jump.target.custom": "自定义 JSON", "jump.roomId": "房间 ID", "jump.window": "房内窗口", "jump.window.game_window": "游戏窗口", "jump.window.gift_panel": "礼物面板", "jump.window.red_packet_claim": "领红包弹窗", "jump.window.wheel": "转盘窗口", "jump.gameId": "游戏 ID", "jump.giftId": "礼物 ID", "jump.redPacketNo": "红包编号", "jump.hint": "APP 参数会按公共跳转方案生成 JSON;需要特殊参数时可直接编辑 JSON。", + "jump.appTarget": "APP 目标", "jump.target.wallet": "钱包", "jump.target.room": "指定房间", "jump.target.room_random": "随机房间", "jump.target.room_window": "指定房内拉起窗口", "jump.target.room_random_window": "随机房内拉起窗口", "jump.target.room_game": "指定房内拉起游戏", "jump.target.room_random_game": "随机房内拉起游戏", "jump.target.explore_game": "Explore 游戏 H5", "jump.target.custom": "自定义 JSON", "jump.roomId": "房间 ID", "jump.window": "房内窗口", "jump.window.game_window": "游戏窗口", "jump.window.gift_panel": "礼物面板", "jump.window.red_packet_claim": "领红包弹窗", "jump.window.wheel": "转盘窗口", "jump.gameId": "游戏 ID", "jump.giftId": "礼物 ID", "jump.redPacketNo": "红包编号", "time.unlimited": "不限", "time.quickHour": "近 1 小时", "time.quickDay": "近 24 小时", "time.quickWeek": "近 7 天", "time.start": "开始时间", "time.end": "结束时间", "time.startDate": "开始日期", "time.endDate": "结束日期", "time.pending": "待选择", "time.startMark": "始", "time.endMark": "末", "time.previousMonth": "上个月", "time.nextMonth": "下个月", "time.hour": "时", "time.minute": "分", "time.clear": "清空", "time.apply": "确定", "time.endAfterStart": "结束时间必须晚于开始时间", "time.endDateAfterStart": "结束日期不能早于开始日期", "time.completeRange": "选择完整区间", "time.endLaterShort": "结束需晚于开始", "time.endDateLaterShort": "结束不能早于开始", "time.durationDays": "{count} 天", "time.durationHours": "{count} 小时", "time.durationMinutes": "{count} 分钟", "time.duration": "间隔 {value}", "time.sameDay": "同一天", "time.lessMinute": "间隔少于 1 分钟", "errors.loadFailed": "数据加载失败", "errors.requestFailed": "请求失败", "errors.unauthorized": "会话已失效,请重新登录。", "errors.forbidden": "你没有执行此操作的权限。", "errors.notFound": "未找到请求的资源。", "errors.conflict": "当前数据状态存在冲突,请刷新后重试。", "errors.tooLarge": "请求内容过大。", "errors.rateLimited": "请求过于频繁,请稍后重试。", "errors.server": "服务暂时不可用,请稍后重试。", "errors.accountLocked": "账号已临时锁定,请稍后重试。", "errors.appInactive": "该账号关联的 App 已停用。", "errors.vipDisabled": "当前 App 的 VIP 体系已停用", "errors.vipLevelInvalid": "请选择当前 App 已配置的 VIP 等级", "errors.organizationRoute": "该组织列表尚无安全的数据接口", "errors.regionId": "可见区域 ID 必须是安全的非负整数", "errors.grantTarget": "未找到当前 App 内的目标用户", "errors.bannerType": "Banner 类型不正确", "errors.bannerPlatform": "Banner 平台不正确", "errors.bannerStatus": "Banner 状态不正确", "errors.bannerScope": "请至少选择一个展示范围", "errors.bannerImage": "请上传 Banner 图片", "errors.h5Url": "请填写合法的 H5 链接", "errors.h5Protocol": "H5 链接仅支持 http 或 https", "errors.appParamRequired": "请填写 APP 跳转参数", "errors.appParamObject": "APP 跳转参数必须是 JSON 对象", "errors.appParamType": "APP 跳转 type 不在公共跳转方案内", "errors.appParamRoom": "该 APP 跳转需要填写 room_id", "errors.appParamGame": "该 APP 跳转需要填写 game_id", "errors.appParamWindow": "该 APP 跳转需要填写有效 window", "errors.appParamGameCode": "Explore 游戏 H5 需要选择有效 game_code", "errors.roomBannerImage": "房间范围必须上传房间内小屏图", "errors.countryCode": "国家码必须是 2-3 位字母", "errors.bannerRegion": "区域 ID 不正确", "errors.bannerSchedule": "投放结束时间必须晚于开始时间" }; const ar = { ...en, - "app.title": "إدارة HYApp الخارجية", "app.shortTitle": "إدارة HYApp الخارجية", "language.label": "اللغة", + "app.title": "Fami", "app.shortTitle": "Fami", "language.label": "اللغة", "common.actions": "الإجراءات", "common.active": "نشط", "common.all": "الكل", "common.allStatuses": "كل الحالات", "common.banned": "محظور", "common.cancel": "إلغاء", "common.confirm": "تأكيد", "common.create": "إنشاء", "common.disabled": "معطّل", "common.edit": "تعديل", "common.enabled": "مفعّل", "common.expired": "منتهي", "common.loading": "جارٍ التحميل", "common.noData": "لا توجد بيانات", "common.normal": "طبيعي", "common.query": "بحث", "common.reason": "السبب", "common.reload": "إعادة التحميل", "common.retry": "إعادة المحاولة", "common.save": "حفظ", "common.status": "الحالة", "common.user": "المستخدم", "auth.account": "حساب الإدارة الخارجية", "auth.changePassword": "تغيير كلمة مرور الإدارة الخارجية", "auth.checkingSession": "جارٍ التحقق من الجلسة", "auth.confirmNewPassword": "تأكيد كلمة المرور الجديدة", "auth.currentPassword": "كلمة المرور الحالية", "auth.fillAccountPassword": "أدخل الحساب وكلمة المرور", "auth.firstLoginChange": "يجب تغيير كلمة المرور عند تسجيل الدخول لأول مرة", "auth.hidePassword": "إخفاء كلمة المرور", "auth.invalidCredentials": "الحساب أو كلمة المرور غير صحيحة", "auth.login": "تسجيل الدخول", "auth.loginFailed": "فشل تسجيل الدخول", "auth.logout": "تسجيل الخروج", "auth.newPassword": "كلمة المرور الجديدة", "auth.noPermission": "لا يملك هذا الحساب صلاحية الوصول إلى هذه الميزة.", "auth.password": "كلمة المرور", "auth.passwordChangedFailed": "فشل تغيير كلمة المرور", "auth.passwordMismatch": "كلمتا المرور الجديدتان غير متطابقتين", "auth.passwordMin": "8 أحرف على الأقل", "auth.passwordMinError": "يجب أن تتكون كلمة المرور الجديدة من 8 أحرف على الأقل", "auth.passwordWhitespace": "لا يمكن أن تكون كلمة المرور الجديدة فارغة أو مسافات فقط", "auth.recheck": "إعادة التحقق", "auth.returnOverview": "العودة إلى ملخص الصلاحيات", "auth.savePassword": "حفظ كلمة المرور", "auth.sessionFailed": "فشل التحقق من الجلسة", "auth.showPassword": "إظهار كلمة المرور", "nav.expand": "فتح القائمة", "nav.overview": "ملخص الصلاحيات", "nav.users": "إدارة المستخدمين", "nav.bans": "إدارة الحظر", "nav.hosts": "المضيفون", "nav.agencies": "الوكالات", "nav.bds": "قائمة BD", "nav.bdManagers": "مديرو BD", "nav.superAdmins": "المشرفون العامون", "nav.rooms": "إدارة الغرف", "nav.grants": "الموارد والمعرّفات المميزة", "nav.banners": "إدارة اللافتات", "nav.team": "فريقي", @@ -357,19 +355,19 @@ const ar = { "bans.title": "قائمة الحسابات المحظورة", "bans.reason": "سبب الحظر", "bans.unban": "إلغاء الحظر", "bans.unbanned": "تم إلغاء حظر المستخدم", "bans.unbanFailed": "فشل إلغاء حظر المستخدم", "bans.confirmTitle": "إلغاء حظر المستخدم", "bans.confirmContent": "هل تريد إلغاء حظر {user}؟", "organization.title": "المؤسسة", "organization.agencies": "قائمة الوكالات", "organization.bds": "قائمة BD", "organization.bdManagers": "قائمة مديري BD", "organization.hosts": "قائمة المضيفين", "organization.managers": "قائمة المديرين", "organization.superAdmins": "قائمة المشرفين العامين", "organization.team": "فريقي", "organization.agency": "الوكالة", "organization.owner": "المالك", "organization.parentBd": "BD الأعلى", "organization.member": "العضو", "organization.country": "الدولة / المنطقة", "organization.userReference": "المستخدم {id}", "organization.agencyReference": "الوكالة {id}", "organization.teamSearch": "معرّف الوكالة / معرّف المالك / معرّف BD الأعلى", "organization.noPermission": "لا يملك هذا الحساب صلاحية الوصول إلى قائمة المؤسسة هذه.", "organization.bdLeaderCount": "قادة BD: {count}", "organization.bdCount": "BD: {count}", "organization.agencyCount": "الوكالات: {count}", "organization.truncated": "يتجاوز حجم الفريق حد الواجهة. النتائج الحالية غير مكتملة.", "rooms.title": "إدارة الغرف", "rooms.room": "الغرفة", "rooms.owner": "المالك", "rooms.visibleRegion": "المنطقة المرئية", "rooms.search": "معرّف الغرفة / الاسم / المالك", "rooms.updated": "تم تحديث بيانات الغرفة", "rooms.updateFailed": "فشل تحديث الغرفة", "rooms.editTitle": "تعديل الغرفة", "rooms.background": "خلفية الغرفة", "rooms.name": "اسم الغرفة", "rooms.description": "وصف الغرفة", "rooms.visibleRegionId": "معرّف المنطقة المرئية", "rooms.allRegions": "الكل", - "grants.title": "الموارد والمعرّفات المميزة", "grants.grantResource": "منح عنصر", "grants.grantPrettyId": "منح معرّف مميز", "grants.availableResources": "الموارد المتاحة", "grants.targetUser": "المستخدم المستهدف", "grants.resource": "المورد", "grants.quantity": "الكمية", "grants.resourceGranted": "تم منح عنصر الامتياز", "grants.prettyIdGranted": "تم منح المعرّف المميز", "grants.failed": "فشل المنح", "grants.resourceTitle": "منح عنصر امتياز", "grants.prettyIdTitle": "منح معرّف مميز", "grants.target": "معرّف المستخدم / المعرّف المميز", "grants.currentTarget": "معرّف المستخدم / المعرّف المميز الحالي", "grants.prettyId": "المعرّف المميز المراد منحه", "grants.validDays": "الصلاحية (بالأيام)", "grants.permanentHint": "أدخل 0 للصلاحية الدائمة", "grants.reason": "سبب المنح", "grants.notice": "قبل الإرسال، يتم التحقق من المستخدم داخل التطبيق الحالي ويُستخدم معرّف المستخدم الذي يعيده الخادم.", "grants.confirm": "تأكيد المنح", + "grants.title": "الموارد والمعرّفات المميزة", "grants.grantResource": "منح عنصر", "grants.grantPrettyId": "منح معرّف مميز", "grants.availableResources": "الموارد المتاحة", "grants.targetUser": "المستخدم المستهدف", "grants.resource": "المورد", "grants.quantity": "الكمية", "grants.resourceGranted": "تم منح عنصر الامتياز", "grants.prettyIdGranted": "تم منح المعرّف المميز", "grants.failed": "فشل المنح", "grants.resourceTitle": "منح عنصر امتياز", "grants.prettyIdTitle": "منح معرّف مميز", "grants.target": "معرّف المستخدم / المعرّف المميز", "grants.currentTarget": "معرّف المستخدم / المعرّف المميز الحالي", "grants.prettyId": "المعرّف المميز المراد منحه", "grants.validDays": "الصلاحية (بالأيام)", "grants.permanentHint": "أدخل 0 للصلاحية الدائمة", "grants.reason": "سبب المنح", "grants.confirm": "تأكيد المنح", "banners.title": "إدارة اللافتات", "banners.banner": "اللافتة", "banners.type": "النوع", "banners.platform": "المنصة", "banners.sort": "الترتيب", "banners.create": "إنشاء لافتة", "banners.created": "تم إنشاء اللافتة", "banners.createFailed": "فشل إنشاء اللافتة", "banners.image": "صورة اللافتة", "banners.roomImage": "الصورة المصغرة داخل الغرفة", "banners.description": "الوصف", "banners.jumpType": "نوع الوجهة", "banners.h5Param": "المعامل (رابط H5)", "banners.appParam": "المعامل (JSON للتنقل داخل التطبيق)", "banners.displayScope": "نطاق العرض", "banners.scopeHome": "الرئيسية", "banners.scopeRoom": "الغرفة", "banners.scopeRecharge": "الشحن", "banners.scopeMe": "حسابي", "banners.regionId": "معرّف المنطقة", "banners.countryCode": "رمز الدولة", "banners.countryCodeHint": "حرفان أو 3؛ اتركه فارغًا للكل", "banners.schedule": "جدول العرض", "upload.preview": "معاينة {label}", "upload.empty": "لا توجد صورة", "upload.uploading": "جارٍ الرفع", "upload.replace": "استبدال الصورة", "upload.select": "رفع صورة", "upload.imageOnly": "اختر ملف صورة", "upload.sizeLimit": "يجب ألا يتجاوز حجم الصورة 10 ميغابايت", "upload.missingUrl": "لا تحتوي استجابة الرفع على رابط للصورة", "upload.failed": "فشل رفع الصورة", "pagination.total": "{count} عنصر", "pagination.previous": "السابق", "pagination.next": "التالي", "status.active": "نشط", "status.enabled": "مفعّل", "status.running": "قيد التشغيل", "status.normal": "طبيعي", "status.unbanned": "غير محظور", "status.banned": "محظور", "status.disabled": "معطّل", "status.failed": "فشل", "status.blocked": "محجوب", "status.expired": "منتهي", - "jump.appTarget": "وجهة التطبيق", "jump.target.wallet": "المحفظة", "jump.target.room": "غرفة محددة", "jump.target.room_random": "غرفة عشوائية", "jump.target.room_window": "نافذة في غرفة محددة", "jump.target.room_random_window": "نافذة في غرفة عشوائية", "jump.target.room_game": "لعبة في غرفة محددة", "jump.target.room_random_game": "لعبة في غرفة عشوائية", "jump.target.explore_game": "لعبة Explore H5", "jump.target.custom": "JSON مخصص", "jump.roomId": "معرّف الغرفة", "jump.window": "نافذة داخل الغرفة", "jump.window.game_window": "نافذة اللعبة", "jump.window.gift_panel": "لوحة الهدايا", "jump.window.red_packet_claim": "نافذة الظرف الأحمر", "jump.window.wheel": "نافذة العجلة", "jump.gameId": "معرّف اللعبة", "jump.giftId": "معرّف الهدية", "jump.redPacketNo": "رقم الظرف الأحمر", "jump.hint": "يتم إنشاء معاملات التنقل داخل التطبيق بصيغة JSON، ويمكنك تعديلها مباشرة للمعاملات المخصصة.", + "jump.appTarget": "وجهة التطبيق", "jump.target.wallet": "المحفظة", "jump.target.room": "غرفة محددة", "jump.target.room_random": "غرفة عشوائية", "jump.target.room_window": "نافذة في غرفة محددة", "jump.target.room_random_window": "نافذة في غرفة عشوائية", "jump.target.room_game": "لعبة في غرفة محددة", "jump.target.room_random_game": "لعبة في غرفة عشوائية", "jump.target.explore_game": "لعبة Explore H5", "jump.target.custom": "JSON مخصص", "jump.roomId": "معرّف الغرفة", "jump.window": "نافذة داخل الغرفة", "jump.window.game_window": "نافذة اللعبة", "jump.window.gift_panel": "لوحة الهدايا", "jump.window.red_packet_claim": "نافذة الظرف الأحمر", "jump.window.wheel": "نافذة العجلة", "jump.gameId": "معرّف اللعبة", "jump.giftId": "معرّف الهدية", "jump.redPacketNo": "رقم الظرف الأحمر", "time.unlimited": "أي وقت", "time.quickHour": "آخر ساعة", "time.quickDay": "آخر 24 ساعة", "time.quickWeek": "آخر 7 أيام", "time.start": "وقت البدء", "time.end": "وقت الانتهاء", "time.startDate": "تاريخ البدء", "time.endDate": "تاريخ الانتهاء", "time.pending": "غير محدد", "time.startMark": "ب", "time.endMark": "ن", "time.previousMonth": "الشهر السابق", "time.nextMonth": "الشهر التالي", "time.hour": "الساعة", "time.minute": "الدقيقة", "time.clear": "مسح", "time.apply": "تطبيق", "time.endAfterStart": "يجب أن يكون وقت الانتهاء بعد وقت البدء", "time.endDateAfterStart": "لا يمكن أن يسبق تاريخ الانتهاء تاريخ البدء", "time.completeRange": "حدد النطاق الكامل", "time.endLaterShort": "يجب أن تكون النهاية بعد البداية", "time.endDateLaterShort": "لا يمكن أن تسبق النهاية البداية", "time.durationDays": "{count} يوم", "time.durationHours": "{count} ساعة", "time.durationMinutes": "{count} دقيقة", "time.duration": "{value}", "time.sameDay": "اليوم نفسه", "time.lessMinute": "أقل من دقيقة واحدة", "errors.loadFailed": "فشل تحميل البيانات", "errors.requestFailed": "فشل الطلب", "errors.unauthorized": "انتهت الجلسة. سجّل الدخول مرة أخرى.", "errors.forbidden": "ليس لديك إذن لتنفيذ هذه العملية.", "errors.notFound": "لم يتم العثور على المورد المطلوب.", "errors.conflict": "تتعارض العملية مع البيانات الحالية. حدّث الصفحة وحاول مجددًا.", "errors.tooLarge": "حجم الطلب كبير جدًا.", "errors.rateLimited": "طلبات كثيرة جدًا. حاول لاحقًا.", "errors.server": "الخدمة غير متاحة مؤقتًا. حاول لاحقًا.", "errors.accountLocked": "الحساب مقفل مؤقتًا. حاول لاحقًا.", "errors.appInactive": "التطبيق المرتبط بهذا الحساب غير نشط.", "errors.vipDisabled": "نظام VIP معطّل في التطبيق الحالي", "errors.vipLevelInvalid": "اختر مستوى VIP معدًا للتطبيق الحالي", "errors.organizationRoute": "لا توجد نقطة بيانات آمنة لقائمة المؤسسة هذه", "errors.regionId": "يجب أن يكون معرّف المنطقة عددًا صحيحًا آمنًا وغير سالب", "errors.grantTarget": "لم يتم العثور على المستخدم المستهدف في التطبيق الحالي", "errors.bannerType": "نوع اللافتة غير صالح", "errors.bannerPlatform": "منصة اللافتة غير صالحة", "errors.bannerStatus": "حالة اللافتة غير صالحة", "errors.bannerScope": "اختر نطاق عرض واحدًا على الأقل", "errors.bannerImage": "ارفع صورة للافتة", "errors.h5Url": "أدخل رابط H5 صالحًا", "errors.h5Protocol": "يجب أن يستخدم رابط H5 بروتوكول http أو https", "errors.appParamRequired": "أدخل معاملات التنقل داخل التطبيق", "errors.appParamObject": "يجب أن تكون معاملات التطبيق كائن JSON", "errors.appParamType": "نوع التنقل داخل التطبيق غير مدعوم", "errors.appParamRoom": "تتطلب هذه الوجهة room_id", "errors.appParamGame": "تتطلب هذه الوجهة game_id", "errors.appParamWindow": "تتطلب هذه الوجهة نافذة صالحة", "errors.appParamGameCode": "تتطلب لعبة Explore H5 قيمة game_code صالحة", "errors.roomBannerImage": "ارفع صورة مصغرة لنطاق الغرفة", "errors.countryCode": "يجب أن يتكون رمز الدولة من حرفين أو 3", "errors.bannerRegion": "معرّف المنطقة غير صالح", "errors.bannerSchedule": "يجب أن يكون وقت انتهاء العرض بعد وقت البدء" }; const tr = { ...en, - "app.title": "HYApp Harici Yönetim", "app.shortTitle": "HYApp Harici Yönetim", "language.label": "Dil", + "app.title": "Fami", "app.shortTitle": "Fami", "language.label": "Dil", "common.actions": "İşlemler", "common.active": "Aktif", "common.all": "Tümü", "common.allStatuses": "Tüm durumlar", "common.banned": "Yasaklı", "common.cancel": "İptal", "common.confirm": "Onayla", "common.create": "Oluştur", "common.disabled": "Devre dışı", "common.edit": "Düzenle", "common.enabled": "Etkin", "common.expired": "Süresi dolmuş", "common.loading": "Yükleniyor", "common.noData": "Veri yok", "common.normal": "Normal", "common.query": "Ara", "common.reason": "Neden", "common.reload": "Yeniden yükle", "common.retry": "Tekrar dene", "common.save": "Kaydet", "common.status": "Durum", "common.user": "Kullanıcı", "auth.account": "Harici yönetim hesabı", "auth.changePassword": "Harici yönetim parolasını değiştir", "auth.checkingSession": "Oturum kontrol ediliyor", "auth.confirmNewPassword": "Yeni parolayı doğrula", "auth.currentPassword": "Mevcut parola", "auth.fillAccountPassword": "Hesabınızı ve parolanızı girin", "auth.firstLoginChange": "İlk girişte parolanızı değiştirmeniz gerekir", "auth.hidePassword": "Parolayı gizle", "auth.invalidCredentials": "Hesap veya parola hatalı", "auth.login": "Giriş yap", "auth.loginFailed": "Giriş başarısız", "auth.logout": "Çıkış yap", "auth.newPassword": "Yeni parola", "auth.noPermission": "Bu harici yönetim hesabının bu özelliğe erişimi yok.", "auth.password": "Parola", "auth.passwordChangedFailed": "Parola değiştirilemedi", "auth.passwordMismatch": "Yeni parolalar eşleşmiyor", "auth.passwordMin": "En az 8 karakter", "auth.passwordMinError": "Yeni parola en az 8 karakter olmalıdır", "auth.passwordWhitespace": "Yeni parola boş veya yalnızca boşluk olamaz", "auth.recheck": "Tekrar kontrol et", "auth.returnOverview": "Yetki özetine dön", "auth.savePassword": "Parolayı kaydet", "auth.sessionFailed": "Oturum doğrulanamadı", "auth.showPassword": "Parolayı göster", "nav.expand": "Menüyü aç", "nav.overview": "Yetki özeti", "nav.users": "Kullanıcı yönetimi", "nav.bans": "Yasak yönetimi", "nav.hosts": "Yayıncılar", "nav.agencies": "Ajanslar", "nav.bds": "BD listesi", "nav.bdManagers": "BD Yöneticileri", "nav.superAdmins": "Süper Yöneticiler", "nav.rooms": "Oda yönetimi", "nav.grants": "Kaynaklar ve özel ID'ler", "nav.banners": "Banner yönetimi", "nav.team": "Ekibim", @@ -379,12 +377,12 @@ const tr = { "bans.title": "Yasaklı hesap listesi", "bans.reason": "Yasak nedeni", "bans.unban": "Yasağı kaldır", "bans.unbanned": "Kullanıcı yasağı kaldırıldı", "bans.unbanFailed": "Kullanıcı yasağı kaldırılamadı", "bans.confirmTitle": "Kullanıcı yasağını kaldır", "bans.confirmContent": "{user} kullanıcısının yasağı kaldırılsın mı?", "organization.title": "Organizasyon", "organization.agencies": "Ajans listesi", "organization.bds": "BD listesi", "organization.bdManagers": "BD Yöneticisi listesi", "organization.hosts": "Yayıncı listesi", "organization.managers": "Yönetici listesi", "organization.superAdmins": "Süper Yönetici listesi", "organization.team": "Ekibim", "organization.agency": "Ajans", "organization.owner": "Sahip", "organization.parentBd": "Üst BD", "organization.member": "Üye", "organization.country": "Ülke / bölge", "organization.userReference": "Kullanıcı {id}", "organization.agencyReference": "Ajans {id}", "organization.teamSearch": "Ajans ID / sahip kullanıcı ID / üst BD ID", "organization.noPermission": "Bu hesabın bu organizasyon listesine erişimi yok.", "organization.bdLeaderCount": "BD Liderleri: {count}", "organization.bdCount": "BD: {count}", "organization.agencyCount": "Ajanslar: {count}", "organization.truncated": "Ekip API sınırını aşıyor. Mevcut sonuçlar eksik.", "rooms.title": "Oda yönetimi", "rooms.room": "Oda", "rooms.owner": "Sahip", "rooms.visibleRegion": "Görünür bölge", "rooms.search": "Oda ID / ad / sahip", "rooms.updated": "Oda bilgileri güncellendi", "rooms.updateFailed": "Oda güncellenemedi", "rooms.editTitle": "Odayı düzenle", "rooms.background": "Oda arka planı", "rooms.name": "Oda adı", "rooms.description": "Oda açıklaması", "rooms.visibleRegionId": "Görünür bölge ID", "rooms.allRegions": "Tümü", - "grants.title": "Kaynaklar ve özel ID'ler", "grants.grantResource": "Öğe ver", "grants.grantPrettyId": "Özel ID ver", "grants.availableResources": "Kullanılabilir kaynaklar", "grants.targetUser": "Hedef kullanıcı", "grants.resource": "Kaynak", "grants.quantity": "Miktar", "grants.resourceGranted": "Ayrıcalık öğesi verildi", "grants.prettyIdGranted": "Özel ID verildi", "grants.failed": "Verme işlemi başarısız", "grants.resourceTitle": "Ayrıcalık öğesi ver", "grants.prettyIdTitle": "Özel ID ver", "grants.target": "Kullanıcı ID / özel ID", "grants.currentTarget": "Kullanıcı ID / mevcut özel ID", "grants.prettyId": "Verilecek özel ID", "grants.validDays": "Geçerlilik (gün)", "grants.permanentHint": "Kalıcı geçerlilik için 0 girin", "grants.reason": "Verme nedeni", "grants.notice": "Göndermeden önce kullanıcı geçerli uygulamada doğrulanır ve sunucunun döndürdüğü kullanıcı ID kullanılır.", "grants.confirm": "Vermeyi onayla", + "grants.title": "Kaynaklar ve özel ID'ler", "grants.grantResource": "Öğe ver", "grants.grantPrettyId": "Özel ID ver", "grants.availableResources": "Kullanılabilir kaynaklar", "grants.targetUser": "Hedef kullanıcı", "grants.resource": "Kaynak", "grants.quantity": "Miktar", "grants.resourceGranted": "Ayrıcalık öğesi verildi", "grants.prettyIdGranted": "Özel ID verildi", "grants.failed": "Verme işlemi başarısız", "grants.resourceTitle": "Ayrıcalık öğesi ver", "grants.prettyIdTitle": "Özel ID ver", "grants.target": "Kullanıcı ID / özel ID", "grants.currentTarget": "Kullanıcı ID / mevcut özel ID", "grants.prettyId": "Verilecek özel ID", "grants.validDays": "Geçerlilik (gün)", "grants.permanentHint": "Kalıcı geçerlilik için 0 girin", "grants.reason": "Verme nedeni", "grants.confirm": "Vermeyi onayla", "banners.title": "Banner yönetimi", "banners.banner": "Banner", "banners.type": "Tür", "banners.platform": "Platform", "banners.sort": "Sıralama", "banners.create": "Banner oluştur", "banners.created": "Banner oluşturuldu", "banners.createFailed": "Banner oluşturulamadı", "banners.image": "Banner görseli", "banners.roomImage": "Oda içi küçük görsel", "banners.description": "Açıklama", "banners.jumpType": "Hedef türü", "banners.h5Param": "Parametre (H5 bağlantısı)", "banners.appParam": "Parametre (uygulama yönlendirme JSON'u)", "banners.displayScope": "Gösterim kapsamı", "banners.scopeHome": "Ana sayfa", "banners.scopeRoom": "Oda", "banners.scopeRecharge": "Yükleme", "banners.scopeMe": "Ben", "banners.regionId": "Bölge ID", "banners.countryCode": "Ülke kodu", "banners.countryCodeHint": "2–3 harf; tümü için boş bırakın", "banners.schedule": "Gösterim zamanı", "upload.preview": "{label} önizlemesi", "upload.empty": "Görsel yok", "upload.uploading": "Yükleniyor", "upload.replace": "Görseli değiştir", "upload.select": "Görsel yükle", "upload.imageOnly": "Bir görsel dosyası seçin", "upload.sizeLimit": "Görseller 10 MB'ı aşamaz", "upload.missingUrl": "Yükleme yanıtında görsel URL'si yok", "upload.failed": "Görsel yüklenemedi", "pagination.total": "{count} öğe", "pagination.previous": "Önceki", "pagination.next": "Sonraki", "status.active": "Aktif", "status.enabled": "Etkin", "status.running": "Çalışıyor", "status.normal": "Normal", "status.unbanned": "Yasaksız", "status.banned": "Yasaklı", "status.disabled": "Devre dışı", "status.failed": "Başarısız", "status.blocked": "Engelli", "status.expired": "Süresi dolmuş", - "jump.appTarget": "Uygulama hedefi", "jump.target.wallet": "Cüzdan", "jump.target.room": "Belirli oda", "jump.target.room_random": "Rastgele oda", "jump.target.room_window": "Belirli odada pencere", "jump.target.room_random_window": "Rastgele odada pencere", "jump.target.room_game": "Belirli odada oyun", "jump.target.room_random_game": "Rastgele odada oyun", "jump.target.explore_game": "Explore oyunu H5", "jump.target.custom": "Özel JSON", "jump.roomId": "Oda ID", "jump.window": "Oda içi pencere", "jump.window.game_window": "Oyun penceresi", "jump.window.gift_panel": "Hediye paneli", "jump.window.red_packet_claim": "Kırmızı paket penceresi", "jump.window.wheel": "Çark penceresi", "jump.gameId": "Oyun ID", "jump.giftId": "Hediye ID", "jump.redPacketNo": "Kırmızı paket numarası", "jump.hint": "Uygulama yönlendirme parametreleri JSON olarak oluşturulur; özel parametreler için JSON'u doğrudan düzenleyebilirsiniz.", + "jump.appTarget": "Uygulama hedefi", "jump.target.wallet": "Cüzdan", "jump.target.room": "Belirli oda", "jump.target.room_random": "Rastgele oda", "jump.target.room_window": "Belirli odada pencere", "jump.target.room_random_window": "Rastgele odada pencere", "jump.target.room_game": "Belirli odada oyun", "jump.target.room_random_game": "Rastgele odada oyun", "jump.target.explore_game": "Explore oyunu H5", "jump.target.custom": "Özel JSON", "jump.roomId": "Oda ID", "jump.window": "Oda içi pencere", "jump.window.game_window": "Oyun penceresi", "jump.window.gift_panel": "Hediye paneli", "jump.window.red_packet_claim": "Kırmızı paket penceresi", "jump.window.wheel": "Çark penceresi", "jump.gameId": "Oyun ID", "jump.giftId": "Hediye ID", "jump.redPacketNo": "Kırmızı paket numarası", "time.unlimited": "Sınırsız", "time.quickHour": "Son 1 saat", "time.quickDay": "Son 24 saat", "time.quickWeek": "Son 7 gün", "time.start": "Başlangıç zamanı", "time.end": "Bitiş zamanı", "time.startDate": "Başlangıç tarihi", "time.endDate": "Bitiş tarihi", "time.pending": "Seçilmedi", "time.startMark": "B", "time.endMark": "S", "time.previousMonth": "Önceki ay", "time.nextMonth": "Sonraki ay", "time.hour": "Saat", "time.minute": "Dakika", "time.clear": "Temizle", "time.apply": "Uygula", "time.endAfterStart": "Bitiş zamanı başlangıçtan sonra olmalıdır", "time.endDateAfterStart": "Bitiş tarihi başlangıçtan önce olamaz", "time.completeRange": "Tam aralığı seçin", "time.endLaterShort": "Bitiş başlangıçtan sonra olmalı", "time.endDateLaterShort": "Bitiş başlangıçtan önce olamaz", "time.durationDays": "{count} gün", "time.durationHours": "{count} saat", "time.durationMinutes": "{count} dakika", "time.duration": "{value}", "time.sameDay": "Aynı gün", "time.lessMinute": "1 dakikadan az", "errors.loadFailed": "Veriler yüklenemedi", "errors.requestFailed": "İstek başarısız", "errors.unauthorized": "Oturumunuz sona erdi. Tekrar giriş yapın.", "errors.forbidden": "Bu işlemi yapma yetkiniz yok.", "errors.notFound": "İstenen kaynak bulunamadı.", "errors.conflict": "İşlem mevcut verilerle çakışıyor. Yenileyip tekrar deneyin.", "errors.tooLarge": "İstek çok büyük.", "errors.rateLimited": "Çok fazla istek gönderildi. Daha sonra tekrar deneyin.", "errors.server": "Hizmet geçici olarak kullanılamıyor. Daha sonra tekrar deneyin.", "errors.accountLocked": "Hesap geçici olarak kilitlendi. Daha sonra tekrar deneyin.", "errors.appInactive": "Bu hesaba bağlı uygulama etkin değil.", "errors.vipDisabled": "Geçerli uygulamanın VIP programı devre dışı", "errors.vipLevelInvalid": "Geçerli uygulama için yapılandırılmış bir VIP seviyesi seçin", "errors.organizationRoute": "Bu organizasyon listesi için güvenli veri uç noktası yok", "errors.regionId": "Görünür bölge ID güvenli ve negatif olmayan bir tam sayı olmalıdır", "errors.grantTarget": "Geçerli uygulamada hedef kullanıcı bulunamadı", "errors.bannerType": "Geçersiz banner türü", "errors.bannerPlatform": "Geçersiz banner platformu", "errors.bannerStatus": "Geçersiz banner durumu", "errors.bannerScope": "En az bir gösterim kapsamı seçin", "errors.bannerImage": "Bir banner görseli yükleyin", "errors.h5Url": "Geçerli bir H5 bağlantısı girin", "errors.h5Protocol": "H5 bağlantıları http veya https kullanmalıdır", "errors.appParamRequired": "Uygulama yönlendirme parametrelerini girin", "errors.appParamObject": "Uygulama parametreleri bir JSON nesnesi olmalıdır", "errors.appParamType": "Uygulama yönlendirme türü desteklenmiyor", "errors.appParamRoom": "Bu hedef room_id gerektirir", "errors.appParamGame": "Bu hedef game_id gerektirir", "errors.appParamWindow": "Bu hedef geçerli bir window gerektirir", "errors.appParamGameCode": "Explore oyunu H5 geçerli bir game_code gerektirir", "errors.roomBannerImage": "Oda kapsamı için oda içi küçük görsel yükleyin", "errors.countryCode": "Ülke kodu 2–3 harften oluşmalıdır", "errors.bannerRegion": "Geçersiz bölge ID", "errors.bannerSchedule": "Gösterim bitiş zamanı başlangıçtan sonra olmalıdır" }; diff --git a/external-admin/src/i18n/messages.test.js b/external-admin/src/i18n/messages.test.js index 7fa4dbb..08ee1cf 100644 --- a/external-admin/src/i18n/messages.test.js +++ b/external-admin/src/i18n/messages.test.js @@ -13,6 +13,10 @@ describe("external admin translations", () => { test("uses native language names and Arabic RTL only", () => { expect(EXTERNAL_LANGUAGE_OPTIONS.map((item) => item.label)).toEqual(["English", "简体中文", "العربية", "Türkçe"]); + for (const locale of ["en", "zh-CN", "ar", "tr"]) { + expect(externalMessage(locale, "app.title")).toBe("Fami"); + expect(externalMessage(locale, "app.shortTitle")).toBe("Fami"); + } expect(externalDirection("ar")).toBe("rtl"); expect(externalDirection("en")).toBe("ltr"); expect(externalDirection("zh-CN")).toBe("ltr"); diff --git a/external-admin/src/layout/ExternalAdminLayout.jsx b/external-admin/src/layout/ExternalAdminLayout.jsx index 3ff4361..5354b28 100644 --- a/external-admin/src/layout/ExternalAdminLayout.jsx +++ b/external-admin/src/layout/ExternalAdminLayout.jsx @@ -27,8 +27,9 @@ import Tooltip from "@mui/material/Tooltip"; import Typography from "@mui/material/Typography"; import useMediaQuery from "@mui/material/useMediaQuery"; import { useTheme } from "@mui/material/styles"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom"; +import { AppIdentity } from "@/shared/ui/AppIdentity.jsx"; import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; import { hasAnyExternalCapability } from "../config/capabilities.js"; import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; @@ -64,6 +65,19 @@ export function ExternalAdminLayout() { ); const current = [...visibleNavigation].reverse().find((item) => location.pathname.startsWith(item.path)); + useEffect(() => { + // Login resolves the account's App server-side. Both the visible brand and browser metadata must use that apps row rather than a bundled image or client-selected tenant. + const appName = String(session?.appName || session?.appCode || t("app.title")).trim(); + const logoUrl = String(session?.logoUrl || "").trim(); + globalThis.document.title = appName; + syncBrandFavicon(logoUrl); + return () => { + // Leaving an authenticated App session must not leak its brand into the next account's login screen. + globalThis.document.title = t("app.title"); + syncBrandFavicon(""); + }; + }, [session?.appCode, session?.appName, session?.logoUrl, t]); + const handleLogout = async () => { await logout(); navigate("/login", { replace: true }); @@ -72,11 +86,14 @@ export function ExternalAdminLayout() { const drawer = ( - HY - - {t("app.shortTitle")} - {session?.appName || session?.appCode} - + @@ -140,3 +157,19 @@ export function ExternalAdminLayout() { ); } + +function syncBrandFavicon(logoUrl) { + const selector = 'link[data-external-app-favicon="true"]'; + const current = globalThis.document?.head?.querySelector(selector); + if (!logoUrl) { + current?.remove(); + return; + } + const link = current || globalThis.document.createElement("link"); + link.dataset.externalAppFavicon = "true"; + link.rel = "icon"; + link.href = logoUrl; + if (!current) { + globalThis.document.head.append(link); + } +} diff --git a/external-admin/src/layout/ExternalAdminLayout.test.jsx b/external-admin/src/layout/ExternalAdminLayout.test.jsx index 525e67b..6f158b9 100644 --- a/external-admin/src/layout/ExternalAdminLayout.test.jsx +++ b/external-admin/src/layout/ExternalAdminLayout.test.jsx @@ -12,6 +12,7 @@ const auth = vi.hoisted(() => ({ account: "fami-manager", appCode: "fami", appName: "Fami", + logoUrl: "https://media.example.com/apps/fami.png", capabilities: ["user:list", "room:list"] } })); @@ -24,6 +25,13 @@ describe("ExternalAdminLayout RTL", () => { beforeEach(() => { localStorage.setItem(EXTERNAL_LOCALE_STORAGE_KEY, "ar"); viewport.mobile = false; + auth.session = { + account: "fami-manager", + appCode: "fami", + appName: "Fami", + logoUrl: "https://media.example.com/apps/fami.png", + capabilities: ["user:list", "room:list"] + }; }); test("opens the mobile navigation from the right in Arabic", async () => { @@ -40,7 +48,7 @@ describe("ExternalAdminLayout RTL", () => { }); test("places the desktop navigation drawer on the right for Arabic", () => { - renderLayout(); + const view = renderLayout(); expect(document.documentElement).toHaveAttribute("dir", "rtl"); expect(document.documentElement).toHaveAttribute("lang", "ar"); @@ -52,8 +60,37 @@ describe("ExternalAdminLayout RTL", () => { .find((cssText) => cssText.includes(`.${generatedClass}`)); expect(drawerRule).toContain("right: 0px"); expect(drawerRule).toContain("border-left:"); + expect(document.querySelector(".external-sidebar-brand img")).toHaveAttribute("src", "https://media.example.com/apps/fami.png"); + expect(screen.getAllByText("Fami").length).toBeGreaterThan(0); + expect(screen.queryByText("إدارة HYApp الخارجية")).not.toBeInTheDocument(); + expect(document.title).toBe("Fami"); + expect(document.head.querySelector('link[data-external-app-favicon="true"]')).toHaveAttribute("href", "https://media.example.com/apps/fami.png"); expect(screen.getAllByText("ملخص الصلاحيات").length).toBeGreaterThan(0); expect(screen.getByRole("combobox", { name: "اللغة" })).toHaveTextContent("العربية"); + + view.unmount(); + expect(document.title).toBe("Fami"); + expect(document.head.querySelector('link[data-external-app-favicon="true"]')).not.toBeInTheDocument(); + }); + + test("uses the App name, logo and title resolved by the authenticated session", () => { + auth.session = { + ...auth.session, + account: "lalu-manager", + appCode: "lalu", + appName: "Lalu", + logoUrl: "https://media.example.com/apps/lalu.png" + }; + const view = renderLayout(); + + expect(screen.getAllByText("Lalu").length).toBeGreaterThan(0); + expect(document.querySelector(".external-sidebar-brand img")).toHaveAttribute("src", "https://media.example.com/apps/lalu.png"); + expect(document.title).toBe("Lalu"); + expect(document.head.querySelector('link[data-external-app-favicon="true"]')).toHaveAttribute("href", "https://media.example.com/apps/lalu.png"); + + view.unmount(); + expect(document.title).toBe("Fami"); + expect(document.head.querySelector('link[data-external-app-favicon="true"]')).not.toBeInTheDocument(); }); }); diff --git a/external-admin/src/pages/LoginPage.jsx b/external-admin/src/pages/LoginPage.jsx index b58ff84..c9a2747 100644 --- a/external-admin/src/pages/LoginPage.jsx +++ b/external-admin/src/pages/LoginPage.jsx @@ -1,8 +1,6 @@ -import LockOutlined from "@mui/icons-material/LockOutlined"; import VisibilityOffOutlined from "@mui/icons-material/VisibilityOffOutlined"; import VisibilityOutlined from "@mui/icons-material/VisibilityOutlined"; import Alert from "@mui/material/Alert"; -import Avatar from "@mui/material/Avatar"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import Card from "@mui/material/Card"; @@ -62,7 +60,6 @@ export default function LoginPage() { - {t("app.title")} diff --git a/external-admin/src/pages/LoginPage.test.jsx b/external-admin/src/pages/LoginPage.test.jsx index bac99f1..eb4ed36 100644 --- a/external-admin/src/pages/LoginPage.test.jsx +++ b/external-admin/src/pages/LoginPage.test.jsx @@ -20,7 +20,8 @@ describe("LoginPage", () => { const user = userEvent.setup(); const { container } = renderLogin(); - expect(screen.getByRole("heading", { name: "HYApp External Admin" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Fami" })).toBeInTheDocument(); + expect(container.querySelector("img")).toBeNull(); expect(screen.queryByRole("combobox", { name: "App" })).not.toBeInTheDocument(); await user.type(screen.getByRole("textbox", { name: "External admin account" }), "fami-manager"); await user.type(container.querySelector('input[autocomplete="current-password"]'), "secret-password"); diff --git a/external-admin/src/styles/index.css b/external-admin/src/styles/index.css index df45aae..d0ed2ac 100644 --- a/external-admin/src/styles/index.css +++ b/external-admin/src/styles/index.css @@ -35,8 +35,7 @@ body { min-width: 140px !important; } -.external-login-avatar, -.external-logo-mark { +.external-login-avatar { background: var(--brand-gradient) !important; color: #fff; } @@ -89,14 +88,9 @@ body { padding: 12px 20px; } -.external-logo-mark { - width: 38px; - height: 38px; - display: grid; - place-items: center; - border-radius: 10px; - font-weight: 800; - box-shadow: var(--brand-shadow); +.external-sidebar-brand { + font-size: 16px; + font-weight: 750; } .external-nav { diff --git a/src/shared/ui/AppIdentity.jsx b/src/shared/ui/AppIdentity.jsx index 475f98a..702622e 100644 --- a/src/shared/ui/AppIdentity.jsx +++ b/src/shared/ui/AppIdentity.jsx @@ -2,7 +2,7 @@ import { useState } from "react"; import styles from "./AppIdentity.module.css"; // AppIdentity 是后台中 App 的唯一展示形式:业务页只传目录数据,避免各个下拉、表格和弹窗分别维护 Logo 规则。 -export function AppIdentity({ app, appCode, appName, className = "", logoUrl, size = "medium" }) { +export function AppIdentity({ app, appCode, appName, className = "", logoUrl, showLogoFallback = true, size = "medium" }) { // 主后台使用 camelCase,Social BI 的服务端聚合契约使用 snake_case;组件在展示边界统一兼容,业务视图不复制字段转换。 const resolvedCode = String(app?.appCode || app?.app_code || appCode || "").trim(); const resolvedName = String(app?.appName || app?.app_name || appName || resolvedCode || "-").trim(); @@ -10,13 +10,13 @@ export function AppIdentity({ app, appCode, appName, className = "", logoUrl, si return ( - + {resolvedName} ); } -function AppLogo({ appCode, appName, logoUrl }) { +function AppLogo({ appCode, appName, logoUrl, showFallback }) { const [failedLogoUrl, setFailedLogoUrl] = useState(""); const canRenderImage = Boolean(logoUrl) && failedLogoUrl !== logoUrl; @@ -32,6 +32,11 @@ function AppLogo({ appCode, appName, logoUrl }) { ); } + if (!showFallback) { + // 外管品牌只能展示 apps.logo_url 的真实图片;缺失或加载失败时不伪造一个看似品牌 Logo 的首字母图标。 + return null; + } + // 网络失败或历史数据暂无 Logo 时保留稳定占位,不让选择项高度和表格对齐抖动。 const initial = String(appName || appCode || "?") diff --git a/src/shared/ui/AppIdentity.test.jsx b/src/shared/ui/AppIdentity.test.jsx index 913afce..a4d3c88 100644 --- a/src/shared/ui/AppIdentity.test.jsx +++ b/src/shared/ui/AppIdentity.test.jsx @@ -29,3 +29,13 @@ test("supports the snake case Social BI app contract", () => { expect(screen.getByText("Aslan")).toBeInTheDocument(); expect(container.querySelector("img")).toHaveAttribute("src", "https://media.example.com/aslan.png"); }); + +test("can hide the fallback when a surface must only show a database logo", () => { + const { container } = render( + , + ); + + expect(container.querySelector("img")).toBeNull(); + expect(screen.getByText("Fami")).toBeInTheDocument(); + expect(screen.queryByText("F", { selector: "span" })).not.toBeInTheDocument(); +}); diff --git a/src/shared/ui/AppJumpConfigField.jsx b/src/shared/ui/AppJumpConfigField.jsx index 64a3121..f157b27 100644 --- a/src/shared/ui/AppJumpConfigField.jsx +++ b/src/shared/ui/AppJumpConfigField.jsx @@ -23,6 +23,7 @@ export function AppJumpConfigField({ onChange, paramValue, required = false, + showHint = true, typeLabel = "跳转类型", typeValue, }) { @@ -102,7 +103,7 @@ export function AppJumpConfigField({ )} - {type === "app" ? ( + {type === "app" && showHint ? (

{messages.hint || "APP 参数会按公共跳转方案生成 JSON;需要特殊参数时可直接编辑 JSON。"}

) : null} From e9c4595f023dd3690f7c775177ff28a023bfd4ec Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 15 Jul 2026 18:45:36 +0800 Subject: [PATCH 05/15] feat(external-admin): configure account permissions --- contracts/admin-openapi.json | 165 +++++++++++++- src/app/permissions.ts | 1 + src/features/external-admin-users/api.test.ts | 55 +++++ src/features/external-admin-users/api.ts | 95 ++++++++ .../ExternalAdminPermissionDialogs.test.jsx | 61 +++++ .../ExternalAdminPermissionDrawer.jsx | 60 +++++ .../ExternalAdminPermissionSelector.jsx | 144 ++++++++++++ .../ExternalAdminPermissionSelector.test.jsx | 60 +++++ .../ExternalAdminUserFormDialog.jsx | 28 ++- .../external-admin-users.module.css | 151 +++++++++++++ .../hooks/useExternalAdminUsersPage.js | 209 +++++++++++++++++- .../hooks/useExternalAdminUsersPage.test.jsx | 174 ++++++++++++++- .../pages/ExternalAdminUsersPage.jsx | 35 ++- .../pages/ExternalAdminUsersPage.test.jsx | 97 ++++++++ .../permissionSelection.js | 58 +++++ .../external-admin-users/permissions.js | 7 +- .../external-admin-users/permissions.test.jsx | 28 +++ .../external-admin-users/schema.test.ts | 13 ++ src/features/external-admin-users/schema.ts | 20 ++ src/shared/api/generated/endpoints.ts | 26 ++- src/shared/api/generated/schema.d.ts | 132 +++++++++++ src/shared/api/request.test.ts | 14 ++ src/shared/api/request.ts | 11 +- 23 files changed, 1629 insertions(+), 15 deletions(-) create mode 100644 src/features/external-admin-users/components/ExternalAdminPermissionDialogs.test.jsx create mode 100644 src/features/external-admin-users/components/ExternalAdminPermissionDrawer.jsx create mode 100644 src/features/external-admin-users/components/ExternalAdminPermissionSelector.jsx create mode 100644 src/features/external-admin-users/components/ExternalAdminPermissionSelector.test.jsx create mode 100644 src/features/external-admin-users/pages/ExternalAdminUsersPage.test.jsx create mode 100644 src/features/external-admin-users/permissionSelection.js create mode 100644 src/features/external-admin-users/permissions.test.jsx diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index 5e0167b..703b45c 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -6160,7 +6160,22 @@ } }, "x-permission": "external-admin-user:create", - "x-permissions": ["external-admin-user:create"] + "x-permissions": ["external-admin-user:create", "external-admin-user:permissions"] + } + }, + "/admin/external-admin-users/permission-catalog": { + "get": { + "operationId": "listExternalAdminPermissionCatalog", + "parameters": [ + { "$ref": "#/components/parameters/AppCodeHeader" } + ], + "responses": { + "200": { + "$ref": "#/components/responses/ExternalAdminPermissionCatalogResponse" + } + }, + "x-permission": "external-admin-user:permissions", + "x-permissions": ["external-admin-user:permissions"] } }, "/admin/external-admin-users/target": { @@ -6240,6 +6255,46 @@ "x-permissions": ["external-admin-user:reset-password"] } }, + "/admin/external-admin-users/{id}/permissions": { + "get": { + "operationId": "getExternalAdminUserPermissions", + "parameters": [ + { "$ref": "#/components/parameters/AppCodeHeader" }, + { "$ref": "#/components/parameters/Id" } + ], + "responses": { + "200": { + "$ref": "#/components/responses/ExternalAdminUserPermissionsResponse" + } + }, + "x-permission": "external-admin-user:permissions", + "x-permissions": ["external-admin-user:permissions"] + }, + "put": { + "operationId": "updateExternalAdminUserPermissions", + "parameters": [ + { "$ref": "#/components/parameters/AppCodeHeader" }, + { "$ref": "#/components/parameters/Id" } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalAdminUserPermissionsInput" + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/ExternalAdminUserResponse" + } + }, + "x-permission": "external-admin-user:permissions", + "x-permissions": ["external-admin-user:permissions"] + } + }, "/exports/app-users": { "post": { "operationId": "appExportUsers", @@ -8693,6 +8748,22 @@ } } }, + "ExternalAdminPermissionCatalogResponse": { + "description": "External admin permission catalog", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ApiResponseExternalAdminPermissionCatalog" } + } + } + }, + "ExternalAdminUserPermissionsResponse": { + "description": "External admin account permissions", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ApiResponseExternalAdminUserPermissions" } + } + } + }, "AgencyPageResponse": { "description": "OK", "content": { @@ -9569,6 +9640,28 @@ } ] }, + "ApiResponseExternalAdminPermissionCatalog": { + "allOf": [ + { "$ref": "#/components/schemas/Envelope" }, + { + "type": "object", + "properties": { + "data": { "$ref": "#/components/schemas/ExternalAdminPermissionCatalog" } + } + } + ] + }, + "ApiResponseExternalAdminUserPermissions": { + "allOf": [ + { "$ref": "#/components/schemas/Envelope" }, + { + "type": "object", + "properties": { + "data": { "$ref": "#/components/schemas/ExternalAdminUserPermissions" } + } + } + ] + }, "ApiPageExternalAdminUser": { "type": "object", "required": ["items", "page", "pageSize", "total"], @@ -9606,7 +9699,7 @@ }, "ExternalAdminUser": { "type": "object", - "required": ["id", "appCode", "username", "status", "linkedUser", "permissions", "createdAtMs"], + "required": ["id", "appCode", "username", "status", "linkedUser", "permissions", "permissionRevision", "createdAtMs"], "properties": { "id": { "type": "integer" }, "appCode": { "type": "string" }, @@ -9617,6 +9710,7 @@ "type": "array", "items": { "type": "string" } }, + "permissionRevision": { "type": "integer", "format": "int64", "minimum": 1 }, "createdByAdminId": { "type": "integer" }, "lastLoginAtMs": { "type": "integer", "format": "int64", "nullable": true }, "createdAtMs": { "type": "integer", "format": "int64" }, @@ -9635,7 +9729,72 @@ "maxLength": 64, "pattern": "^[A-Za-z0-9._-]+$" }, - "password": { "type": "string", "minLength": 8, "maxLength": 72 } + "password": { "type": "string", "minLength": 8, "maxLength": 72 }, + "permissions": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + } + } + }, + "ExternalAdminPermissionCatalogItem": { + "type": "object", + "required": ["code", "label", "group", "dependencies", "capabilities", "defaultGranted"], + "additionalProperties": false, + "properties": { + "code": { "type": "string", "minLength": 1 }, + "label": { "type": "string", "minLength": 1 }, + "group": { "type": "string", "minLength": 1 }, + "dependencies": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "capabilities": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "defaultGranted": { "type": "boolean" } + } + }, + "ExternalAdminPermissionCatalog": { + "type": "object", + "required": ["items", "total"], + "additionalProperties": false, + "properties": { + "items": { + "type": "array", + "items": { "$ref": "#/components/schemas/ExternalAdminPermissionCatalogItem" } + }, + "total": { "type": "integer", "minimum": 0 } + } + }, + "ExternalAdminUserPermissions": { + "type": "object", + "required": ["accountId", "permissions", "revision"], + "additionalProperties": false, + "properties": { + "accountId": { "type": "string", "minLength": 1 }, + "permissions": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "revision": { "type": "integer", "format": "int64", "minimum": 1 } + } + }, + "ExternalAdminUserPermissionsInput": { + "type": "object", + "required": ["permissions", "expectedRevision"], + "additionalProperties": false, + "properties": { + "permissions": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "expectedRevision": { "type": "integer", "format": "int64", "minimum": 1 } } }, "ExternalAdminUserStatusInput": { diff --git a/src/app/permissions.ts b/src/app/permissions.ts index ea6a475..1035e51 100644 --- a/src/app/permissions.ts +++ b/src/app/permissions.ts @@ -63,6 +63,7 @@ export const PERMISSIONS = { coinAdjustmentCreate: "coin-adjustment:create", externalAdminUserView: "external-admin-user:view", externalAdminUserCreate: "external-admin-user:create", + externalAdminUserPermissions: "external-admin-user:permissions", externalAdminUserStatus: "external-admin-user:status", externalAdminUserResetPassword: "external-admin-user:reset-password", reportView: "report:view", diff --git a/src/features/external-admin-users/api.test.ts b/src/features/external-admin-users/api.test.ts index bc3e742..da4bad5 100644 --- a/src/features/external-admin-users/api.test.ts +++ b/src/features/external-admin-users/api.test.ts @@ -2,10 +2,13 @@ import { afterEach, expect, test, vi } from "vitest"; import { setAccessToken, setSelectedAppCode } from "@/shared/api/request"; import { createExternalAdminUser, + getExternalAdminUserPermissions, listExternalAdminUsers, + listExternalAdminPermissionCatalog, lookupExternalAdminUserTarget, resetExternalAdminUserPassword, updateExternalAdminUserStatus, + updateExternalAdminUserPermissions, } from "./api"; afterEach(() => { @@ -14,6 +17,56 @@ afterEach(() => { vi.unstubAllGlobals(); }); +test("loads the backend-driven permission catalog and sends an explicit permission snapshot", async () => { + vi.stubGlobal( + "fetch", + vi.fn() + .mockResolvedValueOnce( + envelope({ + items: [ + { + capabilities: ["user:list"], + code: "user:list", + defaultGranted: true, + dependencies: [], + group: "用户管理", + label: "用户列表", + }, + ], + total: 1, + }), + ) + .mockResolvedValueOnce(envelope({ accountId: "71", permissions: ["user:list"], revision: 4 })) + .mockResolvedValueOnce(envelope({ ...externalUserFixture(), permissions: [] })), + ); + + const catalog = await listExternalAdminPermissionCatalog("fami"); + const current = await getExternalAdminUserPermissions("fami", 71); + const updated = await updateExternalAdminUserPermissions("fami", 71, [], 4); + + const calls = vi.mocked(fetch).mock.calls; + expect(calls.map(([, init]) => init?.method)).toEqual(["GET", "GET", "PUT"]); + expect(String(calls[0][0])).toContain("/api/v1/admin/external-admin-users/permission-catalog"); + expect(String(calls[1][0])).toContain("/api/v1/admin/external-admin-users/71/permissions"); + expect(String(calls[2][0])).toContain("/api/v1/admin/external-admin-users/71/permissions"); + expect(requestBody(calls[2][1])).toEqual({ expectedRevision: 4, permissions: [] }); + expect(catalog).toEqual({ + items: [ + { + capabilities: ["user:list"], + code: "user:list", + defaultGranted: true, + dependencies: [], + group: "用户管理", + label: "用户列表", + }, + ], + total: 1, + }); + expect(current).toEqual({ accountId: "71", permissions: ["user:list"], revision: 4 }); + expect(updated.permissions).toEqual([]); +}); + test("external admin APIs pin every read and write to the explicit App scope", async () => { setSelectedAppCode("lalu"); const externalUser = externalUserFixture(); @@ -76,6 +129,7 @@ test("external admin APIs pin every read and write to the explicit App scope", a userId: "user-1001", }, permissions: ["user:list", "room:edit"], + permissionRevision: 3, status: "active", username: "ops.fami", }, @@ -114,6 +168,7 @@ function externalUserFixture() { username: "Fami user", }, permissions: ["user:list", "room:edit"], + permission_revision: 3, status: "active", updated_at_ms: "1784077200000", username: "ops.fami", diff --git a/src/features/external-admin-users/api.ts b/src/features/external-admin-users/api.ts index e815302..0951492 100644 --- a/src/features/external-admin-users/api.ts +++ b/src/features/external-admin-users/api.ts @@ -17,6 +17,7 @@ export interface ExternalAdminUserDto { lastLoginAtMs?: number; linkedUser: ExternalAdminLinkedUserDto; permissions: string[]; + permissionRevision: number; status: ExternalAdminUserStatus; updatedAtMs?: number; username: string; @@ -26,8 +27,29 @@ export interface ExternalAdminUserTargetDto extends ExternalAdminLinkedUserDto { existingExternalAdminUser?: ExternalAdminUserDto; } +export interface ExternalAdminPermissionCatalogItemDto { + capabilities: string[]; + code: string; + defaultGranted: boolean; + dependencies: string[]; + group: string; + label: string; +} + +export interface ExternalAdminPermissionCatalogDto { + items: ExternalAdminPermissionCatalogItemDto[]; + total: number; +} + +export interface ExternalAdminUserPermissionsDto { + accountId: string; + permissions: string[]; + revision: number; +} + export interface CreateExternalAdminUserPayload { password: string; + permissions?: string[]; targetUserId: string; username: string; } @@ -58,6 +80,32 @@ export async function lookupExternalAdminUserTarget( return normalizeTarget(data); } +export async function listExternalAdminPermissionCatalog( + appCode: string, +): Promise { + const endpoint = API_ENDPOINTS.listExternalAdminPermissionCatalog; + const data = await apiRequest(apiEndpointPath(API_OPERATIONS.listExternalAdminPermissionCatalog), { + headers: appScopeHeaders(appCode), + method: endpoint.method, + }); + return normalizePermissionCatalog(data); +} + +export async function getExternalAdminUserPermissions( + appCode: string, + id: EntityId, +): Promise { + const endpoint = API_ENDPOINTS.getExternalAdminUserPermissions; + const data = await apiRequest( + apiEndpointPath(API_OPERATIONS.getExternalAdminUserPermissions, { id }), + { + headers: appScopeHeaders(appCode), + method: endpoint.method, + }, + ); + return normalizeUserPermissions(data); +} + export async function createExternalAdminUser( appCode: string, payload: CreateExternalAdminUserPayload, @@ -108,6 +156,24 @@ export async function resetExternalAdminUserPassword( return normalizeExternalAdminUser(data); } +export async function updateExternalAdminUserPermissions( + appCode: string, + id: EntityId, + permissions: string[], + expectedRevision: number, +): Promise { + const endpoint = API_ENDPOINTS.updateExternalAdminUserPermissions; + const data = await apiRequest( + apiEndpointPath(API_OPERATIONS.updateExternalAdminUserPermissions, { id }), + { + body: { expectedRevision, permissions }, + headers: appScopeHeaders(appCode), + method: endpoint.method, + }, + ); + return normalizeExternalAdminUser(data); +} + function appScopeHeaders(appCode: string) { // 外管账号是强 App 隔离数据;显式固定请求头,避免切换 App 后全局请求上下文把写操作送到另一租户。 return { "X-App-Code": String(appCode || "").trim().toLowerCase() }; @@ -151,6 +217,7 @@ function normalizeExternalAdminUser(raw: RawRecord): ExternalAdminUserDto { lastLoginAtMs: optionalNumber(item.lastLoginAtMs ?? item.last_login_at_ms), linkedUser, permissions: stringArray(item.permissions), + permissionRevision: numberValue(item.permissionRevision ?? item.permission_revision), status: status === "disabled" ? "disabled" : "active", updatedAtMs: optionalNumber(item.updatedAtMs ?? item.updated_at_ms), username: stringValue(item.username ?? item.account ?? item.accountName ?? item.account_name), @@ -178,6 +245,34 @@ function normalizeTarget(raw: RawRecord): ExternalAdminUserTargetDto { }; } +function normalizePermissionCatalog(raw: RawRecord): ExternalAdminPermissionCatalogDto { + const item = asRecord(raw) || {}; + const items = (Array.isArray(item.items) ? item.items : []) + .map((entry) => { + const permission = asRecord(entry) || {}; + return { + capabilities: stringArray(permission.capabilities), + code: stringValue(permission.code), + defaultGranted: Boolean(permission.defaultGranted ?? permission.default_granted), + dependencies: stringArray(permission.dependencies), + group: stringValue(permission.group), + label: stringValue(permission.label), + }; + }) + // 空编码无法用于提交,直接从可选目录剔除,避免 UI 生成不可保存的勾选项。 + .filter((permission) => permission.code); + return { items, total: numberValue(item.total, items.length) }; +} + +function normalizeUserPermissions(raw: RawRecord): ExternalAdminUserPermissionsDto { + const item = asRecord(raw) || {}; + return { + accountId: stringValue(item.accountId ?? item.account_id), + permissions: stringArray(item.permissions), + revision: numberValue(item.revision), + }; +} + function normalizeUser(raw: RawRecord): ExternalAdminLinkedUserDto { const user = asRecord(raw) || {}; return { diff --git a/src/features/external-admin-users/components/ExternalAdminPermissionDialogs.test.jsx b/src/features/external-admin-users/components/ExternalAdminPermissionDialogs.test.jsx new file mode 100644 index 0000000..30c43e1 --- /dev/null +++ b/src/features/external-admin-users/components/ExternalAdminPermissionDialogs.test.jsx @@ -0,0 +1,61 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, expect, test, vi } from "vitest"; +import { ExternalAdminPermissionDrawer } from "./ExternalAdminPermissionDrawer.jsx"; +import { ExternalAdminUserFormDialog } from "./ExternalAdminUserFormDialog.jsx"; + +afterEach(cleanup); + +const abilities = { canCreate: true, canManagePermissions: true }; + +test("disables account creation when a successful permission catalog is empty", () => { + render( + , + ); + + expect(screen.getByRole("button", { name: "创建" })).toBeDisabled(); +}); + +test("disables permission updates when a successful catalog is empty", () => { + render( + , + ); + + expect(screen.getByRole("button", { name: "保存权限" })).toBeDisabled(); +}); diff --git a/src/features/external-admin-users/components/ExternalAdminPermissionDrawer.jsx b/src/features/external-admin-users/components/ExternalAdminPermissionDrawer.jsx new file mode 100644 index 0000000..66c354b --- /dev/null +++ b/src/features/external-admin-users/components/ExternalAdminPermissionDrawer.jsx @@ -0,0 +1,60 @@ +import { ExternalAdminPermissionSelector } from "@/features/external-admin-users/components/ExternalAdminPermissionSelector.jsx"; +import styles from "@/features/external-admin-users/external-admin-users.module.css"; +import { Button } from "@/shared/ui/Button.jsx"; +import { SideDrawer } from "@/shared/ui/SideDrawer.jsx"; + +export function ExternalAdminPermissionDrawer({ + abilities, + catalog, + catalogError, + catalogLoading, + error, + loading, + onChange, + onClose, + onReloadCatalog, + onRetry, + onSubmit, + permissions, + permissionsLoading, + user, +}) { + const open = Boolean(user); + const unavailable = Boolean( + !catalog.length || catalogError || error || catalogLoading || permissionsLoading || loading, + ); + + return ( + + + + + } + contentClassName={styles.permissionDrawerBody} + open={open} + title={`配置权限${user?.username ? ` · ${user.username}` : ""}`} + width="detail" + onClose={onClose} + > + + + ); +} diff --git a/src/features/external-admin-users/components/ExternalAdminPermissionSelector.jsx b/src/features/external-admin-users/components/ExternalAdminPermissionSelector.jsx new file mode 100644 index 0000000..ff822d1 --- /dev/null +++ b/src/features/external-admin-users/components/ExternalAdminPermissionSelector.jsx @@ -0,0 +1,144 @@ +import Checkbox from "@mui/material/Checkbox"; +import CircularProgress from "@mui/material/CircularProgress"; +import { useMemo } from "react"; +import { Button } from "@/shared/ui/Button.jsx"; +import styles from "@/features/external-admin-users/external-admin-users.module.css"; +import { + updatePermissionGroup, + updatePermissionSelection, +} from "@/features/external-admin-users/permissionSelection.js"; + +export { updatePermissionSelection } from "@/features/external-admin-users/permissionSelection.js"; + +export function ExternalAdminPermissionSelector({ + catalog = [], + disabled = false, + error = "", + loading = false, + onChange, + onRetry, + value = [], +}) { + const groups = useMemo(() => groupCatalog(catalog), [catalog]); + const knownCodes = useMemo(() => catalog.map((permission) => permission.code), [catalog]); + const selected = useMemo(() => new Set(value), [value]); + const selectedCount = knownCodes.filter((code) => selected.has(code)).length; + + if (loading) { + return ( +
+ + 加载权限目录 +
+ ); + } + + if (error) { + return ( +
+ {error} + {onRetry ? : null} +
+ ); + } + + return ( +
+
+ + 已选 {selectedCount} / {knownCodes.length} 项 + +
+ + +
+
+ + {groups.length ? ( +
+ {groups.map((group) => { + const groupCodes = group.items.map((permission) => permission.code); + const groupSelectedCount = groupCodes.filter((code) => selected.has(code)).length; + return ( +
+
+ {group.name} + +
+
+ {group.items.map((permission) => { + return ( + + ); + })} +
+
+ ); + })} +
+ ) : ( +
当前无可配置权限
+ )} +
+ ); +} + +function groupCatalog(catalog) { + const groups = new Map(); + catalog.forEach((permission) => { + const groupName = permission.group || "其他"; + if (!groups.has(groupName)) { + groups.set(groupName, []); + } + groups.get(groupName).push(permission); + }); + return [...groups].map(([name, items]) => ({ items, name })); +} diff --git a/src/features/external-admin-users/components/ExternalAdminPermissionSelector.test.jsx b/src/features/external-admin-users/components/ExternalAdminPermissionSelector.test.jsx new file mode 100644 index 0000000..43cb0a3 --- /dev/null +++ b/src/features/external-admin-users/components/ExternalAdminPermissionSelector.test.jsx @@ -0,0 +1,60 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import { + ExternalAdminPermissionSelector, + updatePermissionSelection, +} from "./ExternalAdminPermissionSelector.jsx"; +import { defaultPermissionSelection } from "@/features/external-admin-users/permissionSelection.js"; + +const catalog = [ + { code: "user:list", dependencies: [], group: "用户管理", label: "用户列表" }, + { code: "user:update", dependencies: ["user:list"], group: "用户管理", label: "编辑用户" }, + { code: "room:list", dependencies: [], group: "房间管理", label: "房间列表" }, +]; + +describe("ExternalAdminPermissionSelector", () => { + test("adds required permissions and removes actions whose dependency is cleared", () => { + expect(updatePermissionSelection(catalog, [], "user:update", true)).toEqual(["user:list", "user:update"]); + expect( + updatePermissionSelection(catalog, ["user:list", "user:update", "room:list"], "user:list", false), + ).toEqual(["room:list"]); + }); + + test("expands dependencies declared by default-granted catalog entries", () => { + expect( + defaultPermissionSelection([ + { code: "user:list", defaultGranted: false, dependencies: [] }, + { code: "user:update", defaultGranted: true, dependencies: ["user:list"] }, + ]), + ).toEqual(["user:list", "user:update"]); + }); + + test("resolves cyclic dependencies without recursion or removal loops", () => { + const cyclicCatalog = [ + { code: "level:view", dependencies: ["vip:view"], group: "等级", label: "等级" }, + { code: "vip:view", dependencies: ["level:view"], group: "等级", label: "VIP" }, + ]; + + expect(updatePermissionSelection(cyclicCatalog, [], "level:view", true)).toEqual([ + "level:view", + "vip:view", + ]); + expect(updatePermissionSelection(cyclicCatalog, ["level:view", "vip:view"], "level:view", false)).toEqual([]); + }); + + test("renders backend groups and supports explicit select all and clear", () => { + const onChange = vi.fn(); + const { rerender } = render( + , + ); + + expect(screen.getByText("已选 1 / 3 项")).toBeInTheDocument(); + expect(screen.getByText("用户管理")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "全选" })); + expect(onChange).toHaveBeenLastCalledWith(["user:list", "user:update", "room:list"]); + + rerender( item.code)} />); + fireEvent.click(screen.getByRole("button", { name: "清空" })); + expect(onChange).toHaveBeenLastCalledWith([]); + }); +}); diff --git a/src/features/external-admin-users/components/ExternalAdminUserFormDialog.jsx b/src/features/external-admin-users/components/ExternalAdminUserFormDialog.jsx index ce0a679..1d071d8 100644 --- a/src/features/external-admin-users/components/ExternalAdminUserFormDialog.jsx +++ b/src/features/external-admin-users/components/ExternalAdminUserFormDialog.jsx @@ -8,9 +8,13 @@ import { } from "@/shared/ui/AdminFormDialog.jsx"; import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx"; import styles from "@/features/external-admin-users/external-admin-users.module.css"; +import { ExternalAdminPermissionSelector } from "@/features/external-admin-users/components/ExternalAdminPermissionSelector.jsx"; export function ExternalAdminUserFormDialog({ abilities, + catalog, + catalogError, + catalogLoading, form, loading, lookupLoading, @@ -18,6 +22,7 @@ export function ExternalAdminUserFormDialog({ onDisplayUserIdChange, onFormChange, onLookup, + onReloadCatalog, onSubmit, open, targetUser, @@ -30,7 +35,14 @@ export function ExternalAdminUserFormDialog({ loading={loading} open={open} size="large" - submitDisabled={!abilities.canCreate || loading || !targetUser?.userId || Boolean(existingAccount)} + submitDisabled={ + !abilities.canCreate || + loading || + !targetUser?.userId || + Boolean(existingAccount) || + (abilities.canManagePermissions && + (catalogLoading || Boolean(catalogError) || !catalog.length)) + } submitLabel="创建" title="创建外管用户" onClose={onClose} @@ -108,6 +120,20 @@ export function ExternalAdminUserFormDialog({ /> + + {abilities.canManagePermissions ? ( + + onFormChange({ permissions })} + onRetry={onReloadCatalog} + /> + + ) : null} ); } diff --git a/src/features/external-admin-users/external-admin-users.module.css b/src/features/external-admin-users/external-admin-users.module.css index 35ff7ed..7ff1a6b 100644 --- a/src/features/external-admin-users/external-admin-users.module.css +++ b/src/features/external-admin-users/external-admin-users.module.css @@ -50,6 +50,137 @@ white-space: nowrap; } +.permissionSelector { + display: grid; + min-width: 0; + gap: var(--space-3); +} + +.permissionToolbar { + display: flex; + min-height: var(--control-height); + align-items: center; + justify-content: space-between; + gap: var(--space-3); +} + +.permissionToolbar > strong { + color: var(--text-primary); + font-size: var(--control-font-size); +} + +.permissionToolbarActions { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.permissionToolbarActions :global(.MuiButton-root) { + min-width: 64px; +} + +.permissionGroups { + display: grid; + gap: var(--space-3); +} + +.permissionGroup { + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--bg-card); +} + +.permissionGroupHeader { + display: flex; + min-height: 44px; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + padding: 0 var(--space-3); + border-bottom: 1px solid var(--border-soft); + background: var(--bg-card-strong); +} + +.permissionGroupHeader > strong { + color: var(--text-primary); + font-size: var(--control-font-size); +} + +.permissionGroupHeader label { + display: flex; + align-items: center; + gap: var(--space-1); + color: var(--text-tertiary); + font-size: 12px; + font-variant-numeric: tabular-nums; +} + +.permissionOptions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-2); + padding: var(--space-3); +} + +.permissionOption { + display: flex; + min-width: 0; + min-height: 48px; + align-items: center; + gap: var(--space-1); + padding: var(--space-2); + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--bg-card-strong); + cursor: pointer; + transition: + border-color var(--motion-fast) var(--ease-standard), + background var(--motion-fast) var(--ease-standard), + transform var(--motion-base) var(--ease-emphasized); +} + +.permissionOption:hover { + border-color: var(--primary-border-strong); + background: var(--primary-hover); +} + +.permissionOption:active { + transform: scale(0.99); +} + +.permissionOption > span { + min-width: 0; +} + +.permissionOption strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.permissionOption strong { + color: var(--text-primary); + font-size: var(--control-font-size); +} + +.permissionState { + display: grid; + min-height: 150px; + place-items: center; + align-content: center; + gap: var(--space-3); + padding: var(--space-4); + border: 1px solid var(--border); + border-radius: var(--radius-control); + color: var(--text-tertiary); + text-align: center; +} + +.permissionDrawerBody { + align-content: start; +} + @media (max-width: 760px) { .lookupRow { grid-template-columns: 1fr; @@ -58,4 +189,24 @@ .lookupButton { width: 100%; } + + .permissionToolbar { + align-items: flex-start; + flex-direction: column; + } + + .permissionToolbarActions, + .permissionToolbarActions :global(.MuiButton-root) { + width: 100%; + } + + .permissionOptions { + grid-template-columns: 1fr; + } +} + +@media (prefers-reduced-motion: reduce) { + .permissionOption { + transition: none; + } } diff --git a/src/features/external-admin-users/hooks/useExternalAdminUsersPage.js b/src/features/external-admin-users/hooks/useExternalAdminUsersPage.js index 4584d50..84f78c8 100644 --- a/src/features/external-admin-users/hooks/useExternalAdminUsersPage.js +++ b/src/features/external-admin-users/hooks/useExternalAdminUsersPage.js @@ -3,16 +3,21 @@ import { useQueryClient } from "@tanstack/react-query"; import { useAppScope } from "@/app/app-scope/AppScopeProvider.jsx"; import { createExternalAdminUser, + getExternalAdminUserPermissions, listExternalAdminUsers, + listExternalAdminPermissionCatalog, lookupExternalAdminUserTarget, resetExternalAdminUserPassword, + updateExternalAdminUserPermissions, updateExternalAdminUserStatus, } from "@/features/external-admin-users/api"; import { useExternalAdminUserAbilities } from "@/features/external-admin-users/permissions.js"; +import { defaultPermissionSelection } from "@/features/external-admin-users/permissionSelection.js"; import { externalAdminUserCreateFormSchema, externalAdminUserLookupSchema, externalAdminUserPasswordFormSchema, + externalAdminUserPermissionsFormSchema, } from "@/features/external-admin-users/schema"; import { toPageQuery } from "@/shared/api/query"; import { parseForm } from "@/shared/forms/validation"; @@ -23,8 +28,9 @@ import { useToast } from "@/shared/ui/ToastProvider.jsx"; const pageSize = 50; const emptyPage = { items: [], page: 1, pageSize, total: 0 }; -const emptyCreateForm = () => ({ confirmPassword: "", displayUserId: "", password: "", username: "" }); +const emptyCreateForm = () => ({ confirmPassword: "", displayUserId: "", password: "", permissions: [], username: "" }); const emptyPasswordForm = () => ({ confirmPassword: "", password: "" }); +const emptyPermissionCatalog = { items: [], total: 0 }; export function useExternalAdminUsersPage() { const { appCode } = useAppScope(); @@ -34,6 +40,7 @@ export function useExternalAdminUsersPage() { const { showToast } = useToast(); const currentAppCodeRef = useRef(appCode); const lookupRequestRef = useRef(0); + const permissionRequestRef = useRef(0); const [keyword, setKeywordState] = useState(""); const [status, setStatusState] = useState(""); @@ -43,10 +50,15 @@ export function useExternalAdminUsersPage() { const [createForm, setCreateFormState] = useState(emptyCreateForm); const [targetUser, setTargetUser] = useState(null); const [lookupLoading, setLookupLoading] = useState(false); + const [createPermissionsReady, setCreatePermissionsReady] = useState(false); const [passwordUser, setPasswordUser] = useState(null); const [passwordForm, setPasswordFormState] = useState(emptyPasswordForm); const [actionAppCode, setActionAppCode] = useState(""); const [loadingAction, setLoadingAction] = useState(""); + const [permissionUser, setPermissionUser] = useState(null); + const [permissionForm, setPermissionFormState] = useState({ expectedRevision: null, permissions: [] }); + const [permissionLoading, setPermissionLoading] = useState(false); + const [permissionError, setPermissionError] = useState(""); const requestQuery = useMemo( () => toPageQuery({ keyword, page, pageSize, status }), @@ -64,6 +76,22 @@ export function useExternalAdminUsersPage() { initialData: emptyPage, queryKey: ["external-admin-users", appCode, requestQuery], }); + const catalogQueryFn = useCallback(() => listExternalAdminPermissionCatalog(appCode), [appCode]); + const { + data: permissionCatalog = emptyPermissionCatalog, + error: catalogQueryError, + loading: catalogLoading, + reload: reloadPermissionCatalog, + } = useAdminQuery(catalogQueryFn, { + enabled: Boolean(appCode && abilities.canManagePermissions), + errorMessage: "加载外管权限目录失败", + initialData: emptyPermissionCatalog, + queryKey: ["external-admin-permission-catalog", appCode], + staleTime: 5 * 60 * 1000, + }); + const catalogEmpty = + abilities.canManagePermissions && !catalogLoading && !catalogQueryError && permissionCatalog.items.length === 0; + const catalogError = catalogQueryError || (catalogEmpty ? "外管权限目录为空,请联系管理员" : ""); useLayoutEffect(() => { // 写操作完成回调依赖最新租户;布局 effect 在用户能继续交互前同步边界,又不在渲染阶段读写 ref。 @@ -73,20 +101,51 @@ export function useExternalAdminUsersPage() { useEffect(() => { // App 是外管账号的租户边界;切换后必须立即丢弃账号、密码和用户匹配结果,禁止跨 App 复用敏感表单。 lookupRequestRef.current += 1; + permissionRequestRef.current += 1; setCreateOpen(false); setCreateFormState(emptyCreateForm()); + setCreatePermissionsReady(false); setTargetUser(null); setLookupLoading(false); setPasswordUser(null); setPasswordFormState(emptyPasswordForm()); setActionAppCode(""); setLoadingAction(""); + setPermissionUser(null); + setPermissionFormState({ expectedRevision: null, permissions: [] }); + setPermissionLoading(false); + setPermissionError(""); setKeywordState(""); setStatusState(""); setPage(1); setMergedPage({ ...emptyPage, appCode }); }, [appCode]); + useEffect(() => { + if ( + !createOpen || + !abilities.canManagePermissions || + createPermissionsReady || + catalogLoading || + catalogError + ) { + return; + } + // 默认值完全由目录声明,避免前端把未来新增的高风险能力自动授权;即使默认集合为空,也显式保留 []。 + setCreateFormState((current) => ({ + ...current, + permissions: defaultPermissionSelection(permissionCatalog.items || []), + })); + setCreatePermissionsReady(true); + }, [ + abilities.canManagePermissions, + catalogError, + catalogLoading, + createOpen, + createPermissionsReady, + permissionCatalog.items, + ]); + useEffect(() => { setMergedPage({ ...emptyPage, appCode }); }, [appCode, keyword, status]); @@ -134,6 +193,9 @@ export function useExternalAdminUsersPage() { }; const setCreateForm = (patch) => { + if (Object.prototype.hasOwnProperty.call(patch, "permissions")) { + setCreatePermissionsReady(true); + } setCreateFormState((current) => ({ ...current, ...patch })); }; const changeDisplayUserId = (value) => { @@ -144,7 +206,15 @@ export function useExternalAdminUsersPage() { }; const openCreate = () => { lookupRequestRef.current += 1; - setCreateFormState(emptyCreateForm()); + const catalogReady = !catalogLoading && !catalogError; + setCreateFormState({ + ...emptyCreateForm(), + permissions: + abilities.canManagePermissions && catalogReady + ? defaultPermissionSelection(permissionCatalog.items || []) + : [], + }); + setCreatePermissionsReady(!abilities.canManagePermissions || catalogReady); setTargetUser(null); setLookupLoading(false); setActionAppCode(appCode); @@ -157,6 +227,7 @@ export function useExternalAdminUsersPage() { lookupRequestRef.current += 1; setCreateOpen(false); setCreateFormState(emptyCreateForm()); + setCreatePermissionsReady(false); setTargetUser(null); setLookupLoading(false); setActionAppCode(""); @@ -206,6 +277,10 @@ export function useExternalAdminUsersPage() { const submitCreate = async (event) => { event.preventDefault(); const scope = actionAppCode; + if (!abilities.canCreate) { + showToast("没有创建外管用户的权限", "error"); + return; + } if (!scope || scope !== appCode) { showToast("应用已切换,请重新创建", "error"); return; @@ -218,6 +293,10 @@ export function useExternalAdminUsersPage() { showToast("该 App 用户已绑定外管账号", "error"); return; } + if (catalogLoading || catalogError || permissionCatalog.items.length === 0) { + showToast(catalogError || "外管权限目录尚未加载", "error"); + return; + } let formPayload; try { formPayload = parseForm(externalAdminUserCreateFormSchema, createForm); @@ -230,6 +309,8 @@ export function useExternalAdminUsersPage() { try { await createExternalAdminUser(scope, { password: formPayload.password, + // 创建权限已收口为 create + permissions,始终提交显式快照,[] 表示主动创建零权限账号。 + permissions: formPayload.permissions || [], // 始终提交查询接口返回的稳定 userId,短 ID / 靓号只用于定位,避免后续改号造成错误绑定。 targetUserId: targetUser.userId, username: formPayload.username, @@ -239,6 +320,7 @@ export function useExternalAdminUsersPage() { } setCreateOpen(false); setCreateFormState(emptyCreateForm()); + setCreatePermissionsReady(false); setTargetUser(null); setActionAppCode(""); showToast("外管用户已创建", "success"); @@ -341,10 +423,121 @@ export function useExternalAdminUsersPage() { } }; + const loadPermissions = useCallback( + async (item, scope = appCode) => { + const requestId = permissionRequestRef.current + 1; + permissionRequestRef.current = requestId; + setPermissionLoading(true); + setPermissionError(""); + try { + const result = await getExternalAdminUserPermissions(scope, item.id); + if (currentAppCodeRef.current !== scope || permissionRequestRef.current !== requestId) { + return; + } + setPermissionFormState({ + // 详情和目录可能并发返回,先保留服务端快照;提交边界再按届时已加载的目录过滤。 + expectedRevision: result.revision, + permissions: result.permissions, + }); + } catch (err) { + if (currentAppCodeRef.current === scope && permissionRequestRef.current === requestId) { + setPermissionError(err.message || "加载账号权限失败"); + } + } finally { + if (currentAppCodeRef.current === scope && permissionRequestRef.current === requestId) { + setPermissionLoading(false); + } + } + }, + [appCode], + ); + + const openPermissions = (item) => { + setPermissionUser(item); + setPermissionFormState({ expectedRevision: null, permissions: [] }); + setPermissionError(""); + setActionAppCode(appCode); + void loadPermissions(item, appCode); + }; + const closePermissions = () => { + if (loadingAction.startsWith("permissions:")) { + return; + } + permissionRequestRef.current += 1; + setPermissionUser(null); + setPermissionFormState({ expectedRevision: null, permissions: [] }); + setPermissionLoading(false); + setPermissionError(""); + setActionAppCode(""); + }; + const retryPermissions = () => { + if (permissionUser) { + void loadPermissions(permissionUser, actionAppCode || appCode); + } + }; + const setPermissions = (permissions) => { + setPermissionFormState((current) => ({ ...current, permissions })); + }; + const submitPermissions = async () => { + const scope = actionAppCode; + const item = permissionUser; + if (!abilities.canManagePermissions) { + showToast("没有配置外管权限的权限", "error"); + return; + } + if (!item || !scope || scope !== appCode) { + showToast("应用已切换,请重新操作", "error"); + return; + } + if (catalogLoading || catalogError || permissionCatalog.items.length === 0) { + showToast(catalogError || "外管权限目录尚未加载", "error"); + return; + } + let payload; + try { + payload = parseForm(externalAdminUserPermissionsFormSchema, permissionForm); + } catch (err) { + showToast(err.message || "权限参数不正确", "error"); + return; + } + const knownCodes = new Set((permissionCatalog.items || []).map((permission) => permission.code)); + const permissions = payload.permissions.filter((permission) => knownCodes.has(permission)); + + setPermissionError(""); + setLoadingAction(`permissions:${item.id}`); + try { + // 目录是允许写入的唯一边界;被服务端下线的旧编码不应在本次编辑中重新写回。 + await updateExternalAdminUserPermissions(scope, item.id, permissions, payload.expectedRevision); + if (currentAppCodeRef.current !== scope) { + return; + } + setPermissionUser(null); + setPermissionFormState({ expectedRevision: null, permissions: [] }); + setPermissionError(""); + setActionAppCode(""); + showToast("权限已更新,该账号需重新登录", "success"); + await refreshList(scope); + } catch (err) { + if (currentAppCodeRef.current === scope) { + const message = + Number(err?.status) === 409 + ? "账号权限已被更新,请重新加载" + : "更新外管权限失败"; + setPermissionError(message); + showToast(message, "error"); + } + } finally { + if (currentAppCodeRef.current === scope) { + setLoadingAction(""); + } + } + }; + return { abilities, closeCreate, closePassword, + closePermissions, createForm, createOpen, data, @@ -356,21 +549,33 @@ export function useExternalAdminUsersPage() { lookupTarget, openCreate, openPassword, + openPermissions, page, pageSize, passwordForm, passwordUser, + permissionCatalog, + permissionError, + permissionForm, + permissionLoading, + permissionUser, + catalogError, + catalogLoading, reload, resetFilters, + reloadPermissionCatalog, + retryPermissions, setCreateForm, setDisplayUserId: changeDisplayUserId, setKeyword, setPage, setPasswordForm, + setPermissions, setStatus, status, submitCreate, submitPassword, + submitPermissions, targetUser, toggleStatus, }; diff --git a/src/features/external-admin-users/hooks/useExternalAdminUsersPage.test.jsx b/src/features/external-admin-users/hooks/useExternalAdminUsersPage.test.jsx index 1e25d6e..2a4e886 100644 --- a/src/features/external-admin-users/hooks/useExternalAdminUsersPage.test.jsx +++ b/src/features/external-admin-users/hooks/useExternalAdminUsersPage.test.jsx @@ -2,12 +2,18 @@ import { act, renderHook, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, expect, test, vi } from "vitest"; const mocks = vi.hoisted(() => ({ - abilities: { canCreate: true, canResetPassword: true, canStatus: true, canView: true }, + abilities: { canCreate: true, canManagePermissions: true, canResetPassword: true, canStatus: true, canView: true }, appCode: "fami", + catalogError: "", + catalogLoading: false, + catalogPage: null, + catalogQueryOptions: null, confirm: vi.fn(), createExternalAdminUser: vi.fn(), + getExternalAdminUserPermissions: vi.fn(), invalidateQueries: vi.fn(), listExternalAdminUsers: vi.fn(), + listExternalAdminPermissionCatalog: vi.fn(), lookupExternalAdminUserTarget: vi.fn(), queryOptions: null, queryPage: { items: [], page: 1, pageSize: 50, total: 0 }, @@ -15,6 +21,7 @@ const mocks = vi.hoisted(() => ({ resetExternalAdminUserPassword: vi.fn(), showToast: vi.fn(), updateExternalAdminUserStatus: vi.fn(), + updateExternalAdminUserPermissions: vi.fn(), })); vi.mock("@tanstack/react-query", () => ({ @@ -27,10 +34,13 @@ vi.mock("@/app/app-scope/AppScopeProvider.jsx", () => ({ vi.mock("@/features/external-admin-users/api", () => ({ createExternalAdminUser: mocks.createExternalAdminUser, + getExternalAdminUserPermissions: mocks.getExternalAdminUserPermissions, listExternalAdminUsers: mocks.listExternalAdminUsers, + listExternalAdminPermissionCatalog: mocks.listExternalAdminPermissionCatalog, lookupExternalAdminUserTarget: mocks.lookupExternalAdminUserTarget, resetExternalAdminUserPassword: mocks.resetExternalAdminUserPassword, updateExternalAdminUserStatus: mocks.updateExternalAdminUserStatus, + updateExternalAdminUserPermissions: mocks.updateExternalAdminUserPermissions, })); vi.mock("@/features/external-admin-users/permissions.js", () => ({ @@ -40,6 +50,18 @@ vi.mock("@/features/external-admin-users/permissions.js", () => ({ vi.mock("@/shared/hooks/useAdminQuery.js", () => ({ useAdminQuery: (_queryFn, options) => { mocks.queryOptions = options; + if (options.queryKey[0] === "external-admin-permission-catalog") { + mocks.catalogQueryOptions = options; + if (options.enabled) { + void _queryFn(); + } + return { + data: mocks.catalogPage, + error: mocks.catalogError, + loading: mocks.catalogLoading, + reload: mocks.reload, + }; + } return { data: mocks.queryPage, error: "", loading: false, reload: mocks.reload }; }, })); @@ -55,15 +77,26 @@ vi.mock("@/shared/ui/ToastProvider.jsx", () => ({ import { useExternalAdminUsersPage } from "./useExternalAdminUsersPage.js"; beforeEach(() => { + mocks.abilities = { canCreate: true, canManagePermissions: true, canResetPassword: true, canStatus: true, canView: true }; mocks.appCode = "fami"; + mocks.catalogError = ""; + mocks.catalogLoading = false; + mocks.catalogPage = permissionCatalogFixture(); mocks.queryPage = { items: [], page: 1, pageSize: 50, total: 0 }; mocks.confirm.mockResolvedValue(true); mocks.createExternalAdminUser.mockResolvedValue(externalUserFixture()); + mocks.listExternalAdminPermissionCatalog.mockResolvedValue(permissionCatalogFixture()); + mocks.getExternalAdminUserPermissions.mockResolvedValue({ + accountId: "71", + permissions: ["user:list"], + revision: 4, + }); mocks.invalidateQueries.mockResolvedValue(undefined); mocks.lookupExternalAdminUserTarget.mockResolvedValue(targetFixture()); mocks.reload.mockResolvedValue(undefined); mocks.resetExternalAdminUserPassword.mockResolvedValue(externalUserFixture()); mocks.updateExternalAdminUserStatus.mockResolvedValue({ ...externalUserFixture(), status: "disabled" }); + mocks.updateExternalAdminUserPermissions.mockResolvedValue(externalUserFixture()); }); afterEach(() => { @@ -88,6 +121,7 @@ test("creates an App-scoped account with the canonical userId returned by lookup expect(mocks.lookupExternalAdminUserTarget).toHaveBeenCalledWith("fami", "VIP-1001"); expect(mocks.createExternalAdminUser).toHaveBeenCalledWith("fami", { password: "StrongPass123!", + permissions: ["user:list", "user:update"], targetUserId: "internal-user-1001", username: "ops.fami", }); @@ -95,6 +129,67 @@ test("creates an App-scoped account with the canonical userId returned by lookup expect(result.current.createOpen).toBe(false); }); +test("creates an explicit zero-permission account after the operator clears defaults", async () => { + const { result } = renderHook(() => useExternalAdminUsersPage()); + + act(() => result.current.openCreate()); + act(() => result.current.setCreateForm({ permissions: [] })); + act(() => result.current.setDisplayUserId("VIP-1001")); + await act(async () => result.current.lookupTarget()); + act(() => + result.current.setCreateForm({ + confirmPassword: "StrongPass123!", + password: "StrongPass123!", + username: "ops.zero", + }), + ); + await act(async () => result.current.submitCreate({ preventDefault: vi.fn() })); + + expect(mocks.createExternalAdminUser).toHaveBeenCalledWith("fami", { + password: "StrongPass123!", + permissions: [], + targetUserId: "internal-user-1001", + username: "ops.zero", + }); +}); + +test("does not request the permission catalog without the configure-permissions grant", () => { + mocks.abilities = { ...mocks.abilities, canCreate: false, canManagePermissions: false }; + + renderHook(() => useExternalAdminUsersPage()); + + expect(mocks.catalogQueryOptions.enabled).toBe(false); + expect(mocks.listExternalAdminPermissionCatalog).not.toHaveBeenCalled(); +}); + +test("treats an empty successful catalog as unavailable and blocks create and update writes", async () => { + mocks.catalogPage = { items: [], total: 0 }; + const item = externalUserFixture(); + const { result } = renderHook(() => useExternalAdminUsersPage()); + + expect(result.current.catalogError).toBe("外管权限目录为空,请联系管理员"); + act(() => result.current.openCreate()); + act(() => result.current.setDisplayUserId("VIP-1001")); + await act(async () => result.current.lookupTarget()); + act(() => + result.current.setCreateForm({ + confirmPassword: "StrongPass123!", + password: "StrongPass123!", + username: "ops.empty", + }), + ); + await act(async () => result.current.submitCreate({ preventDefault: vi.fn() })); + expect(mocks.createExternalAdminUser).not.toHaveBeenCalled(); + + act(() => result.current.closeCreate()); + act(() => result.current.openPermissions(item)); + await waitFor(() => expect(result.current.permissionLoading).toBe(false)); + await act(async () => result.current.submitPermissions()); + + expect(mocks.updateExternalAdminUserPermissions).not.toHaveBeenCalled(); + expect(mocks.showToast).toHaveBeenCalledWith("外管权限目录为空,请联系管理员", "error"); +}); + test("clears list filters, lookup results and password form when App scope changes", async () => { const { result, rerender } = renderHook(() => useExternalAdminUsersPage()); @@ -112,13 +207,76 @@ test("clears list filters, lookup results and password form when App scope chang await waitFor(() => expect(result.current.keyword).toBe("")); expect(result.current.status).toBe(""); expect(result.current.createOpen).toBe(false); - expect(result.current.createForm).toEqual({ confirmPassword: "", displayUserId: "", password: "", username: "" }); + expect(result.current.createForm).toEqual({ confirmPassword: "", displayUserId: "", password: "", permissions: [], username: "" }); expect(result.current.targetUser).toBeNull(); expect(result.current.passwordUser).toBeNull(); expect(result.current.passwordForm).toEqual({ confirmPassword: "", password: "" }); expect(mocks.queryOptions.queryKey[1]).toBe("lalu"); }); +test("loads and replaces an account permission snapshot, including explicit zero permissions", async () => { + const item = externalUserFixture(); + const { result } = renderHook(() => useExternalAdminUsersPage()); + + act(() => result.current.openPermissions(item)); + await waitFor(() => expect(result.current.permissionForm.permissions).toEqual(["user:list"])); + expect(mocks.getExternalAdminUserPermissions).toHaveBeenCalledWith("fami", 71); + + act(() => result.current.setPermissions([])); + await act(async () => result.current.submitPermissions()); + + expect(mocks.updateExternalAdminUserPermissions).toHaveBeenCalledWith("fami", 71, [], 4); + expect(mocks.showToast).toHaveBeenCalledWith("权限已更新,该账号需重新登录", "success"); + expect(result.current.permissionUser).toBeNull(); +}); + +test("keeps the permission drawer open and offers reload after a concurrent update", async () => { + const conflict = Object.assign(new Error("conflict"), { status: 409 }); + mocks.updateExternalAdminUserPermissions.mockRejectedValueOnce(conflict); + const item = externalUserFixture(); + const { result } = renderHook(() => useExternalAdminUsersPage()); + + act(() => result.current.openPermissions(item)); + await waitFor(() => expect(result.current.permissionLoading).toBe(false)); + await act(async () => result.current.submitPermissions()); + + expect(result.current.permissionUser).toEqual(item); + expect(result.current.permissionError).toBe("账号权限已被更新,请重新加载"); + expect(mocks.showToast).toHaveBeenCalledWith("账号权限已被更新,请重新加载", "error"); + + mocks.getExternalAdminUserPermissions.mockResolvedValueOnce({ + accountId: "71", + permissions: ["user:list", "user:update"], + revision: 5, + }); + act(() => result.current.retryPermissions()); + await waitFor(() => expect(result.current.permissionForm.expectedRevision).toBe(5)); + await act(async () => result.current.submitPermissions()); + + expect(mocks.updateExternalAdminUserPermissions).toHaveBeenNthCalledWith( + 2, + "fami", + 71, + ["user:list", "user:update"], + 5, + ); + expect(result.current.permissionUser).toBeNull(); +}); + +test("does not expose backend permission codes when a non-conflict update fails", async () => { + mocks.updateExternalAdminUserPermissions.mockRejectedValueOnce( + new Error("privilege:grant requires user-title:grant"), + ); + const { result } = renderHook(() => useExternalAdminUsersPage()); + + act(() => result.current.openPermissions(externalUserFixture())); + await waitFor(() => expect(result.current.permissionLoading).toBe(false)); + await act(async () => result.current.submitPermissions()); + + expect(result.current.permissionError).toBe("更新外管权限失败"); + expect(mocks.showToast).toHaveBeenCalledWith("更新外管权限失败", "error"); +}); + test("ignores a late user lookup response after switching App", async () => { let resolveLookup; mocks.lookupExternalAdminUserTarget.mockImplementationOnce( @@ -184,8 +342,20 @@ function externalUserFixture() { lastLoginAtMs: 1784077200000, linkedUser: targetFixture(), permissions: [], + permissionRevision: 4, status: "active", updatedAtMs: 1784077200000, username: "ops.fami", }; } + +function permissionCatalogFixture() { + return { + items: [ + { capabilities: ["user:list"], code: "user:list", defaultGranted: true, dependencies: [], group: "用户管理", label: "用户列表" }, + { capabilities: ["user:list", "user:update"], code: "user:update", defaultGranted: true, dependencies: ["user:list"], group: "用户管理", label: "编辑用户" }, + { capabilities: ["room:list"], code: "room:list", defaultGranted: false, dependencies: [], group: "房间管理", label: "房间列表" }, + ], + total: 3, + }; +} diff --git a/src/features/external-admin-users/pages/ExternalAdminUsersPage.jsx b/src/features/external-admin-users/pages/ExternalAdminUsersPage.jsx index a6db87d..c00b450 100644 --- a/src/features/external-admin-users/pages/ExternalAdminUsersPage.jsx +++ b/src/features/external-admin-users/pages/ExternalAdminUsersPage.jsx @@ -1,6 +1,8 @@ import PasswordOutlined from "@mui/icons-material/PasswordOutlined"; import PersonAddAltOutlined from "@mui/icons-material/PersonAddAltOutlined"; import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined"; +import AdminPanelSettingsOutlined from "@mui/icons-material/AdminPanelSettingsOutlined"; +import { ExternalAdminPermissionDrawer } from "@/features/external-admin-users/components/ExternalAdminPermissionDrawer.jsx"; import { ExternalAdminUserFormDialog } from "@/features/external-admin-users/components/ExternalAdminUserFormDialog.jsx"; import { ExternalAdminUserPasswordDialog } from "@/features/external-admin-users/components/ExternalAdminUserPasswordDialog.jsx"; import styles from "@/features/external-admin-users/external-admin-users.module.css"; @@ -56,7 +58,7 @@ const columns = [ label: "状态", render: (item, _index, context) => { const page = context?.page; - // 外管状态和密码都是账号级敏感写操作;同页串行化可避免两个请求互相覆盖全局 loading 状态。 + // 外管状态、密码和权限都是账号级敏感写操作;同页串行化可避免多个请求互相覆盖全局 loading 状态。 const accountActionInFlight = Boolean(page?.loadingAction); return ( (item.permissions?.length ? `${item.permissions.length} 项` : "-"), + render: (item) => `${item.permissions?.length || 0} 项`, width: "minmax(120px, 0.65fr)", }, { @@ -102,6 +104,13 @@ const columns = [ const page = context?.page; return ( + page.openPermissions(item)} + > + + + ({ openPermissions: vi.fn(), page: null })); + +vi.mock("@/features/external-admin-users/hooks/useExternalAdminUsersPage.js", () => ({ + useExternalAdminUsersPage: () => mocks.page, +})); + +import { ExternalAdminUsersPage } from "./ExternalAdminUsersPage.jsx"; + +beforeEach(() => { + mocks.openPermissions.mockReset(); + mocks.page = pageFixture(); +}); + +test("exposes a dedicated row action for configuring external account permissions", () => { + render(); + + const button = screen.getByRole("button", { name: "配置权限" }); + fireEvent.click(button); + + expect(mocks.openPermissions).toHaveBeenCalledWith(mocks.page.data.items[0]); + expect(screen.getByText("2 项")).toBeInTheDocument(); +}); + +test("disables permission configuration without the main-admin grant ability", () => { + mocks.page.abilities.canManagePermissions = false; + render(); + + expect(screen.getByRole("button", { name: "配置权限" })).toBeDisabled(); +}); + +function pageFixture() { + const item = { + appCode: "fami", + createdAtMs: 1784073600000, + createdByAdminId: 1, + id: 71, + linkedUser: { displayUserId: "123456", userId: "user-1", username: "fami123456" }, + permissions: ["user:list", "user:update"], + permissionRevision: 3, + status: "active", + username: "123456", + }; + return { + abilities: { + canCreate: true, + canManagePermissions: true, + canResetPassword: true, + canStatus: true, + canView: true, + }, + catalogError: "", + catalogLoading: false, + closeCreate: vi.fn(), + closePassword: vi.fn(), + closePermissions: vi.fn(), + createForm: { confirmPassword: "", displayUserId: "", password: "", permissions: [], username: "" }, + createOpen: false, + data: { items: [item], page: 1, pageSize: 50, total: 1 }, + error: "", + keyword: "", + loading: false, + loadingAction: "", + lookupLoading: false, + lookupTarget: vi.fn(), + openCreate: vi.fn(), + openPassword: vi.fn(), + openPermissions: mocks.openPermissions, + page: 1, + passwordForm: { confirmPassword: "", password: "" }, + passwordUser: null, + permissionCatalog: { items: [], total: 0 }, + permissionError: "", + permissionForm: { expectedRevision: null, permissions: [] }, + permissionLoading: false, + permissionUser: null, + reload: vi.fn(), + reloadPermissionCatalog: vi.fn(), + resetFilters: vi.fn(), + retryPermissions: vi.fn(), + setCreateForm: vi.fn(), + setDisplayUserId: vi.fn(), + setKeyword: vi.fn(), + setPage: vi.fn(), + setPasswordForm: vi.fn(), + setPermissions: vi.fn(), + setStatus: vi.fn(), + status: "", + submitCreate: vi.fn(), + submitPassword: vi.fn(), + submitPermissions: vi.fn(), + targetUser: null, + toggleStatus: vi.fn(), + }; +} diff --git a/src/features/external-admin-users/permissionSelection.js b/src/features/external-admin-users/permissionSelection.js new file mode 100644 index 0000000..7ff42a8 --- /dev/null +++ b/src/features/external-admin-users/permissionSelection.js @@ -0,0 +1,58 @@ +export function defaultPermissionSelection(catalog) { + return catalog + .filter((permission) => permission.defaultGranted) + .reduce( + (selected, permission) => updatePermissionSelection(catalog, selected, permission.code, true), + [], + ); +} + +export function updatePermissionSelection(catalog, current, code, checked) { + const permissionMap = new Map(catalog.map((permission) => [permission.code, permission])); + const selected = new Set(current.filter((permissionCode) => permissionMap.has(permissionCode))); + + if (checked) { + // 目录依赖由后端维护;勾选业务动作时递归补齐前置能力,保证提交的是可执行权限快照。 + addWithDependencies(code, permissionMap, selected, new Set()); + } else { + selected.delete(code); + // 取消前置能力后同步移除所有失去依赖的动作,避免保存一个界面无法真实使用的组合。 + removeBrokenDependents(permissionMap, selected); + } + + return catalog.map((permission) => permission.code).filter((permissionCode) => selected.has(permissionCode)); +} + +export function updatePermissionGroup(catalog, current, groupCodes, checked) { + return groupCodes.reduce( + (next, code) => updatePermissionSelection(catalog, next, code, checked), + current, + ); +} + +function addWithDependencies(code, permissionMap, selected, visiting) { + if (!permissionMap.has(code) || visiting.has(code)) { + return; + } + visiting.add(code); + const permission = permissionMap.get(code); + (permission.dependencies || []).forEach((dependency) => + addWithDependencies(dependency, permissionMap, selected, visiting), + ); + selected.add(code); + visiting.delete(code); +} + +function removeBrokenDependents(permissionMap, selected) { + let changed = true; + while (changed) { + changed = false; + selected.forEach((code) => { + const dependencies = permissionMap.get(code)?.dependencies || []; + if (dependencies.some((dependency) => !selected.has(dependency))) { + selected.delete(code); + changed = true; + } + }); + } +} diff --git a/src/features/external-admin-users/permissions.js b/src/features/external-admin-users/permissions.js index 0d01521..3374cdb 100644 --- a/src/features/external-admin-users/permissions.js +++ b/src/features/external-admin-users/permissions.js @@ -3,9 +3,14 @@ import { PERMISSIONS } from "@/app/permissions"; export function useExternalAdminUserAbilities() { const { can } = useAuth(); + const canCreateAccount = can(PERMISSIONS.externalAdminUserCreate); + const canManagePermissions = can(PERMISSIONS.externalAdminUserPermissions); return { - canCreate: can(PERMISSIONS.externalAdminUserCreate), + // 创建必须同时能配置权限,禁止通过“省略权限字段使用默认值”绕过独立授权边界。 + canCreate: canCreateAccount && canManagePermissions, + canCreateAccount, + canManagePermissions, canResetPassword: can(PERMISSIONS.externalAdminUserResetPassword), canStatus: can(PERMISSIONS.externalAdminUserStatus), canView: can(PERMISSIONS.externalAdminUserView), diff --git a/src/features/external-admin-users/permissions.test.jsx b/src/features/external-admin-users/permissions.test.jsx new file mode 100644 index 0000000..f7b2915 --- /dev/null +++ b/src/features/external-admin-users/permissions.test.jsx @@ -0,0 +1,28 @@ +import { renderHook } from "@testing-library/react"; +import { beforeEach, expect, test, vi } from "vitest"; +import { PERMISSIONS } from "@/app/permissions"; + +const mocks = vi.hoisted(() => ({ granted: new Set() })); + +vi.mock("@/app/auth/AuthProvider.jsx", () => ({ + useAuth: () => ({ can: (permission) => mocks.granted.has(permission) }), +})); + +import { useExternalAdminUserAbilities } from "./permissions.js"; + +beforeEach(() => { + mocks.granted = new Set(); +}); + +test("requires both account creation and permission configuration grants to create", () => { + mocks.granted.add(PERMISSIONS.externalAdminUserCreate); + const { result, rerender } = renderHook(() => useExternalAdminUserAbilities()); + + expect(result.current.canCreateAccount).toBe(true); + expect(result.current.canManagePermissions).toBe(false); + expect(result.current.canCreate).toBe(false); + + mocks.granted.add(PERMISSIONS.externalAdminUserPermissions); + rerender(); + expect(result.current.canCreate).toBe(true); +}); diff --git a/src/features/external-admin-users/schema.test.ts b/src/features/external-admin-users/schema.test.ts index ca1e605..f54b76f 100644 --- a/src/features/external-admin-users/schema.test.ts +++ b/src/features/external-admin-users/schema.test.ts @@ -3,6 +3,7 @@ import { externalAdminUserCreateFormSchema, externalAdminUserLookupSchema, externalAdminUserPasswordFormSchema, + externalAdminUserPermissionsFormSchema, } from "./schema"; describe("external admin user schemas", () => { @@ -62,4 +63,16 @@ describe("external admin user schemas", () => { expect(result.success).toBe(false); }); + + test("preserves an explicit zero-permission snapshot and removes duplicate codes", () => { + expect( + externalAdminUserPermissionsFormSchema.parse({ expectedRevision: 4, permissions: [] }), + ).toEqual({ expectedRevision: 4, permissions: [] }); + expect( + externalAdminUserPermissionsFormSchema.parse({ + expectedRevision: 4, + permissions: ["user:list", "user:list", "room:list"], + }), + ).toEqual({ expectedRevision: 4, permissions: ["user:list", "room:list"] }); + }); }); diff --git a/src/features/external-admin-users/schema.ts b/src/features/external-admin-users/schema.ts index ca85633..c39cc70 100644 --- a/src/features/external-admin-users/schema.ts +++ b/src/features/external-admin-users/schema.ts @@ -16,6 +16,17 @@ const usernameSchema = z // 外管系统使用独立登录标识;限制为跨浏览器、网关和审计日志均稳定的 ASCII 账号字符集。 .regex(/^[A-Za-z0-9._-]+$/, "外管账号只能包含字母、数字、点、下划线和连字符"); +const permissionCodeSchema = z + .string() + .trim() + .min(1, "权限编码不能为空") + .max(128, "权限编码不能超过 128 个字符") + .refine((value) => !/[\s\p{Cc}]/u.test(value), "权限编码不能包含空白或控制字符"); + +const permissionsSchema = z.array(permissionCodeSchema).max(200, "权限数量不能超过 200 项").transform((items) => [ + ...new Set(items), +]); + // 密码属于登录凭证,不能 trim 或自动改写;前端只做长度与确认值校验,服务端会重复校验并仅持久化密码摘要。 const passwordSchema = z .string() @@ -32,6 +43,7 @@ export const externalAdminUserCreateFormSchema = z confirmPassword: passwordSchema, displayUserId: displayUserIdSchema, password: passwordSchema, + permissions: permissionsSchema.optional(), username: usernameSchema, }) .superRefine((value, context) => { @@ -57,7 +69,15 @@ export const externalAdminUserStatusSchema = z.object({ status: z.enum(["active", "disabled"]), }); +export const externalAdminUserPermissionsFormSchema = z.object({ + expectedRevision: z.number().int().positive("权限版本不正确"), + // 空数组是明确的零权限快照,不能用默认权限替换,也不能在提交前过滤掉该字段。 + permissions: permissionsSchema, +}); + export type ExternalAdminUserCreateForm = z.input; export type ExternalAdminUserCreatePayload = z.output; export type ExternalAdminUserPasswordForm = z.input; export type ExternalAdminUserPasswordPayload = z.output; +export type ExternalAdminUserPermissionsForm = z.input; +export type ExternalAdminUserPermissionsPayload = z.output; diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index 4ca64a5..7125e62 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -136,6 +136,7 @@ export const API_OPERATIONS = { getCPConfig: "getCPConfig", getCPWeeklyRankConfig: "getCPWeeklyRankConfig", getCumulativeRechargeRewardConfig: "getCumulativeRechargeRewardConfig", + getExternalAdminUserPermissions: "getExternalAdminUserPermissions", getFinanceCoinSellerRechargeExchangeRate: "getFinanceCoinSellerRechargeExchangeRate", getFinanceScope: "getFinanceScope", getFinanceUSDTAddresses: "getFinanceUSDTAddresses", @@ -211,6 +212,7 @@ export const API_OPERATIONS = { listEmojiPackCategories: "listEmojiPackCategories", listEmojiPacks: "listEmojiPacks", listExploreTabs: "listExploreTabs", + listExternalAdminPermissionCatalog: "listExternalAdminPermissionCatalog", listExternalAdminUsers: "listExternalAdminUsers", listFinanceCoinSellerRechargeOrders: "listFinanceCoinSellerRechargeOrders", listFinanceScopeAssignments: "listFinanceScopeAssignments", @@ -346,6 +348,7 @@ export const API_OPERATIONS = { updateCumulativeRechargeRewardConfig: "updateCumulativeRechargeRewardConfig", updateDiceConfig: "updateDiceConfig", updateExploreTab: "updateExploreTab", + updateExternalAdminUserPermissions: "updateExternalAdminUserPermissions", updateExternalAdminUserStatus: "updateExternalAdminUserStatus", updateFirstRechargeRewardConfig: "updateFirstRechargeRewardConfig", updateGift: "updateGift", @@ -684,7 +687,7 @@ export const API_ENDPOINTS: Record = { operationId: API_OPERATIONS.createExternalAdminUser, path: "/v1/admin/external-admin-users", permission: "external-admin-user:create", - permissions: ["external-admin-user:create"] + permissions: ["external-admin-user:create","external-admin-user:permissions"] }, createFinanceCoinSellerRechargeOrder: { method: "POST", @@ -1267,6 +1270,13 @@ export const API_ENDPOINTS: Record = { permission: "cumulative-recharge-reward:view", permissions: ["cumulative-recharge-reward:view"] }, + getExternalAdminUserPermissions: { + method: "GET", + operationId: API_OPERATIONS.getExternalAdminUserPermissions, + path: "/v1/admin/external-admin-users/{id}/permissions", + permission: "external-admin-user:permissions", + permissions: ["external-admin-user:permissions"] + }, getFinanceCoinSellerRechargeExchangeRate: { method: "GET", operationId: API_OPERATIONS.getFinanceCoinSellerRechargeExchangeRate, @@ -1790,6 +1800,13 @@ export const API_ENDPOINTS: Record = { permission: "app-config:view", permissions: ["app-config:view"] }, + listExternalAdminPermissionCatalog: { + method: "GET", + operationId: API_OPERATIONS.listExternalAdminPermissionCatalog, + path: "/v1/admin/external-admin-users/permission-catalog", + permission: "external-admin-user:permissions", + permissions: ["external-admin-user:permissions"] + }, listExternalAdminUsers: { method: "GET", operationId: API_OPERATIONS.listExternalAdminUsers, @@ -2721,6 +2738,13 @@ export const API_ENDPOINTS: Record = { permission: "app-config:update", permissions: ["app-config:update"] }, + updateExternalAdminUserPermissions: { + method: "PUT", + operationId: API_OPERATIONS.updateExternalAdminUserPermissions, + path: "/v1/admin/external-admin-users/{id}/permissions", + permission: "external-admin-user:permissions", + permissions: ["external-admin-user:permissions"] + }, updateExternalAdminUserStatus: { method: "PATCH", operationId: API_OPERATIONS.updateExternalAdminUserStatus, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index e3698b9..401af3d 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -3612,6 +3612,22 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/external-admin-users/permission-catalog": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listExternalAdminPermissionCatalog"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/admin/external-admin-users/target": { parameters: { query?: never; @@ -3660,6 +3676,22 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/external-admin-users/{id}/permissions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getExternalAdminUserPermissions"]; + put: operations["updateExternalAdminUserPermissions"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/exports/app-users": { parameters: { query?: never; @@ -4796,6 +4828,12 @@ export interface components { ApiResponseExternalAdminUserTarget: components["schemas"]["Envelope"] & { data?: components["schemas"]["ExternalAdminUserTargetLookup"]; }; + ApiResponseExternalAdminPermissionCatalog: components["schemas"]["Envelope"] & { + data?: components["schemas"]["ExternalAdminPermissionCatalog"]; + }; + ApiResponseExternalAdminUserPermissions: components["schemas"]["Envelope"] & { + data?: components["schemas"]["ExternalAdminUserPermissions"]; + }; ApiPageExternalAdminUser: { items: components["schemas"]["ExternalAdminUser"][]; page: number; @@ -4825,6 +4863,8 @@ export interface components { status: "active" | "disabled"; linkedUser: components["schemas"]["ExternalAdminUserTarget"]; permissions: string[]; + /** Format: int64 */ + permissionRevision: number; createdByAdminId?: number; /** Format: int64 */ lastLoginAtMs?: number | null; @@ -4837,6 +4877,30 @@ export interface components { targetUserId: string; username: string; password: string; + permissions?: string[]; + }; + ExternalAdminPermissionCatalogItem: { + code: string; + label: string; + group: string; + dependencies: string[]; + capabilities: string[]; + defaultGranted: boolean; + }; + ExternalAdminPermissionCatalog: { + items: components["schemas"]["ExternalAdminPermissionCatalogItem"][]; + total: number; + }; + ExternalAdminUserPermissions: { + accountId: string; + permissions: string[]; + /** Format: int64 */ + revision: number; + }; + ExternalAdminUserPermissionsInput: { + permissions: string[]; + /** Format: int64 */ + expectedRevision: number; }; ExternalAdminUserStatusInput: { /** @enum {string} */ @@ -7030,6 +7094,24 @@ export interface components { "application/json": components["schemas"]["ApiResponseExternalAdminUserTarget"]; }; }; + /** @description External admin permission catalog */ + ExternalAdminPermissionCatalogResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponseExternalAdminPermissionCatalog"]; + }; + }; + /** @description External admin account permissions */ + ExternalAdminUserPermissionsResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponseExternalAdminUserPermissions"]; + }; + }; /** @description OK */ AgencyPageResponse: { headers: { @@ -12214,6 +12296,20 @@ export interface operations { 201: components["responses"]["ExternalAdminUserResponse"]; }; }; + listExternalAdminPermissionCatalog: { + parameters: { + query?: never; + header: { + "X-App-Code": components["parameters"]["AppCodeHeader"]; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["ExternalAdminPermissionCatalogResponse"]; + }; + }; lookupExternalAdminUserTarget: { parameters: { query: { @@ -12270,6 +12366,42 @@ export interface operations { 200: components["responses"]["ExternalAdminUserResponse"]; }; }; + getExternalAdminUserPermissions: { + parameters: { + query?: never; + header: { + "X-App-Code": components["parameters"]["AppCodeHeader"]; + }; + path: { + id: components["parameters"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["ExternalAdminUserPermissionsResponse"]; + }; + }; + updateExternalAdminUserPermissions: { + parameters: { + query?: never; + header: { + "X-App-Code": components["parameters"]["AppCodeHeader"]; + }; + path: { + id: components["parameters"]["Id"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ExternalAdminUserPermissionsInput"]; + }; + }; + responses: { + 200: components["responses"]["ExternalAdminUserResponse"]; + }; + }; appExportUsers: { parameters: { query?: { diff --git a/src/shared/api/request.test.ts b/src/shared/api/request.test.ts index 96c7337..7a18c87 100644 --- a/src/shared/api/request.test.ts +++ b/src/shared/api/request.test.ts @@ -84,3 +84,17 @@ test("does not replay mutations after a network failure", async () => { await expect(apiRequest("/v1/admin/countries", { body: {}, method: "POST" })).rejects.toThrow("Failed to fetch"); expect(fetch).toHaveBeenCalledTimes(1); }); + +test("preserves the HTTP status on API errors for optimistic concurrency handling", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ code: 409, message: "账号权限版本冲突" }), { status: 409 }) + ) + ); + + await expect(apiRequest("/v1/admin/external-admin-users/71/permissions", { body: {}, method: "PUT" })).rejects.toMatchObject({ + message: "账号权限版本冲突", + status: 409 + }); +}); diff --git a/src/shared/api/request.ts b/src/shared/api/request.ts index eefe9b5..0144db7 100644 --- a/src/shared/api/request.ts +++ b/src/shared/api/request.ts @@ -124,19 +124,26 @@ export async function apiRequest( if (raw) { if (!response.ok) { - throw new Error(response.statusText || "请求失败"); + throw requestError(response.statusText || "请求失败", response.status); } return response; } const payload = await readJSON(response); if (!response.ok || payload.code !== 0) { - throw new Error(resolveErrorMessage(response, payload.message)); + throw requestError(resolveErrorMessage(response, payload.message), response.status); } return payload.data as TData; } +function requestError(message: string, status: number) { + const error = new Error(message) as Error & { status: number }; + // 保留 HTTP 状态供并发写入等业务分支精确判断,同时维持既有 Error message 行为。 + error.status = status; + return error; +} + function refreshOnce() { if (!refreshHandler) { return Promise.resolve(false); From 6ff2feb052def89d7f584698e7f13069225e57c2 Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 15 Jul 2026 19:03:07 +0800 Subject: [PATCH 06/15] fix(external-admin): resolve branding after login --- external-admin/index.html | 2 +- .../src/i18n/ExternalI18nProvider.test.jsx | 10 +++--- external-admin/src/i18n/messages.js | 12 +++---- external-admin/src/i18n/messages.test.js | 10 +++--- .../src/layout/ExternalAdminLayout.jsx | 33 ++----------------- .../src/layout/ExternalAdminLayout.test.jsx | 4 +-- .../src/pages/ChangePasswordPage.jsx | 18 ++++++++-- .../src/pages/ChangePasswordPage.test.jsx | 31 ++++++++++++++++- external-admin/src/pages/LoginPage.jsx | 6 ++++ external-admin/src/pages/LoginPage.test.jsx | 12 ++++++- .../src/shared/useExternalSessionBrand.js | 32 ++++++++++++++++++ external-admin/src/styles/index.css | 6 ++-- src/shared/ui/AppIdentity.test.jsx | 14 ++++++++ 13 files changed, 134 insertions(+), 56 deletions(-) create mode 100644 external-admin/src/shared/useExternalSessionBrand.js diff --git a/external-admin/index.html b/external-admin/index.html index a83fee8..61d85dd 100644 --- a/external-admin/index.html +++ b/external-admin/index.html @@ -4,7 +4,7 @@ - Fami + External Admin
diff --git a/external-admin/src/i18n/ExternalI18nProvider.test.jsx b/external-admin/src/i18n/ExternalI18nProvider.test.jsx index 86af77f..7a30b33 100644 --- a/external-admin/src/i18n/ExternalI18nProvider.test.jsx +++ b/external-admin/src/i18n/ExternalI18nProvider.test.jsx @@ -16,7 +16,7 @@ describe("ExternalI18nProvider", () => { render(); expect(screen.getByTestId("locale")).toHaveTextContent("en"); - expect(screen.getByTestId("title")).toHaveTextContent("Fami"); + expect(screen.getByTestId("title")).toHaveTextContent("External Admin"); expect(document.documentElement).toHaveAttribute("lang", "en"); expect(document.documentElement).toHaveAttribute("dir", "ltr"); }); @@ -26,25 +26,25 @@ describe("ExternalI18nProvider", () => { const firstRender = render(); await user.click(screen.getByRole("button", { name: "ar" })); - expect(screen.getByTestId("title")).toHaveTextContent("Fami"); + expect(screen.getByTestId("title")).toHaveTextContent("الإدارة الخارجية"); expect(document.documentElement).toHaveAttribute("lang", "ar"); expect(document.documentElement).toHaveAttribute("dir", "rtl"); expect(document.body).toHaveAttribute("dir", "rtl"); expect(localStorage.getItem(EXTERNAL_LOCALE_STORAGE_KEY)).toBe("ar"); await user.click(screen.getByRole("button", { name: "tr" })); - expect(screen.getByTestId("title")).toHaveTextContent("Fami"); + expect(screen.getByTestId("title")).toHaveTextContent("Harici Yönetim"); expect(document.documentElement).toHaveAttribute("lang", "tr"); expect(document.documentElement).toHaveAttribute("dir", "ltr"); await user.click(screen.getByRole("button", { name: "zh-CN" })); - expect(screen.getByTestId("title")).toHaveTextContent("Fami"); + expect(screen.getByTestId("title")).toHaveTextContent("外管后台"); expect(localStorage.getItem(EXTERNAL_LOCALE_STORAGE_KEY)).toBe("zh-CN"); firstRender.unmount(); render(); expect(screen.getByTestId("locale")).toHaveTextContent("zh-CN"); - expect(screen.getByTestId("title")).toHaveTextContent("Fami"); + expect(screen.getByTestId("title")).toHaveTextContent("外管后台"); }); }); diff --git a/external-admin/src/i18n/messages.js b/external-admin/src/i18n/messages.js index cb399f3..71b14ec 100644 --- a/external-admin/src/i18n/messages.js +++ b/external-admin/src/i18n/messages.js @@ -10,8 +10,8 @@ export const EXTERNAL_LANGUAGE_OPTIONS = Object.freeze([ ]); const en = { - "app.title": "Fami", - "app.shortTitle": "Fami", + "app.title": "External Admin", + "app.shortTitle": "External Admin", "language.label": "Language", "common.actions": "Actions", "common.active": "Active", @@ -321,8 +321,8 @@ const en = { }; const zhCN = { - "app.title": "Fami", - "app.shortTitle": "Fami", + "app.title": "外管后台", + "app.shortTitle": "外管后台", "language.label": "语言", "common.actions": "操作", "common.active": "正常", "common.all": "全部", "common.allStatuses": "全部状态", "common.banned": "已封禁", "common.cancel": "取消", "common.confirm": "确认", "common.create": "创建", "common.disabled": "禁用", "common.edit": "编辑", "common.enabled": "启用", "common.expired": "过期", "common.loading": "正在加载", "common.noData": "当前无数据", "common.normal": "正常", "common.query": "查询", "common.reason": "原因", "common.reload": "重新加载", "common.retry": "重试", "common.save": "保存", "common.status": "状态", "common.user": "用户", "auth.account": "外管账号", "auth.changePassword": "修改外管密码", "auth.checkingSession": "正在校验外管会话", "auth.confirmNewPassword": "确认新密码", "auth.currentPassword": "当前密码", "auth.fillAccountPassword": "请填写账号和密码", "auth.firstLoginChange": "首次登录必须修改密码", "auth.hidePassword": "隐藏密码", "auth.invalidCredentials": "账号或密码错误", "auth.login": "登录", "auth.loginFailed": "登录失败", "auth.logout": "退出登录", "auth.newPassword": "新密码", "auth.noPermission": "当前外管账号没有访问此功能的权限。", "auth.password": "密码", "auth.passwordChangedFailed": "密码修改失败", "auth.passwordMismatch": "两次输入的新密码不一致", "auth.passwordMin": "至少 8 个字符", "auth.passwordMinError": "新密码至少 8 个字符", "auth.passwordWhitespace": "新密码不能为空或全为空白字符", "auth.recheck": "重新校验", "auth.returnOverview": "返回权限总览", "auth.savePassword": "保存密码", "auth.sessionFailed": "会话校验失败", "auth.showPassword": "显示密码", @@ -345,7 +345,7 @@ const zhCN = { const ar = { ...en, - "app.title": "Fami", "app.shortTitle": "Fami", "language.label": "اللغة", + "app.title": "الإدارة الخارجية", "app.shortTitle": "الإدارة الخارجية", "language.label": "اللغة", "common.actions": "الإجراءات", "common.active": "نشط", "common.all": "الكل", "common.allStatuses": "كل الحالات", "common.banned": "محظور", "common.cancel": "إلغاء", "common.confirm": "تأكيد", "common.create": "إنشاء", "common.disabled": "معطّل", "common.edit": "تعديل", "common.enabled": "مفعّل", "common.expired": "منتهي", "common.loading": "جارٍ التحميل", "common.noData": "لا توجد بيانات", "common.normal": "طبيعي", "common.query": "بحث", "common.reason": "السبب", "common.reload": "إعادة التحميل", "common.retry": "إعادة المحاولة", "common.save": "حفظ", "common.status": "الحالة", "common.user": "المستخدم", "auth.account": "حساب الإدارة الخارجية", "auth.changePassword": "تغيير كلمة مرور الإدارة الخارجية", "auth.checkingSession": "جارٍ التحقق من الجلسة", "auth.confirmNewPassword": "تأكيد كلمة المرور الجديدة", "auth.currentPassword": "كلمة المرور الحالية", "auth.fillAccountPassword": "أدخل الحساب وكلمة المرور", "auth.firstLoginChange": "يجب تغيير كلمة المرور عند تسجيل الدخول لأول مرة", "auth.hidePassword": "إخفاء كلمة المرور", "auth.invalidCredentials": "الحساب أو كلمة المرور غير صحيحة", "auth.login": "تسجيل الدخول", "auth.loginFailed": "فشل تسجيل الدخول", "auth.logout": "تسجيل الخروج", "auth.newPassword": "كلمة المرور الجديدة", "auth.noPermission": "لا يملك هذا الحساب صلاحية الوصول إلى هذه الميزة.", "auth.password": "كلمة المرور", "auth.passwordChangedFailed": "فشل تغيير كلمة المرور", "auth.passwordMismatch": "كلمتا المرور الجديدتان غير متطابقتين", "auth.passwordMin": "8 أحرف على الأقل", "auth.passwordMinError": "يجب أن تتكون كلمة المرور الجديدة من 8 أحرف على الأقل", "auth.passwordWhitespace": "لا يمكن أن تكون كلمة المرور الجديدة فارغة أو مسافات فقط", "auth.recheck": "إعادة التحقق", "auth.returnOverview": "العودة إلى ملخص الصلاحيات", "auth.savePassword": "حفظ كلمة المرور", "auth.sessionFailed": "فشل التحقق من الجلسة", "auth.showPassword": "إظهار كلمة المرور", "nav.expand": "فتح القائمة", "nav.overview": "ملخص الصلاحيات", "nav.users": "إدارة المستخدمين", "nav.bans": "إدارة الحظر", "nav.hosts": "المضيفون", "nav.agencies": "الوكالات", "nav.bds": "قائمة BD", "nav.bdManagers": "مديرو BD", "nav.superAdmins": "المشرفون العامون", "nav.rooms": "إدارة الغرف", "nav.grants": "الموارد والمعرّفات المميزة", "nav.banners": "إدارة اللافتات", "nav.team": "فريقي", @@ -367,7 +367,7 @@ const ar = { const tr = { ...en, - "app.title": "Fami", "app.shortTitle": "Fami", "language.label": "Dil", + "app.title": "Harici Yönetim", "app.shortTitle": "Harici Yönetim", "language.label": "Dil", "common.actions": "İşlemler", "common.active": "Aktif", "common.all": "Tümü", "common.allStatuses": "Tüm durumlar", "common.banned": "Yasaklı", "common.cancel": "İptal", "common.confirm": "Onayla", "common.create": "Oluştur", "common.disabled": "Devre dışı", "common.edit": "Düzenle", "common.enabled": "Etkin", "common.expired": "Süresi dolmuş", "common.loading": "Yükleniyor", "common.noData": "Veri yok", "common.normal": "Normal", "common.query": "Ara", "common.reason": "Neden", "common.reload": "Yeniden yükle", "common.retry": "Tekrar dene", "common.save": "Kaydet", "common.status": "Durum", "common.user": "Kullanıcı", "auth.account": "Harici yönetim hesabı", "auth.changePassword": "Harici yönetim parolasını değiştir", "auth.checkingSession": "Oturum kontrol ediliyor", "auth.confirmNewPassword": "Yeni parolayı doğrula", "auth.currentPassword": "Mevcut parola", "auth.fillAccountPassword": "Hesabınızı ve parolanızı girin", "auth.firstLoginChange": "İlk girişte parolanızı değiştirmeniz gerekir", "auth.hidePassword": "Parolayı gizle", "auth.invalidCredentials": "Hesap veya parola hatalı", "auth.login": "Giriş yap", "auth.loginFailed": "Giriş başarısız", "auth.logout": "Çıkış yap", "auth.newPassword": "Yeni parola", "auth.noPermission": "Bu harici yönetim hesabının bu özelliğe erişimi yok.", "auth.password": "Parola", "auth.passwordChangedFailed": "Parola değiştirilemedi", "auth.passwordMismatch": "Yeni parolalar eşleşmiyor", "auth.passwordMin": "En az 8 karakter", "auth.passwordMinError": "Yeni parola en az 8 karakter olmalıdır", "auth.passwordWhitespace": "Yeni parola boş veya yalnızca boşluk olamaz", "auth.recheck": "Tekrar kontrol et", "auth.returnOverview": "Yetki özetine dön", "auth.savePassword": "Parolayı kaydet", "auth.sessionFailed": "Oturum doğrulanamadı", "auth.showPassword": "Parolayı göster", "nav.expand": "Menüyü aç", "nav.overview": "Yetki özeti", "nav.users": "Kullanıcı yönetimi", "nav.bans": "Yasak yönetimi", "nav.hosts": "Yayıncılar", "nav.agencies": "Ajanslar", "nav.bds": "BD listesi", "nav.bdManagers": "BD Yöneticileri", "nav.superAdmins": "Süper Yöneticiler", "nav.rooms": "Oda yönetimi", "nav.grants": "Kaynaklar ve özel ID'ler", "nav.banners": "Banner yönetimi", "nav.team": "Ekibim", diff --git a/external-admin/src/i18n/messages.test.js b/external-admin/src/i18n/messages.test.js index 08ee1cf..19c7cf1 100644 --- a/external-admin/src/i18n/messages.test.js +++ b/external-admin/src/i18n/messages.test.js @@ -13,10 +13,12 @@ describe("external admin translations", () => { test("uses native language names and Arabic RTL only", () => { expect(EXTERNAL_LANGUAGE_OPTIONS.map((item) => item.label)).toEqual(["English", "简体中文", "العربية", "Türkçe"]); - for (const locale of ["en", "zh-CN", "ar", "tr"]) { - expect(externalMessage(locale, "app.title")).toBe("Fami"); - expect(externalMessage(locale, "app.shortTitle")).toBe("Fami"); - } + expect(["en", "zh-CN", "ar", "tr"].map((locale) => externalMessage(locale, "app.title"))).toEqual([ + "External Admin", "外管后台", "الإدارة الخارجية", "Harici Yönetim" + ]); + expect(["en", "zh-CN", "ar", "tr"].map((locale) => externalMessage(locale, "app.shortTitle"))).toEqual([ + "External Admin", "外管后台", "الإدارة الخارجية", "Harici Yönetim" + ]); expect(externalDirection("ar")).toBe("rtl"); expect(externalDirection("en")).toBe("ltr"); expect(externalDirection("zh-CN")).toBe("ltr"); diff --git a/external-admin/src/layout/ExternalAdminLayout.jsx b/external-admin/src/layout/ExternalAdminLayout.jsx index 5354b28..aedfaf5 100644 --- a/external-admin/src/layout/ExternalAdminLayout.jsx +++ b/external-admin/src/layout/ExternalAdminLayout.jsx @@ -27,13 +27,14 @@ import Tooltip from "@mui/material/Tooltip"; import Typography from "@mui/material/Typography"; import useMediaQuery from "@mui/material/useMediaQuery"; import { useTheme } from "@mui/material/styles"; -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom"; import { AppIdentity } from "@/shared/ui/AppIdentity.jsx"; import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; import { hasAnyExternalCapability } from "../config/capabilities.js"; import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; import { LanguageSwitcher } from "../i18n/LanguageSwitcher.jsx"; +import { useExternalSessionBrand } from "../shared/useExternalSessionBrand.js"; const drawerWidth = 248; const navigation = [ @@ -64,19 +65,7 @@ export function ExternalAdminLayout() { [session] ); const current = [...visibleNavigation].reverse().find((item) => location.pathname.startsWith(item.path)); - - useEffect(() => { - // Login resolves the account's App server-side. Both the visible brand and browser metadata must use that apps row rather than a bundled image or client-selected tenant. - const appName = String(session?.appName || session?.appCode || t("app.title")).trim(); - const logoUrl = String(session?.logoUrl || "").trim(); - globalThis.document.title = appName; - syncBrandFavicon(logoUrl); - return () => { - // Leaving an authenticated App session must not leak its brand into the next account's login screen. - globalThis.document.title = t("app.title"); - syncBrandFavicon(""); - }; - }, [session?.appCode, session?.appName, session?.logoUrl, t]); + useExternalSessionBrand(session, t("app.title")); const handleLogout = async () => { await logout(); @@ -157,19 +146,3 @@ export function ExternalAdminLayout() {
); } - -function syncBrandFavicon(logoUrl) { - const selector = 'link[data-external-app-favicon="true"]'; - const current = globalThis.document?.head?.querySelector(selector); - if (!logoUrl) { - current?.remove(); - return; - } - const link = current || globalThis.document.createElement("link"); - link.dataset.externalAppFavicon = "true"; - link.rel = "icon"; - link.href = logoUrl; - if (!current) { - globalThis.document.head.append(link); - } -} diff --git a/external-admin/src/layout/ExternalAdminLayout.test.jsx b/external-admin/src/layout/ExternalAdminLayout.test.jsx index 6f158b9..7419cad 100644 --- a/external-admin/src/layout/ExternalAdminLayout.test.jsx +++ b/external-admin/src/layout/ExternalAdminLayout.test.jsx @@ -69,7 +69,7 @@ describe("ExternalAdminLayout RTL", () => { expect(screen.getByRole("combobox", { name: "اللغة" })).toHaveTextContent("العربية"); view.unmount(); - expect(document.title).toBe("Fami"); + expect(document.title).toBe("الإدارة الخارجية"); expect(document.head.querySelector('link[data-external-app-favicon="true"]')).not.toBeInTheDocument(); }); @@ -89,7 +89,7 @@ describe("ExternalAdminLayout RTL", () => { expect(document.head.querySelector('link[data-external-app-favicon="true"]')).toHaveAttribute("href", "https://media.example.com/apps/lalu.png"); view.unmount(); - expect(document.title).toBe("Fami"); + expect(document.title).toBe("الإدارة الخارجية"); expect(document.head.querySelector('link[data-external-app-favicon="true"]')).not.toBeInTheDocument(); }); }); diff --git a/external-admin/src/pages/ChangePasswordPage.jsx b/external-admin/src/pages/ChangePasswordPage.jsx index ca8c517..02b533f 100644 --- a/external-admin/src/pages/ChangePasswordPage.jsx +++ b/external-admin/src/pages/ChangePasswordPage.jsx @@ -1,6 +1,4 @@ -import KeyOutlined from "@mui/icons-material/KeyOutlined"; import Alert from "@mui/material/Alert"; -import Avatar from "@mui/material/Avatar"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import Card from "@mui/material/Card"; @@ -10,10 +8,12 @@ import TextField from "@mui/material/TextField"; import Typography from "@mui/material/Typography"; import { useState } from "react"; import { useNavigate } from "react-router-dom"; +import { AppIdentity } from "@/shared/ui/AppIdentity.jsx"; import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; import { LanguageSwitcher } from "../i18n/LanguageSwitcher.jsx"; import { translateExternalError } from "../i18n/errors.js"; +import { useExternalSessionBrand } from "../shared/useExternalSessionBrand.js"; export default function ChangePasswordPage() { const { changePassword, logout, session } = useExternalAuth(); @@ -22,6 +22,7 @@ export default function ChangePasswordPage() { const [error, setError] = useState(""); const [submitting, setSubmitting] = useState(false); const navigate = useNavigate(); + useExternalSessionBrand(session, t("app.title")); const submit = async (event) => { event.preventDefault(); @@ -59,7 +60,18 @@ export default function ChangePasswordPage() { - {t("auth.changePassword")}{session?.passwordChangeRequired ? t("auth.firstLoginChange") : session?.account} + + + {t("auth.changePassword")} + {session?.passwordChangeRequired ? t("auth.firstLoginChange") : session?.account} + {error ? {error} : null} setForm({ ...form, oldPassword: event.target.value })} /> diff --git a/external-admin/src/pages/ChangePasswordPage.test.jsx b/external-admin/src/pages/ChangePasswordPage.test.jsx index 51351d7..c1f6e4f 100644 --- a/external-admin/src/pages/ChangePasswordPage.test.jsx +++ b/external-admin/src/pages/ChangePasswordPage.test.jsx @@ -7,7 +7,13 @@ import ChangePasswordPage from "./ChangePasswordPage.jsx"; const auth = vi.hoisted(() => ({ changePassword: vi.fn(), logout: vi.fn(), - session: { account: "fami-manager", passwordChangeRequired: true } + session: { + account: "fami-manager", + appCode: "fami", + appName: "Fami", + logoUrl: "https://media.example.com/apps/fami.png", + passwordChangeRequired: true + } })); vi.mock("../auth/ExternalAuthProvider.jsx", () => ({ useExternalAuth: () => auth })); @@ -16,6 +22,29 @@ describe("ChangePasswordPage", () => { beforeEach(() => { auth.changePassword.mockReset().mockResolvedValue({}); auth.logout.mockReset().mockResolvedValue(undefined); + auth.session = { + account: "fami-manager", + appCode: "fami", + appName: "Fami", + logoUrl: "https://media.example.com/apps/fami.png", + passwordChangeRequired: true + }; + }); + + test("shows the App brand resolved by the authenticated session", () => { + auth.session = { + ...auth.session, + account: "lalu-manager", + appCode: "lalu", + appName: "Lalu", + logoUrl: "https://media.example.com/apps/lalu.png" + }; + const view = render(); + + expect(screen.getByText("Lalu")).toBeInTheDocument(); + expect(view.container.querySelector("img")).toHaveAttribute("src", "https://media.example.com/apps/lalu.png"); + expect(document.title).toBe("Lalu"); + expect(document.head.querySelector('link[data-external-app-favicon="true"]')).toHaveAttribute("href", "https://media.example.com/apps/lalu.png"); }); test("blocks a whitespace-only new password", async () => { diff --git a/external-admin/src/pages/LoginPage.jsx b/external-admin/src/pages/LoginPage.jsx index c9a2747..963cc02 100644 --- a/external-admin/src/pages/LoginPage.jsx +++ b/external-admin/src/pages/LoginPage.jsx @@ -28,6 +28,12 @@ export default function LoginPage() { const navigate = useNavigate(); const location = useLocation(); + useEffect(() => { + // 登录成功前没有可信 App 上下文;使用通用标题并清掉上一个会话的图标,避免泄露或误导用户所属 App。 + globalThis.document.title = t("app.title"); + globalThis.document.head.querySelector('link[data-external-app-favicon="true"]')?.remove(); + }, [t]); + useEffect(() => { if (!initializing && session) { const destination = session.passwordChangeRequired ? "/change-password" : location.state?.from?.pathname || "/overview"; diff --git a/external-admin/src/pages/LoginPage.test.jsx b/external-admin/src/pages/LoginPage.test.jsx index eb4ed36..000df23 100644 --- a/external-admin/src/pages/LoginPage.test.jsx +++ b/external-admin/src/pages/LoginPage.test.jsx @@ -18,12 +18,22 @@ describe("LoginPage", () => { test("defaults to English, has no App field and submits only account and password", async () => { const user = userEvent.setup(); + const staleFavicon = document.createElement("link"); + staleFavicon.dataset.externalAppFavicon = "true"; + staleFavicon.rel = "icon"; + staleFavicon.href = "https://media.example.com/apps/lalu.png"; + document.head.append(staleFavicon); + document.title = "Lalu"; const { container } = renderLogin(); - expect(screen.getByRole("heading", { name: "Fami" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "External Admin" })).toBeInTheDocument(); expect(container.querySelector("img")).toBeNull(); + expect(document.title).toBe("External Admin"); + expect(document.head.querySelector('link[data-external-app-favicon="true"]')).not.toBeInTheDocument(); expect(screen.queryByRole("combobox", { name: "App" })).not.toBeInTheDocument(); await user.type(screen.getByRole("textbox", { name: "External admin account" }), "fami-manager"); + expect(screen.getByRole("heading", { name: "External Admin" })).toBeInTheDocument(); + expect(container.querySelector("img")).toBeNull(); await user.type(container.querySelector('input[autocomplete="current-password"]'), "secret-password"); await user.click(screen.getByRole("button", { name: "Sign in" })); diff --git a/external-admin/src/shared/useExternalSessionBrand.js b/external-admin/src/shared/useExternalSessionBrand.js new file mode 100644 index 0000000..f7cbbef --- /dev/null +++ b/external-admin/src/shared/useExternalSessionBrand.js @@ -0,0 +1,32 @@ +import { useEffect } from "react"; + +export function useExternalSessionBrand(session, fallbackTitle) { + const appName = String(session?.appName || session?.appCode || fallbackTitle || "External Admin").trim(); + const logoUrl = String(session?.logoUrl || "").trim(); + + useEffect(() => { + // 认证后的 App 品牌只采用服务端 session;session 又来自 apps 行,前端不解析账号名称猜租户。 + globalThis.document.title = appName; + syncBrandFavicon(logoUrl); + return () => { + globalThis.document.title = fallbackTitle || "External Admin"; + syncBrandFavicon(""); + }; + }, [appName, fallbackTitle, logoUrl]); +} + +function syncBrandFavicon(logoUrl) { + const selector = 'link[data-external-app-favicon="true"]'; + const current = globalThis.document?.head?.querySelector(selector); + if (!logoUrl) { + current?.remove(); + return; + } + const link = current || globalThis.document.createElement("link"); + link.dataset.externalAppFavicon = "true"; + link.rel = "icon"; + link.href = logoUrl; + if (!current) { + globalThis.document.head.append(link); + } +} diff --git a/external-admin/src/styles/index.css b/external-admin/src/styles/index.css index d0ed2ac..3bff03d 100644 --- a/external-admin/src/styles/index.css +++ b/external-admin/src/styles/index.css @@ -35,9 +35,9 @@ body { min-width: 140px !important; } -.external-login-avatar { - background: var(--brand-gradient) !important; - color: #fff; +.external-change-password-brand { + font-size: 18px; + font-weight: 750; } .external-full-state { diff --git a/src/shared/ui/AppIdentity.test.jsx b/src/shared/ui/AppIdentity.test.jsx index a4d3c88..ac57526 100644 --- a/src/shared/ui/AppIdentity.test.jsx +++ b/src/shared/ui/AppIdentity.test.jsx @@ -39,3 +39,17 @@ test("can hide the fallback when a surface must only show a database logo", () = expect(screen.getByText("Fami")).toBeInTheDocument(); expect(screen.queryByText("F", { selector: "span" })).not.toBeInTheDocument(); }); + +test("does not invent a fallback when an external portal database logo fails to load", () => { + const { container } = render( + , + ); + + fireEvent.error(container.querySelector("img")); + expect(container.querySelector("img")).toBeNull(); + expect(screen.getByText("Fami")).toBeInTheDocument(); + expect(screen.queryByText("F", { selector: "span" })).not.toBeInTheDocument(); +}); From 4d872de3026049999f16f82bf605adcc4b66543a Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 15 Jul 2026 14:49:39 +0800 Subject: [PATCH 07/15] feat(ops-center): support lucky gift dynamic v3 --- contracts/admin-openapi.json | 20 +- ops-center/src/OpsCenterApp.jsx | 316 ++++--- ops-center/src/OpsCenterApp.test.jsx | 436 +++++++--- ops-center/src/api.js | 479 ++++++----- ops-center/src/api.test.js | 334 +++++--- ops-center/src/components/ConfigsView.jsx | 184 ++-- .../src/components/LuckyGiftConfigDialog.jsx | 794 ++++++++++++------ ops-center/src/configForm.js | 544 +++++++++--- ops-center/src/configForm.test.js | 217 +++++ ops-center/src/styles/index.css | 509 +++++------ .../lucky-gift/pages/LuckyGiftConfigPage.jsx | 214 +---- .../pages/LuckyGiftConfigPage.test.jsx | 14 + src/shared/api/generated/endpoints.ts | 18 +- src/shared/api/generated/schema.d.ts | 36 +- 14 files changed, 2620 insertions(+), 1495 deletions(-) create mode 100644 ops-center/src/configForm.test.js create mode 100644 src/features/lucky-gift/pages/LuckyGiftConfigPage.test.jsx diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index 703b45c..96afce1 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -320,7 +320,7 @@ "x-permissions": ["red-packet:update"] } }, - "/admin/activity/lucky-gifts/v2/config": { + "/admin/ops-center/lucky-gifts/config": { "get": { "operationId": "getLuckyGiftConfig", "responses": { @@ -342,7 +342,7 @@ "x-permissions": ["lucky-gift:update"] } }, - "/admin/activity/lucky-gifts/v2/configs": { + "/admin/ops-center/lucky-gifts/configs": { "get": { "operationId": "listLuckyGiftConfigs", "responses": { @@ -354,7 +354,7 @@ "x-permissions": ["lucky-gift:view"] } }, - "/admin/activity/lucky-gifts/v2/draws": { + "/admin/ops-center/lucky-gifts/draws": { "get": { "operationId": "listLuckyGiftDraws", "responses": { @@ -366,7 +366,7 @@ "x-permissions": ["lucky-gift:view"] } }, - "/admin/activity/lucky-gifts/v2/draw-summary": { + "/admin/ops-center/lucky-gifts/summary": { "get": { "operationId": "getLuckyGiftDrawSummary", "responses": { @@ -378,6 +378,18 @@ "x-permissions": ["lucky-gift:view"] } }, + "/admin/ops-center/lucky-gifts/pools": { + "get": { + "operationId": "listLuckyGiftPoolBalances", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "lucky-gift:view", + "x-permissions": ["lucky-gift:view"] + } + }, "/admin/activity/wheel/config": { "get": { "operationId": "getWheelConfig", diff --git a/ops-center/src/OpsCenterApp.jsx b/ops-center/src/OpsCenterApp.jsx index 3c55ee0..7d0e75b 100644 --- a/ops-center/src/OpsCenterApp.jsx +++ b/ops-center/src/OpsCenterApp.jsx @@ -17,149 +17,185 @@ import { OpsShell } from "./components/OpsShell.jsx"; import { OverviewView } from "./components/OverviewView.jsx"; const views = [ - { icon: InsightsOutlined, key: "overview", label: "数据总览" }, - { icon: CardGiftcardOutlined, key: "configs", label: "幸运礼物配置" }, - { icon: CasinoOutlined, key: "draws", label: "抽奖记录" }, - { icon: AppsOutlined, key: "apps", label: "应用列表" } + { icon: InsightsOutlined, key: "overview", label: "数据总览" }, + { icon: CardGiftcardOutlined, key: "configs", label: "幸运礼物配置" }, + { icon: CasinoOutlined, key: "draws", label: "抽奖记录" }, + { icon: AppsOutlined, key: "apps", label: "应用列表" }, ]; +const viewKeys = new Set(views.map((view) => view.key)); const initialDashboard = { - apps: [], - appSummaries: [], - luckyGifts: [], - poolBalances: [], - summary: {} + apps: [], + appSummaries: [], + luckyGifts: [], + poolBalances: [], + summary: {}, }; -export function OpsCenterApp() { - const [activeView, setActiveView] = useState("overview"); - const [state, setState] = useState({ data: initialDashboard, error: "", loaded: false, loading: true }); - const [dialog, setDialog] = useState(null); - const [savingConfig, setSavingConfig] = useState(false); - const { showToast } = useToast(); - - const loadDashboard = useCallback(async () => { - setState((current) => ({ ...current, error: "", loading: true })); - try { - const data = await fetchOpsDashboard(); - setState({ data, error: "", loaded: true, loading: false }); - } catch (error) { - // 接口灰度期间保留已加载的数据和页面结构,单次失败只提示、不整页白屏。 - setState((current) => ({ - data: current.data || initialDashboard, - error: error.message || "运营接口暂不可用", - loaded: current.loaded, - loading: false - })); - } - }, []); - - useEffect(() => { - loadDashboard(); - }, [loadDashboard]); - - const { data } = state; - - const appOptions = useMemo( - () => data.apps.map((item) => [item.app_code ?? item.appCode, `${item.app_name ?? item.appName ?? ""} (${item.app_code ?? item.appCode})`]), - [data.apps] - ); - - // 抽奖记录页的奖池下拉:配置和真实水位都可能先于对方存在(先发布未入账 / 老池子停用后仍有流水),取并集。 - const poolIDsByApp = useMemo(() => { - const byApp = new Map(); - const add = (appCode, poolID) => { - if (!appCode || !poolID) { - return; - } - if (!byApp.has(appCode)) { - byApp.set(appCode, new Set()); - } - byApp.get(appCode).add(poolID); - }; - data.luckyGifts.forEach((config) => add(config.app_code, config.pool_id)); - data.poolBalances.forEach((pool) => add(pool.app_code ?? pool.appCode, pool.pool_id ?? pool.poolId)); - return new Map(Array.from(byApp.entries(), ([appCode, poolIDs]) => [appCode, Array.from(poolIDs).sort()])); - }, [data.luckyGifts, data.poolBalances]); - - const openCreateConfig = useCallback(() => { - // 新建配置以第一个应用的现有配置做参数模板(每个应用至少有一行草稿或已发布配置), - // 应用和奖池在弹窗里由运营自行选择,避免旧版"添加配置"悄悄绑定到第一个应用的问题。 - const template = data.luckyGifts[0]; - if (!template) { - showToast("暂无可添加配置的应用", "warning"); - return; - } - setDialog({ - config: { ...structuredClone(template), is_default: true, pool_id: DEFAULT_POOL_ID, rule_version: 0 }, - mode: "create" - }); - }, [data.luckyGifts, showToast]); - - const openEditConfig = useCallback((config) => { - setDialog({ config: structuredClone(config), mode: "edit" }); - }, []); - - const handleSaveConfig = useCallback( - async (config) => { - setSavingConfig(true); - try { - await saveLuckyGiftConfig(config); - showToast(`已发布 ${config.app_code} / ${config.pool_id} 的新规则版本`, "success"); - setDialog(null); - await loadDashboard(); - } catch (error) { - // 保存失败保持弹窗打开,运营修完参数可直接重试,不丢已填内容。 - showToast(error.message || "保存幸运礼物配置失败", "error"); - } finally { - setSavingConfig(false); - } - }, - [loadDashboard, showToast] - ); - - const defaultAppCode = data.apps[0]?.app_code ?? data.apps[0]?.appCode ?? ""; - - return ( - -
- - {state.loading ? "同步中" : "已就绪"} - - - {/* 首屏失败走 DataState 的错误重试页;已有数据时刷新失败只弹提示条,保留旧数据可用。 */} - {state.error && state.loaded ? ( -
- {state.error} -
- ) : null} - - {activeView === "overview" ? : null} - {activeView === "configs" ? ( - - ) : null} - {activeView === "draws" ? : null} - {activeView === "apps" ? : null} - - {dialog ? ( - setDialog(null)} - onSubmit={handleSaveConfig} - /> - ) : null} -
-
- ); +export function initialOpsCenterView(search = globalThis.location?.search || "") { + const requested = new URLSearchParams(search).get("view") || ""; + return viewKeys.has(requested) ? requested : "overview"; +} + +export function OpsCenterApp() { + // 主后台旧入口通过 ?view=configs 进入独立 HTML;只接受白名单视图,异常参数仍回到总览。 + const [activeView, setActiveView] = useState(() => initialOpsCenterView()); + const [state, setState] = useState({ data: initialDashboard, error: "", loaded: false, loading: true }); + const [dialog, setDialog] = useState(null); + const [savingConfig, setSavingConfig] = useState(false); + const { showToast } = useToast(); + + const loadDashboard = useCallback(async () => { + setState((current) => ({ ...current, error: "", loading: true })); + try { + const data = await fetchOpsDashboard(); + setState({ data, error: "", loaded: true, loading: false }); + } catch (error) { + // 接口灰度期间保留已加载的数据和页面结构,单次失败只提示、不整页白屏。 + setState((current) => ({ + data: current.data || initialDashboard, + error: error.message || "运营接口暂不可用", + loaded: current.loaded, + loading: false, + })); + } + }, []); + + useEffect(() => { + loadDashboard(); + }, [loadDashboard]); + + const { data } = state; + + const appOptions = useMemo( + () => + data.apps.map((item) => [ + item.app_code ?? item.appCode, + `${item.app_name ?? item.appName ?? ""} (${item.app_code ?? item.appCode})`, + ]), + [data.apps], + ); + + // 抽奖记录页的奖池下拉:配置和真实水位都可能先于对方存在(先发布未入账 / 老池子停用后仍有流水),取并集。 + const poolIDsByApp = useMemo(() => { + const byApp = new Map(); + const add = (appCode, poolID) => { + if (!appCode || !poolID) { + return; + } + if (!byApp.has(appCode)) { + byApp.set(appCode, new Set()); + } + byApp.get(appCode).add(poolID); + }; + data.luckyGifts.forEach((config) => add(config.app_code, config.pool_id)); + data.poolBalances.forEach((pool) => add(pool.app_code ?? pool.appCode, pool.pool_id ?? pool.poolId)); + return new Map(Array.from(byApp.entries(), ([appCode, poolIDs]) => [appCode, Array.from(poolIDs).sort()])); + }, [data.luckyGifts, data.poolBalances]); + + const openCreateConfig = useCallback(() => { + const firstAppCode = data.apps + .map((item) => + String(item.app_code ?? item.appCode ?? "") + .trim() + .toLowerCase(), + ) + .find(Boolean); + if (!firstAppCode) { + showToast("暂无可添加配置的应用", "warning"); + return; + } + // 金额门槛和六维上限按 App 金币口径配置,绝不能复制第一条已发布规则给另一个 App。 + // 仅传规则身份,让 configToForm 生成 disabled dynamic_v3 安全草稿;金额字段保持 0,运营补齐后才能启用。 + setDialog({ + config: { + app_code: firstAppCode, + enabled: false, + is_default: true, + pool_id: DEFAULT_POOL_ID, + rule_version: 0, + strategy_version: "dynamic_v3", + }, + mode: "create", + }); + }, [data.apps, showToast]); + + const openEditConfig = useCallback((config) => { + setDialog({ config: structuredClone(config), mode: "edit" }); + }, []); + + const handleSaveConfig = useCallback( + async (config) => { + setSavingConfig(true); + try { + await saveLuckyGiftConfig(config); + showToast(`已发布 ${config.app_code} / ${config.pool_id} 的新规则版本`, "success"); + setDialog(null); + await loadDashboard(); + } catch (error) { + // 保存失败保持弹窗打开,运营修完参数可直接重试,不丢已填内容。 + showToast(error.message || "保存幸运礼物配置失败", "error"); + } finally { + setSavingConfig(false); + } + }, + [loadDashboard, showToast], + ); + + const defaultAppCode = data.apps[0]?.app_code ?? data.apps[0]?.appCode ?? ""; + + return ( + +
+ + + {state.loading ? "同步中" : "已就绪"} + + + + {/* 首屏失败走 DataState 的错误重试页;已有数据时刷新失败只弹提示条,保留旧数据可用。 */} + {state.error && state.loaded ? ( +
+ {state.error} +
+ ) : null} + + {activeView === "overview" ? : null} + {activeView === "configs" ? ( + + ) : null} + {activeView === "draws" ? ( + + ) : null} + {activeView === "apps" ? : null} + + {dialog ? ( + setDialog(null)} + onSubmit={handleSaveConfig} + /> + ) : null} +
+
+ ); } diff --git a/ops-center/src/OpsCenterApp.test.jsx b/ops-center/src/OpsCenterApp.test.jsx index 6a6b61b..c5d4e21 100644 --- a/ops-center/src/OpsCenterApp.test.jsx +++ b/ops-center/src/OpsCenterApp.test.jsx @@ -2,175 +2,339 @@ import { fireEvent, render, screen, waitFor, within } from "@testing-library/rea import { beforeEach, expect, test, vi } from "vitest"; import { ToastProvider } from "@/shared/ui/ToastProvider.jsx"; import { fetchLuckyGiftDraws, fetchOpsDashboard, saveLuckyGiftConfig } from "./api.js"; -import { OpsCenterApp } from "./OpsCenterApp.jsx"; +import { initialOpsCenterView, OpsCenterApp } from "./OpsCenterApp.jsx"; vi.mock("./api.js", () => ({ - DEFAULT_POOL_ID: "default", - fetchLuckyGiftDraws: vi.fn(), - fetchOpsDashboard: vi.fn(), - saveLuckyGiftConfig: vi.fn() + DEFAULT_POOL_ID: "default", + fetchLuckyGiftDraws: vi.fn(), + fetchOpsDashboard: vi.fn(), + saveLuckyGiftConfig: vi.fn(), })); beforeEach(() => { - vi.clearAllMocks(); - saveLuckyGiftConfig.mockResolvedValue({ app_code: "lalu", pool_id: "lucky" }); - fetchLuckyGiftDraws.mockResolvedValue({ - items: [ - { - app_code: "lalu", - created_at_ms: 1767000000000, - draw_id: "draw-1", - effective_reward_coins: 100, - external_user_id: "ext-1", - gift_id: "gift-9", - multiplier_ppm: 2_000_000, - pool_id: "super_lucky", - reward_status: "granted", - rule_version: 2 - } - ], - page: 1, - pageSize: 20, - total: 1 - }); - fetchOpsDashboard.mockResolvedValue({ - apps: [ - { app_code: "lalu", app_name: "Lalu", source: "registry", status: "active", updated_at_ms: 1767000000000 }, - { app_code: "yumi", app_name: "Yumi", source: "fixed", status: "active" } - ], - appSummaries: [ - { - actual_rtp_ppm: 900000, - app_code: "lalu", - failed_draws: 0, - granted_draws: 3, - pending_draws: 0, - total_draws: 3, - total_reward_coins: 900, - total_spent_coins: 1000, - unique_rooms: 2, - unique_users: 2 - } - ], - luckyGifts: [ - { app_code: "lalu", enabled: true, is_default: false, pool_id: "lucky", rule_version: 3, target_rtp_percent: 95 }, - { app_code: "lalu", enabled: false, is_default: false, pool_id: "super_lucky", rule_version: 2, target_rtp_percent: 92 }, - { app_code: "yumi", enabled: false, is_default: true, pool_id: "default", rule_version: 0, target_rtp_percent: 95 } - ], - poolBalances: [ - { app_code: "lalu", available_balance: 37950017, balance: 38559017, materialized: true, pool_id: "lucky", reserve_floor: 609000, updated_at_ms: 1767000000000 }, - { app_code: "yumi", available_balance: 361500, balance: 382500, materialized: false, pool_id: "default", reserve_floor: 21000 } - ], - summary: { - activeApps: 2, - configuredPools: 2, - enabledPools: 1, - failedDraws: 0, - grantedDraws: 3, - pendingDraws: 0, - poolAvailableTotal: 38311517, - poolBalanceTotal: 38941517, - poolReserveTotal: 630000, - totalDraws: 3, - totalRewardCoins: 900, - totalSpentCoins: 1000 - } - }); + vi.clearAllMocks(); + window.history.replaceState({}, "", "/"); + saveLuckyGiftConfig.mockResolvedValue({ app_code: "lalu", pool_id: "lucky" }); + fetchLuckyGiftDraws.mockResolvedValue({ + items: [ + { + app_code: "lalu", + created_at_ms: 1767000000000, + draw_id: "draw-1", + effective_reward_coins: 100, + external_user_id: "ext-1", + gift_id: "gift-9", + multiplier_ppm: 2_000_000, + pool_id: "super_lucky", + reward_status: "granted", + rule_version: 2, + }, + ], + page: 1, + pageSize: 20, + total: 1, + }); + fetchOpsDashboard.mockResolvedValue({ + apps: [ + { app_code: "lalu", app_name: "Lalu", source: "registry", status: "active", updated_at_ms: 1767000000000 }, + { app_code: "yumi", app_name: "Yumi", source: "fixed", status: "active" }, + ], + appSummaries: [ + { + actual_rtp_ppm: 900000, + app_code: "lalu", + failed_draws: 0, + granted_draws: 3, + pending_draws: 0, + total_draws: 3, + total_reward_coins: 900, + total_spent_coins: 1000, + unique_rooms: 2, + unique_users: 2, + }, + ], + luckyGifts: [ + { + ...mockDynamicConfig(), + app_code: "lalu", + enabled: true, + is_default: false, + pool_id: "lucky", + rule_version: 3, + }, + { + ...mockFixedConfig(92), + app_code: "lalu", + enabled: false, + is_default: false, + pool_id: "super_lucky", + rule_version: 2, + }, + { + ...mockDynamicConfig(), + app_code: "yumi", + enabled: false, + is_default: true, + pool_id: "default", + rule_version: 0, + }, + ], + poolBalances: [ + { + app_code: "lalu", + available_balance: 37950017, + balance: 38559017, + materialized: true, + pool_id: "lucky", + reserve_floor: 609000, + updated_at_ms: 1767000000000, + }, + { + app_code: "yumi", + available_balance: 361500, + balance: 382500, + materialized: false, + pool_id: "default", + reserve_floor: 21000, + }, + ], + summary: { + activeApps: 2, + configuredPools: 2, + enabledPools: 1, + failedDraws: 0, + grantedDraws: 3, + pendingDraws: 0, + poolAvailableTotal: 38311517, + poolBalanceTotal: 38941517, + poolReserveTotal: 630000, + totalDraws: 3, + totalRewardCoins: 900, + totalSpentCoins: 1000, + }, + }); }); function renderApp() { - return render( - - - - ); + return render( + + + , + ); } test("renders overview with aggregated kpis and pool balances", async () => { - renderApp(); + renderApp(); - expect(await screen.findByRole("heading", { name: "运营配置中心" })).toBeTruthy(); - expect(screen.getByRole("navigation", { name: "运营中心导航" })).toBeTruthy(); - ["数据总览", "幸运礼物配置", "抽奖记录", "应用列表"].forEach((label) => { - expect(screen.getByRole("button", { name: new RegExp(label) })).toBeTruthy(); - }); + expect(await screen.findByRole("heading", { name: "运营配置中心" })).toBeTruthy(); + expect(screen.getByRole("navigation", { name: "运营中心导航" })).toBeTruthy(); + ["数据总览", "幸运礼物配置", "抽奖记录", "应用列表"].forEach((label) => { + expect(screen.getByRole("button", { name: new RegExp(label) })).toBeTruthy(); + }); - expect(await screen.findByText("运营应用")).toBeTruthy(); - // 抽奖次数 KPI 必须是跨应用聚合值,而不是第一个应用的汇总(KPI 卡和汇总表列头各出现一次)。 - expect(screen.getAllByText("抽奖次数").length).toBeGreaterThan(0); - expect(screen.getAllByText("38,941,517").length).toBeGreaterThan(0); - expect(screen.getByText("奖池实时水位")).toBeTruthy(); - expect(screen.getByText("已入账")).toBeTruthy(); - expect(screen.getByText("默认水位")).toBeTruthy(); - expect(fetchOpsDashboard).toHaveBeenCalledTimes(1); + expect(await screen.findByText("运营应用")).toBeTruthy(); + // 抽奖次数 KPI 必须是跨应用聚合值,而不是第一个应用的汇总(KPI 卡和汇总表列头各出现一次)。 + expect(screen.getAllByText("抽奖次数").length).toBeGreaterThan(0); + expect(screen.getAllByText("38,941,517").length).toBeGreaterThan(0); + expect(screen.getByText("奖池实时水位")).toBeTruthy(); + expect(screen.getByText("已入账")).toBeTruthy(); + expect(screen.getByText("默认水位")).toBeTruthy(); + expect(fetchOpsDashboard).toHaveBeenCalledTimes(1); +}); + +test("opens the requested config view from the legacy admin document redirect", async () => { + expect(initialOpsCenterView("?view=configs")).toBe("configs"); + expect(initialOpsCenterView("?view=unknown")).toBe("overview"); + window.history.replaceState({}, "", "/ops-center/?view=configs"); + + renderApp(); + + expect(await screen.findByRole("button", { name: "添加配置" })).toBeTruthy(); + expect(screen.getByText("lucky")).toBeTruthy(); }); test("lists every pool config including published non-default pools", async () => { - renderApp(); - await screen.findByText("运营应用"); + renderApp(); + await screen.findByText("运营应用"); - fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ })); + fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ })); - // 核心回归:lalu 的 lucky / super_lucky 已发布奖池必须出现在配置列表里。 - expect(await screen.findByText("lucky")).toBeTruthy(); - expect(screen.getByText("super_lucky")).toBeTruthy(); - expect(screen.getByText("v3")).toBeTruthy(); - expect(screen.getByText("v2")).toBeTruthy(); - expect(screen.getByText("已启用")).toBeTruthy(); - expect(screen.getByText("已停用")).toBeTruthy(); - expect(screen.getByText("默认草稿")).toBeTruthy(); + // 核心回归:lalu 的 lucky / super_lucky 已发布奖池必须出现在配置列表里。 + expect(await screen.findByText("lucky")).toBeTruthy(); + expect(screen.getByText("super_lucky")).toBeTruthy(); + expect(screen.getByText("v3")).toBeTruthy(); + expect(screen.getByText("v2")).toBeTruthy(); + expect(screen.getByText("已启用")).toBeTruthy(); + expect(screen.getByText("已停用")).toBeTruthy(); + expect(screen.getByText("默认草稿")).toBeTruthy(); +}); + +test("creates a disabled dynamic draft without copying another app monetary limits", async () => { + renderApp(); + await screen.findByText("运营应用"); + fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ })); + fireEvent.click(await screen.findByRole("button", { name: "添加配置" })); + + const dialog = await screen.findByRole("dialog"); + expect(within(dialog).getByText("添加幸运礼物配置")).toBeTruthy(); + expect(within(dialog).getByLabelText("策略版本").textContent).toContain("dynamic_v3"); + expect(within(dialog).getByLabelText(/初始奖池金币/).value).toBe("1000000"); + // 第一条 lalu 已发布配置的单次上限为 100 万;新建跨 App 配置必须归零并保持停用。 + expect(within(dialog).getByLabelText(/单次返奖上限/).value).toBe("0"); + expect(within(dialog).getByText("发布后保持停用")).toBeTruthy(); }); test("opens config dialog from a published pool row and saves a new version", async () => { - renderApp(); - await screen.findByText("运营应用"); + renderApp(); + await screen.findByText("运营应用"); - fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ })); - await screen.findByText("lucky"); + fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ })); + await screen.findByText("lucky"); - fireEvent.click(screen.getAllByRole("button", { name: "新增版本" })[0]); - const dialog = await screen.findByRole("dialog"); - expect(within(dialog).getByText("新增幸运礼物版本")).toBeTruthy(); - expect(within(dialog).getAllByLabelText("概率 %")[0].readOnly).toBe(true); + fireEvent.click(screen.getAllByRole("button", { name: "新增版本" })[0]); + const dialog = await screen.findByRole("dialog"); + expect(within(dialog).getByText("新增幸运礼物版本")).toBeTruthy(); + expect(within(dialog).getByLabelText("策略版本").textContent).toContain("dynamic_v3"); + expect(within(dialog).getByLabelText(/初始奖池金币/).value).toBe("1000000"); + expect(within(dialog).getByLabelText(/大奖倍率/).value).toBe("200, 500, 1000"); + expect(within(dialog).getAllByLabelText("概率 %")[0].readOnly).toBe(true); - fireEvent.change(within(dialog).getByLabelText(/目标 RTP \(%\)/), { target: { value: "88" } }); - fireEvent.click(within(dialog).getByRole("button", { name: "保存配置" })); + fireEvent.change(within(dialog).getByLabelText(/目标 RTP \(%\)/), { target: { value: "88" } }); + fireEvent.click(within(dialog).getByRole("button", { name: "保存配置" })); - await waitFor(() => { - expect(saveLuckyGiftConfig).toHaveBeenCalledWith( - expect.objectContaining({ - app_code: "lalu", - pool_id: "lucky", - stages: expect.arrayContaining([expect.objectContaining({ stage: "novice" })]), - target_rtp_percent: 88 - }) - ); - }); + await waitFor(() => { + expect(saveLuckyGiftConfig).toHaveBeenCalledWith( + expect.objectContaining({ + app_code: "lalu", + anchor_rate_percent: 1, + jackpot_multipliers: [200, 500, 1000], + pool_id: "lucky", + profit_rate_percent: 1, + stages: expect.arrayContaining([expect.objectContaining({ stage: "novice" })]), + strategy_version: "dynamic_v3", + target_rtp_percent: 88, + }), + ); + }); }); +test("blocks an invalid enabled dynamic rule before calling the save API", async () => { + renderApp(); + await screen.findByText("运营应用"); + fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ })); + await screen.findByText("lucky"); + fireEvent.click(screen.getAllByRole("button", { name: "新增版本" })[0]); + + const dialog = await screen.findByRole("dialog"); + fireEvent.change(within(dialog).getByLabelText(/初始奖池金币/), { target: { value: "0" } }); + fireEvent.click(within(dialog).getByRole("button", { name: "保存配置" })); + + expect(await within(dialog).findByRole("alert")).toHaveTextContent("初始奖池必须大于 0"); + expect(saveLuckyGiftConfig).not.toHaveBeenCalled(); + + // 奖档编辑同样必须清掉上一次提交错误,避免用户修正后仍看到已经过期的阻断原因。 + fireEvent.change(within(dialog).getAllByLabelText("倍率")[0], { target: { value: "0.1" } }); + expect(within(dialog).queryByRole("alert")).toBeNull(); +}); + +function mockDynamicConfig() { + return { + anchor_daily_payout_cap: 6_000_000, + anchor_rate_percent: 1, + control_band_percent: 3, + device_daily_payout_cap: 4_000_000, + effective_from_ms: 0, + gift_price_reference: 100, + high_water_nonzero_factor_percent: 130, + high_watermark_coins: 20_000_000, + initial_pool_coins: 1_000_000, + jackpot_global_rtp_max_percent: 98, + jackpot_multipliers: [200, 500, 1000], + jackpot_spend_threshold_coins: 50_000, + jackpot_user_72h_rtp_max_percent: 96, + jackpot_user_day_rtp_max_percent: 96, + loss_streak_guarantee: 5, + low_water_nonzero_factor_percent: 70, + low_watermark_coins: 10_000_000, + max_jackpot_hits_per_user_day: 5, + max_single_payout: 1_000_000, + normal_max_equivalent_draws: 20_000, + novice_max_equivalent_draws: 2_000, + pool_rate_percent: 98, + profit_rate_percent: 1, + recharge_boost_factor_percent: 110, + recharge_boost_window_ms: 300_000, + room_hourly_payout_cap: 5_000_000, + settlement_window_wager: 1_000_000, + stages: mockStages(98), + strategy_version: "dynamic_v3", + target_rtp_percent: 98, + user_daily_payout_cap: 3_000_000, + user_hourly_payout_cap: 2_000_000, + }; +} + +function mockFixedConfig(targetRTP) { + return { + control_band_percent: 1, + gift_price_reference: 100, + normal_max_equivalent_draws: 20_000, + novice_max_equivalent_draws: 2_000, + pool_rate_percent: targetRTP + 1, + settlement_window_wager: 1_000_000, + stages: mockStages(targetRTP), + strategy_version: "fixed_v2", + target_rtp_percent: targetRTP, + }; +} + +function mockStages(targetRTP) { + return ["novice", "normal", "advanced"].map((stage, index) => ({ + min_recharge_30d_coins: index === 0 ? 0 : index, + min_recharge_7d_coins: index === 2 ? 1 : 0, + stage, + tiers: [ + { + enabled: true, + high_water_only: false, + multiplier: 0, + probability_percent: 100 - targetRTP, + tier_id: `${stage}_none`, + }, + { + enabled: true, + high_water_only: false, + multiplier: 1, + probability_percent: targetRTP, + tier_id: `${stage}_1x`, + }, + ], + })); +} + test("loads draw records for the first app with server pagination", async () => { - renderApp(); - await screen.findByText("运营应用"); + renderApp(); + await screen.findByText("运营应用"); - fireEvent.click(screen.getByRole("button", { name: /抽奖记录/ })); + fireEvent.click(screen.getByRole("button", { name: /抽奖记录/ })); - await waitFor(() => { - expect(fetchLuckyGiftDraws).toHaveBeenCalledWith( - expect.objectContaining({ appCode: "lalu", page: 1, poolId: "", status: "" }) - ); - }); - expect(await screen.findByText("ext-1")).toBeTruthy(); - expect(screen.getByText("已发放")).toBeTruthy(); - expect(screen.getByText("2x")).toBeTruthy(); + await waitFor(() => { + expect(fetchLuckyGiftDraws).toHaveBeenCalledWith( + expect.objectContaining({ appCode: "lalu", page: 1, poolId: "", status: "" }), + ); + }); + expect(await screen.findByText("ext-1")).toBeTruthy(); + expect(screen.getByText("已发放")).toBeTruthy(); + expect(screen.getByText("2x")).toBeTruthy(); }); test("shows app registry and fixed integrations in apps view", async () => { - renderApp(); - await screen.findByText("运营应用"); + renderApp(); + await screen.findByText("运营应用"); - fireEvent.click(screen.getByRole("button", { name: /应用列表/ })); + fireEvent.click(screen.getByRole("button", { name: /应用列表/ })); - expect(await screen.findByText("Lalu")).toBeTruthy(); - expect(screen.getByText("主数据")).toBeTruthy(); - expect(screen.getByText("外部接入")).toBeTruthy(); + expect(await screen.findByText("Lalu")).toBeTruthy(); + expect(screen.getByText("主数据")).toBeTruthy(); + expect(screen.getByText("外部接入")).toBeTruthy(); }); diff --git a/ops-center/src/api.js b/ops-center/src/api.js index 1983fb4..9095cce 100644 --- a/ops-center/src/api.js +++ b/ops-center/src/api.js @@ -4,261 +4,344 @@ export const OPS_API_PREFIX = "/api/v1/admin/ops-center"; export const DEFAULT_POOL_ID = "default"; export async function fetchOpsDashboard(query = {}) { - // Ops Center 是跨 App 的运营面板:先拉应用主数据,再按 app_code 扇出拉取奖池配置、余额和抽奖汇总。 - // lucky-gift-service 的所有后台接口都以 app_code 作为租户维度过滤,没有"全部应用"一次拉取的入口。 - const appsPayload = await opsRequest("/apps", { query }); - const apps = normalizeItems(appsPayload); - const appCodes = uniqueAppCodes(apps); + // Ops Center 是跨 App 的运营面板:先拉应用主数据,再按 app_code 扇出拉取奖池配置、余额和抽奖汇总。 + // lucky-gift-service 的所有后台接口都以 app_code 作为租户维度过滤,没有"全部应用"一次拉取的入口。 + const appsPayload = await opsRequest("/apps", { query }); + const apps = normalizeItems(appsPayload); + const appCodes = uniqueAppCodes(apps); - const perApp = await Promise.all( - appCodes.map(async (appCode) => { - const [luckyGifts, poolBalances, drawSummary] = await Promise.all([ - fetchAppLuckyGiftConfigs(appCode), - opsRequest("/lucky-gifts/pools", { query: { app_code: appCode } }).then(normalizeItems), - opsRequest("/lucky-gifts/summary", { query: { app_code: appCode } }) - ]); - return { appCode, drawSummary, luckyGifts, poolBalances }; - }) - ); + const perApp = await Promise.all( + appCodes.map(async (appCode) => { + const [luckyGifts, poolBalances, drawSummary] = await Promise.all([ + fetchAppLuckyGiftConfigs(appCode), + opsRequest("/lucky-gifts/pools", { query: { app_code: appCode } }).then(normalizeItems), + opsRequest("/lucky-gifts/summary", { query: { app_code: appCode } }), + ]); + return { appCode, drawSummary, luckyGifts, poolBalances }; + }), + ); - return normalizeDashboard({ apps, perApp }); + return normalizeDashboard({ apps, perApp }); } async function fetchAppLuckyGiftConfigs(appCode) { - // 必须用复数 /configs:它返回该应用每个奖池的最新已发布规则版本。 - // 单数 /config 只回默认奖池(无配置时是服务端草稿),会把 lalu 的 lucky/super_lucky 等已发布奖池整体漏掉。 - const published = normalizeItems(await opsRequest("/lucky-gifts/configs", { query: { app_code: appCode } })) - .map((config) => normalizeLuckyGiftConfig(config, appCode)) - .filter((config) => config.app_code); - if (published.length) { - return published; - } - // 一个已发布配置都没有的应用仍要展示一行可编辑草稿,让运营从这里发布第一版; - // 草稿的默认参数(RTP、回收率、结算窗口)以服务端 DefaultRuleConfig 为准,不在前端伪造。 - const draft = normalizeLuckyGiftConfig(await opsRequest("/lucky-gifts/config", { query: { app_code: appCode } }), appCode); - return draft.app_code ? [draft] : []; + // 必须用复数 /configs:它返回该应用每个奖池的最新已发布规则版本。 + // 单数 /config 只回默认奖池(无配置时是服务端草稿),会把 lalu 的 lucky/super_lucky 等已发布奖池整体漏掉。 + const published = normalizeItems(await opsRequest("/lucky-gifts/configs", { query: { app_code: appCode } })) + .map((config) => normalizeLuckyGiftConfig(config, appCode)) + .filter((config) => config.app_code); + if (published.length) { + return published; + } + // 一个已发布配置都没有的应用仍要展示一行可编辑草稿,让运营从这里发布第一版; + // 草稿的默认参数(RTP、回收率、结算窗口)以服务端 DefaultRuleConfig 为准,不在前端伪造。 + const draft = normalizeLuckyGiftConfig( + await opsRequest("/lucky-gifts/config", { query: { app_code: appCode } }), + appCode, + ); + return draft.app_code ? [draft] : []; } -export async function fetchLuckyGiftDraws({ appCode, page = 1, pageSize = 20, poolId = "", status = "", userId = "" } = {}) { - const query = { - app_code: appCode, - page, - page_size: pageSize, - pool_id: poolId, - status - }; - // 同一个输入框同时支持内部数字 UID 和外部接入方的字符串 ID:纯数字走 user_id 精确匹配, - // 其余走 external_user_id,避免运营要先分辨用户来源再选字段。 - const trimmedUserID = String(userId || "").trim(); - if (/^\d+$/.test(trimmedUserID)) { - query.user_id = trimmedUserID; - } else if (trimmedUserID) { - query.external_user_id = trimmedUserID; - } +export async function fetchLuckyGiftDraws({ + appCode, + page = 1, + pageSize = 20, + poolId = "", + status = "", + userId = "", +} = {}) { + const query = { + app_code: appCode, + page, + page_size: pageSize, + pool_id: poolId, + status, + }; + // 同一个输入框同时支持内部数字 UID 和外部接入方的字符串 ID:纯数字走 user_id 精确匹配, + // 其余走 external_user_id,避免运营要先分辨用户来源再选字段。 + const trimmedUserID = String(userId || "").trim(); + if (/^\d+$/.test(trimmedUserID)) { + query.user_id = trimmedUserID; + } else if (trimmedUserID) { + query.external_user_id = trimmedUserID; + } - const payload = await opsRequest("/lucky-gifts/draws", { query }); - return { - items: normalizeItems(payload), - page: numberValue(payload.page) || page, - pageSize: numberValue(payload.pageSize ?? payload.page_size) || pageSize, - total: numberValue(payload.total) - }; + const payload = await opsRequest("/lucky-gifts/draws", { query }); + return { + items: normalizeItems(payload), + page: numberValue(payload.page) || page, + pageSize: numberValue(payload.pageSize ?? payload.page_size) || pageSize, + total: numberValue(payload.total), + }; } export async function saveLuckyGiftConfig(config = {}) { - const payload = luckyGiftConfigPayload(config); - return opsRequest("/lucky-gifts/config", { - body: payload, - method: "PUT", - query: { - app_code: payload.app_code, - pool_id: payload.pool_id - } - }); + const payload = luckyGiftConfigPayload(config); + return opsRequest("/lucky-gifts/config", { + body: payload, + method: "PUT", + query: { + app_code: payload.app_code, + pool_id: payload.pool_id, + }, + }); } export async function opsRequest(path, { body, method = body ? "POST" : "GET", query } = {}) { - const response = await fetch(buildOpsUrl(path, query), { - body: body === undefined ? undefined : JSON.stringify(body), - credentials: "include", - headers: buildHeaders(body), - method - }); - const payload = await readPayload(response); + const response = await fetch(buildOpsUrl(path, query), { + body: body === undefined ? undefined : JSON.stringify(body), + credentials: "include", + headers: buildHeaders(body), + method, + }); + const payload = await readPayload(response); - if (!response.ok || payload.code !== 0) { - throw new Error(payload.message || response.statusText || "运营接口请求失败"); - } + if (!response.ok || payload.code !== 0) { + throw new Error(payload.message || response.statusText || "运营接口请求失败"); + } - return payload.data ?? {}; + return payload.data ?? {}; } export function buildOpsUrl(path, query = {}) { - const normalizedPath = path.startsWith("/") ? path : `/${path}`; - const url = new URL(`${OPS_API_PREFIX}${normalizedPath}`, window.location.origin); + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + const url = new URL(`${OPS_API_PREFIX}${normalizedPath}`, window.location.origin); - Object.entries(query || {}).forEach(([key, value]) => { - if (value !== undefined && value !== null && value !== "") { - url.searchParams.set(key, String(value)); - } - }); + Object.entries(query || {}).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== "") { + url.searchParams.set(key, String(value)); + } + }); - return url.toString(); + return url.toString(); } export function normalizeDashboard({ apps = [], perApp = [] } = {}) { - const luckyGifts = perApp.flatMap((entry) => entry.luckyGifts || []); - const poolBalances = perApp - .flatMap((entry) => entry.poolBalances || []) - .filter((item) => item?.app_code || item?.appCode); - const appSummaries = perApp - .map((entry) => normalizeDrawSummary(entry.drawSummary, entry.appCode)) - .filter((summary) => summary.app_code); + const luckyGifts = perApp.flatMap((entry) => entry.luckyGifts || []); + const poolBalances = perApp + .flatMap((entry) => entry.poolBalances || []) + .filter((item) => item?.app_code || item?.appCode); + const appSummaries = perApp + .map((entry) => normalizeDrawSummary(entry.drawSummary, entry.appCode)) + .filter((summary) => summary.app_code); - const publishedConfigs = luckyGifts.filter((config) => !config.is_default); + const publishedConfigs = luckyGifts.filter((config) => !config.is_default); - // KPI 一律跨应用聚合。旧版只取第一个应用的汇总,导致 lalu 有大量抽奖时首屏"抽奖次数"仍显示 0。 - const summary = { - activeApps: apps.length, - configuredPools: publishedConfigs.length, - enabledPools: publishedConfigs.filter((config) => config.enabled).length, - failedDraws: sumBy(appSummaries, "failed_draws"), - grantedDraws: sumBy(appSummaries, "granted_draws"), - pendingDraws: sumBy(appSummaries, "pending_draws"), - poolAvailableTotal: sumBy(poolBalances, "available_balance", "availableBalance"), - poolBalanceTotal: sumBy(poolBalances, "balance", "Balance"), - poolReserveTotal: sumBy(poolBalances, "reserve_floor", "reserveFloor"), - totalDraws: sumBy(appSummaries, "total_draws"), - totalRewardCoins: sumBy(appSummaries, "total_reward_coins"), - totalSpentCoins: sumBy(appSummaries, "total_spent_coins") - }; + // KPI 一律跨应用聚合。旧版只取第一个应用的汇总,导致 lalu 有大量抽奖时首屏"抽奖次数"仍显示 0。 + const summary = { + activeApps: apps.length, + configuredPools: publishedConfigs.length, + enabledPools: publishedConfigs.filter((config) => config.enabled).length, + failedDraws: sumBy(appSummaries, "failed_draws"), + grantedDraws: sumBy(appSummaries, "granted_draws"), + pendingDraws: sumBy(appSummaries, "pending_draws"), + poolAvailableTotal: sumBy(poolBalances, "available_balance", "availableBalance"), + poolBalanceTotal: sumBy(poolBalances, "balance", "Balance"), + poolReserveTotal: sumBy(poolBalances, "reserve_floor", "reserveFloor"), + totalDraws: sumBy(appSummaries, "total_draws"), + totalRewardCoins: sumBy(appSummaries, "total_reward_coins"), + totalSpentCoins: sumBy(appSummaries, "total_spent_coins"), + }; - return { apps, appSummaries, luckyGifts, poolBalances, summary }; + return { apps, appSummaries, luckyGifts, poolBalances, summary }; } function normalizeDrawSummary(summary = {}, appCode = "") { - return { - actual_rtp_ppm: numberValue(summary.actual_rtp_ppm ?? summary.actualRtpPpm), - app_code: String(appCode || summary.app_code || "").trim().toLowerCase(), - failed_draws: numberValue(summary.failed_draws ?? summary.failedDraws), - granted_draws: numberValue(summary.granted_draws ?? summary.grantedDraws), - pending_draws: numberValue(summary.pending_draws ?? summary.pendingDraws), - total_draws: numberValue(summary.total_draws ?? summary.totalDraws), - total_reward_coins: numberValue(summary.total_reward_coins ?? summary.totalRewardCoins), - total_spent_coins: numberValue(summary.total_spent_coins ?? summary.totalSpentCoins), - unique_rooms: numberValue(summary.unique_rooms ?? summary.uniqueRooms), - unique_users: numberValue(summary.unique_users ?? summary.uniqueUsers) - }; + return { + actual_rtp_ppm: numberValue(summary.actual_rtp_ppm ?? summary.actualRtpPpm), + app_code: String(appCode || summary.app_code || "") + .trim() + .toLowerCase(), + failed_draws: numberValue(summary.failed_draws ?? summary.failedDraws), + granted_draws: numberValue(summary.granted_draws ?? summary.grantedDraws), + pending_draws: numberValue(summary.pending_draws ?? summary.pendingDraws), + total_draws: numberValue(summary.total_draws ?? summary.totalDraws), + total_reward_coins: numberValue(summary.total_reward_coins ?? summary.totalRewardCoins), + total_spent_coins: numberValue(summary.total_spent_coins ?? summary.totalSpentCoins), + unique_rooms: numberValue(summary.unique_rooms ?? summary.uniqueRooms), + unique_users: numberValue(summary.unique_users ?? summary.uniqueUsers), + }; } function uniqueAppCodes(apps) { - const seen = new Set(); - const codes = []; - apps.forEach((item) => { - const code = String(item?.app_code || item?.appCode || "").trim().toLowerCase(); - if (!code || seen.has(code)) { - return; - } - seen.add(code); - codes.push(code); - }); - return codes; + const seen = new Set(); + const codes = []; + apps.forEach((item) => { + const code = String(item?.app_code || item?.appCode || "") + .trim() + .toLowerCase(); + if (!code || seen.has(code)) { + return; + } + seen.add(code); + codes.push(code); + }); + return codes; } function normalizeLuckyGiftConfig(config = {}, fallbackAppCode = "") { - const appCode = String(config.app_code || config.appCode || fallbackAppCode).trim().toLowerCase(); - const ruleVersion = numberValue(config.rule_version ?? config.ruleVersion); - return { - ...config, - app_code: appCode, - // rule_version <= 0 表示服务端返回的未发布草稿:列表要标成"默认草稿"并禁止当作已生效规则解读。 - is_default: ruleVersion <= 0, - pool_id: config.pool_id || config.poolId || DEFAULT_POOL_ID, - rule_version: ruleVersion - }; + const appCode = String(config.app_code || config.appCode || fallbackAppCode) + .trim() + .toLowerCase(); + const ruleVersion = numberValue(config.rule_version ?? config.ruleVersion); + return { + ...config, + app_code: appCode, + // rule_version <= 0 表示服务端返回的未发布草稿:列表要标成"默认草稿"并禁止当作已生效规则解读。 + is_default: ruleVersion <= 0, + pool_id: config.pool_id || config.poolId || DEFAULT_POOL_ID, + rule_version: ruleVersion, + // 升级前规则没有 strategy_version,lucky-gift owner 明确按 fixed_v2 解释;列表和编辑表单必须沿用该兼容语义。 + strategy_version: String(config.strategy_version || config.strategyVersion || "fixed_v2") + .trim() + .toLowerCase(), + }; } function luckyGiftConfigPayload(config = {}) { - const appCode = String(config.app_code || config.appCode || "").trim().toLowerCase(); - const poolID = String(config.pool_id || config.poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID; - return { - app_code: appCode, - control_band_percent: numberValue(config.control_band_percent ?? config.controlBandPercent), - effective_from_ms: numberValue(config.effective_from_ms ?? config.effectiveFromMS), - enabled: Boolean(config.enabled), - gift_price_reference: numberValue(config.gift_price_reference ?? config.giftPriceReference), - normal_max_equivalent_draws: numberValue(config.normal_max_equivalent_draws ?? config.normalMaxEquivalentDraws), - novice_max_equivalent_draws: numberValue(config.novice_max_equivalent_draws ?? config.noviceMaxEquivalentDraws), - pool_id: poolID, - pool_rate_percent: numberValue(config.pool_rate_percent ?? config.poolRatePercent), - settlement_window_wager: numberValue(config.settlement_window_wager ?? config.settlementWindowWager), - stages: normalizeStages(config.stages), - target_rtp_percent: numberValue(config.target_rtp_percent ?? config.targetRtpPercent) - }; + const appCode = String(config.app_code || config.appCode || "") + .trim() + .toLowerCase(); + const poolID = String(config.pool_id || config.poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID; + const strategyVersion = String(config.strategy_version || config.strategyVersion || "fixed_v2") + .trim() + .toLowerCase(); + + // 规则版本是不可变快照,PUT 必须完整回传 owner GET 返回的策略字段。只提交旧版 RTP/奖档字段会让 + // 空 strategy_version 被服务端按 fixed_v2 发布,或让 dynamic_v3 因资金、水位、大奖字段归零而拒绝发布。 + return { + anchor_daily_payout_cap: numberValue(config.anchor_daily_payout_cap ?? config.anchorDailyPayoutCap), + anchor_rate_percent: numberValue(config.anchor_rate_percent ?? config.anchorRatePercent), + app_code: appCode, + control_band_percent: numberValue(config.control_band_percent ?? config.controlBandPercent), + device_daily_payout_cap: numberValue(config.device_daily_payout_cap ?? config.deviceDailyPayoutCap), + effective_from_ms: numberValue(config.effective_from_ms ?? config.effectiveFromMs ?? config.effectiveFromMS), + enabled: Boolean(config.enabled), + gift_price_reference: numberValue(config.gift_price_reference ?? config.giftPriceReference), + high_water_nonzero_factor_percent: numberValue( + config.high_water_nonzero_factor_percent ?? config.highWaterNonzeroFactorPercent, + ), + high_watermark_coins: numberValue(config.high_watermark_coins ?? config.highWatermarkCoins), + initial_pool_coins: numberValue(config.initial_pool_coins ?? config.initialPoolCoins), + jackpot_global_rtp_max_percent: numberValue( + config.jackpot_global_rtp_max_percent ?? config.jackpotGlobalRTPMaxPercent, + ), + jackpot_multipliers: numberArray(config.jackpot_multipliers ?? config.jackpotMultipliers), + jackpot_spend_threshold_coins: numberValue( + config.jackpot_spend_threshold_coins ?? config.jackpotSpendThresholdCoins, + ), + jackpot_user_72h_rtp_max_percent: numberValue( + config.jackpot_user_72h_rtp_max_percent ?? config.jackpotUser72hRTPMaxPercent, + ), + jackpot_user_day_rtp_max_percent: numberValue( + config.jackpot_user_day_rtp_max_percent ?? config.jackpotUserDayRTPMaxPercent, + ), + loss_streak_guarantee: numberValue(config.loss_streak_guarantee ?? config.lossStreakGuarantee), + low_water_nonzero_factor_percent: numberValue( + config.low_water_nonzero_factor_percent ?? config.lowWaterNonzeroFactorPercent, + ), + low_watermark_coins: numberValue(config.low_watermark_coins ?? config.lowWatermarkCoins), + max_jackpot_hits_per_user_day: numberValue( + config.max_jackpot_hits_per_user_day ?? config.maxJackpotHitsPerUserDay, + ), + max_single_payout: numberValue(config.max_single_payout ?? config.maxSinglePayout), + normal_max_equivalent_draws: numberValue(config.normal_max_equivalent_draws ?? config.normalMaxEquivalentDraws), + novice_max_equivalent_draws: numberValue(config.novice_max_equivalent_draws ?? config.noviceMaxEquivalentDraws), + pool_id: poolID, + pool_rate_percent: numberValue(config.pool_rate_percent ?? config.poolRatePercent), + profit_rate_percent: numberValue(config.profit_rate_percent ?? config.profitRatePercent), + recharge_boost_factor_percent: numberValue( + config.recharge_boost_factor_percent ?? config.rechargeBoostFactorPercent, + ), + recharge_boost_window_ms: numberValue( + config.recharge_boost_window_ms ?? config.rechargeBoostWindowMs ?? config.rechargeBoostWindowMS, + ), + room_hourly_payout_cap: numberValue(config.room_hourly_payout_cap ?? config.roomHourlyPayoutCap), + settlement_window_wager: numberValue(config.settlement_window_wager ?? config.settlementWindowWager), + stages: normalizeStages(config.stages), + strategy_version: strategyVersion, + user_daily_payout_cap: numberValue(config.user_daily_payout_cap ?? config.userDailyPayoutCap), + user_hourly_payout_cap: numberValue(config.user_hourly_payout_cap ?? config.userHourlyPayoutCap), + target_rtp_percent: numberValue(config.target_rtp_percent ?? config.targetRtpPercent), + }; } function buildHeaders(body) { - const headers = {}; - const token = getAccessToken(); + const headers = {}; + const token = getAccessToken(); - if (body !== undefined) { - headers["Content-Type"] = "application/json"; - } - if (token) { - headers.Authorization = `Bearer ${token}`; - } + if (body !== undefined) { + headers["Content-Type"] = "application/json"; + } + if (token) { + headers.Authorization = `Bearer ${token}`; + } - return headers; + return headers; } async function readPayload(response) { - const text = await response.text(); - if (!text) { - return { code: response.ok ? 0 : response.status, data: {} }; - } - try { - return JSON.parse(text); - } catch { - return { code: response.ok ? 0 : response.status, data: text, message: text }; - } + const text = await response.text(); + if (!text) { + return { code: response.ok ? 0 : response.status, data: {} }; + } + try { + return JSON.parse(text); + } catch { + return { code: response.ok ? 0 : response.status, data: text, message: text }; + } } function normalizeItems(value) { - if (Array.isArray(value)) { - return value; - } - if (Array.isArray(value?.items)) { - return value.items; - } - if (Array.isArray(value?.list)) { - return value.list; - } - return []; + if (Array.isArray(value)) { + return value; + } + if (Array.isArray(value?.items)) { + return value.items; + } + if (Array.isArray(value?.list)) { + return value.list; + } + return []; } function sumBy(items, key, fallbackKey) { - return items.reduce((total, item) => total + numberValue(item?.[key] ?? (fallbackKey ? item?.[fallbackKey] : 0)), 0); + return items.reduce( + (total, item) => total + numberValue(item?.[key] ?? (fallbackKey ? item?.[fallbackKey] : 0)), + 0, + ); } function numberValue(value) { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : 0; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function numberArray(values) { + if (!Array.isArray(values)) { + return []; + } + // 不在传输层静默删除 0/负倍率;表单负责拦截,若有遗漏也应让 owner 返回明确校验错误, + // 否则数组位置变化会掩盖运营实际提交的错误值。 + return values.map(numberValue); } function normalizeStages(stages) { - if (!Array.isArray(stages)) { - return []; - } - return stages.map((stage) => ({ - stage: stage.stage || stage.Stage || "", - tiers: Array.isArray(stage.tiers) - ? stage.tiers.map((tier) => ({ - enabled: tier.enabled !== false, - high_water_only: Boolean(tier.high_water_only ?? tier.highWaterOnly), - multiplier: numberValue(tier.multiplier), - probability_percent: numberValue(tier.probability_percent ?? tier.probabilityPercent), - tier_id: tier.tier_id || tier.tierId || "" - })) - : [] - })); + if (!Array.isArray(stages)) { + return []; + } + return stages.map((stage) => ({ + min_recharge_30d_coins: numberValue(stage.min_recharge_30d_coins ?? stage.minRecharge30DCoins), + min_recharge_7d_coins: numberValue(stage.min_recharge_7d_coins ?? stage.minRecharge7DCoins), + stage: stage.stage || stage.Stage || "", + tiers: Array.isArray(stage.tiers) + ? stage.tiers.map((tier) => ({ + enabled: tier.enabled !== false, + high_water_only: Boolean(tier.high_water_only ?? tier.highWaterOnly), + multiplier: numberValue(tier.multiplier), + probability_percent: numberValue(tier.probability_percent ?? tier.probabilityPercent), + tier_id: tier.tier_id || tier.tierId || "", + })) + : [], + })); } diff --git a/ops-center/src/api.test.js b/ops-center/src/api.test.js index aa52235..37b5a12 100644 --- a/ops-center/src/api.test.js +++ b/ops-center/src/api.test.js @@ -2,145 +2,251 @@ import { afterEach, expect, test, vi } from "vitest"; import { buildOpsUrl, fetchLuckyGiftDraws, fetchOpsDashboard, OPS_API_PREFIX, saveLuckyGiftConfig } from "./api.js"; afterEach(() => { - vi.restoreAllMocks(); + vi.restoreAllMocks(); }); function jsonResponse(data) { - return new Response(JSON.stringify({ code: 0, data }), { - headers: { "Content-Type": "application/json" }, - status: 200 - }); + return new Response(JSON.stringify({ code: 0, data }), { + headers: { "Content-Type": "application/json" }, + status: 200, + }); } test("builds ops api urls under the dedicated prefix", () => { - const url = buildOpsUrl("/apps", { app_code: "lalu", empty: "", status: "active" }); + const url = buildOpsUrl("/apps", { app_code: "lalu", empty: "", status: "active" }); - expect(OPS_API_PREFIX).toBe("/api/v1/admin/ops-center"); - expect(url).toBe("http://localhost:3000/api/v1/admin/ops-center/apps?app_code=lalu&status=active"); + expect(OPS_API_PREFIX).toBe("/api/v1/admin/ops-center"); + expect(url).toBe("http://localhost:3000/api/v1/admin/ops-center/apps?app_code=lalu&status=active"); }); test("loads every pool config per app through the plural configs endpoint", async () => { - const calls = []; - vi.spyOn(window, "fetch").mockImplementation(async (url) => { - const text = String(url); - calls.push(text); - if (text.endsWith("/apps")) { - return jsonResponse({ items: [{ app_code: "lalu", status: "active" }] }); - } - if (text.includes("/lucky-gifts/configs?")) { - // lalu 在 default 之外还有 lucky/super_lucky 两个已发布奖池,复数接口必须全部带回。 - return jsonResponse([ - { app_code: "lalu", enabled: true, pool_id: "lucky", rule_version: 3, target_rtp_percent: 95 }, - { app_code: "lalu", enabled: false, pool_id: "super_lucky", rule_version: 2, target_rtp_percent: 92 } - ]); - } - if (text.includes("/lucky-gifts/pools?")) { - return jsonResponse({ - items: [ - { app_code: "lalu", available_balance: 37950017, balance: 38559017, materialized: true, pool_id: "lucky", reserve_floor: 609000 }, - { app_code: "lalu", available_balance: 361500, balance: 382500, materialized: false, pool_id: "default", reserve_floor: 21000 } - ] - }); - } - if (text.includes("/lucky-gifts/summary?")) { - return jsonResponse({ granted_draws: 9, pending_draws: 1, total_draws: 10, total_reward_coins: 900, total_spent_coins: 1000 }); - } - return jsonResponse({ items: [] }); - }); + const calls = []; + vi.spyOn(window, "fetch").mockImplementation(async (url) => { + const text = String(url); + calls.push(text); + if (text.endsWith("/apps")) { + return jsonResponse({ items: [{ app_code: "lalu", status: "active" }] }); + } + if (text.includes("/lucky-gifts/configs?")) { + // lalu 在 default 之外还有 lucky/super_lucky 两个已发布奖池,复数接口必须全部带回。 + return jsonResponse([ + { app_code: "lalu", enabled: true, pool_id: "lucky", rule_version: 3, target_rtp_percent: 95 }, + { app_code: "lalu", enabled: false, pool_id: "super_lucky", rule_version: 2, target_rtp_percent: 92 }, + ]); + } + if (text.includes("/lucky-gifts/pools?")) { + return jsonResponse({ + items: [ + { + app_code: "lalu", + available_balance: 37950017, + balance: 38559017, + materialized: true, + pool_id: "lucky", + reserve_floor: 609000, + }, + { + app_code: "lalu", + available_balance: 361500, + balance: 382500, + materialized: false, + pool_id: "default", + reserve_floor: 21000, + }, + ], + }); + } + if (text.includes("/lucky-gifts/summary?")) { + return jsonResponse({ + granted_draws: 9, + pending_draws: 1, + total_draws: 10, + total_reward_coins: 900, + total_spent_coins: 1000, + }); + } + return jsonResponse({ items: [] }); + }); - const data = await fetchOpsDashboard(); + const data = await fetchOpsDashboard(); - expect(calls).toEqual([ - "http://localhost:3000/api/v1/admin/ops-center/apps", - "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/configs?app_code=lalu", - "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/pools?app_code=lalu", - "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/summary?app_code=lalu" - ]); - expect(data.luckyGifts).toEqual([ - expect.objectContaining({ app_code: "lalu", is_default: false, pool_id: "lucky", rule_version: 3 }), - expect.objectContaining({ app_code: "lalu", is_default: false, pool_id: "super_lucky", rule_version: 2 }) - ]); - expect(data.summary).toMatchObject({ - activeApps: 1, - configuredPools: 2, - enabledPools: 1, - poolBalanceTotal: 38941517, - totalDraws: 10 - }); + expect(calls).toEqual([ + "http://localhost:3000/api/v1/admin/ops-center/apps", + "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/configs?app_code=lalu", + "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/pools?app_code=lalu", + "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/summary?app_code=lalu", + ]); + expect(data.luckyGifts).toEqual([ + expect.objectContaining({ app_code: "lalu", is_default: false, pool_id: "lucky", rule_version: 3 }), + expect.objectContaining({ app_code: "lalu", is_default: false, pool_id: "super_lucky", rule_version: 2 }), + ]); + expect(data.summary).toMatchObject({ + activeApps: 1, + configuredPools: 2, + enabledPools: 1, + poolBalanceTotal: 38941517, + totalDraws: 10, + }); }); test("falls back to the server default draft when an app has no published config", async () => { - const calls = []; - vi.spyOn(window, "fetch").mockImplementation(async (url) => { - const text = String(url); - calls.push(text); - if (text.endsWith("/apps")) { - return jsonResponse({ items: [{ app_code: "yumi", status: "active" }] }); - } - if (text.includes("/lucky-gifts/configs?")) { - return jsonResponse([]); - } - if (text.includes("/lucky-gifts/config?")) { - return jsonResponse({ app_code: "yumi", enabled: false, pool_id: "default", rule_version: 0, target_rtp_percent: 95 }); - } - return jsonResponse({ items: [] }); - }); + const calls = []; + vi.spyOn(window, "fetch").mockImplementation(async (url) => { + const text = String(url); + calls.push(text); + if (text.endsWith("/apps")) { + return jsonResponse({ items: [{ app_code: "yumi", status: "active" }] }); + } + if (text.includes("/lucky-gifts/configs?")) { + return jsonResponse([]); + } + if (text.includes("/lucky-gifts/config?")) { + return jsonResponse({ + app_code: "yumi", + enabled: false, + pool_id: "default", + rule_version: 0, + target_rtp_percent: 95, + }); + } + return jsonResponse({ items: [] }); + }); - const data = await fetchOpsDashboard(); + const data = await fetchOpsDashboard(); - expect(calls).toContain("http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/config?app_code=yumi"); - expect(data.luckyGifts).toEqual([ - expect.objectContaining({ app_code: "yumi", is_default: true, pool_id: "default", rule_version: 0 }) - ]); + expect(calls).toContain("http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/config?app_code=yumi"); + expect(data.luckyGifts).toEqual([ + expect.objectContaining({ app_code: "yumi", is_default: true, pool_id: "default", rule_version: 0 }), + ]); }); test("fetches draws with numeric ids as user_id and other ids as external_user_id", async () => { - const calls = []; - vi.spyOn(window, "fetch").mockImplementation(async (url) => { - calls.push(String(url)); - return jsonResponse({ items: [{ draw_id: "draw-1" }], page: 1, pageSize: 20, total: 1 }); - }); + const calls = []; + vi.spyOn(window, "fetch").mockImplementation(async (url) => { + calls.push(String(url)); + return jsonResponse({ items: [{ draw_id: "draw-1" }], page: 1, pageSize: 20, total: 1 }); + }); - const numericResult = await fetchLuckyGiftDraws({ appCode: "lalu", poolId: "lucky", userId: "10001" }); - await fetchLuckyGiftDraws({ appCode: "lalu", userId: "ext-abc" }); + const numericResult = await fetchLuckyGiftDraws({ appCode: "lalu", poolId: "lucky", userId: "10001" }); + await fetchLuckyGiftDraws({ appCode: "lalu", userId: "ext-abc" }); - const numericParams = new URL(calls[0]).searchParams; - expect(numericParams.get("app_code")).toBe("lalu"); - expect(numericParams.get("pool_id")).toBe("lucky"); - expect(numericParams.get("user_id")).toBe("10001"); - expect(numericParams.get("external_user_id")).toBeNull(); - const externalParams = new URL(calls[1]).searchParams; - expect(externalParams.get("external_user_id")).toBe("ext-abc"); - expect(externalParams.get("user_id")).toBeNull(); - expect(numericResult).toMatchObject({ items: [{ draw_id: "draw-1" }], page: 1, total: 1 }); + const numericParams = new URL(calls[0]).searchParams; + expect(numericParams.get("app_code")).toBe("lalu"); + expect(numericParams.get("pool_id")).toBe("lucky"); + expect(numericParams.get("user_id")).toBe("10001"); + expect(numericParams.get("external_user_id")).toBeNull(); + const externalParams = new URL(calls[1]).searchParams; + expect(externalParams.get("external_user_id")).toBe("ext-abc"); + expect(externalParams.get("user_id")).toBeNull(); + expect(numericResult).toMatchObject({ items: [{ draw_id: "draw-1" }], page: 1, total: 1 }); }); test("saves lucky gift config through admin ops route", async () => { - const calls = []; - vi.spyOn(window, "fetch").mockImplementation(async (url, init) => { - calls.push({ body: init.body, method: init.method, url: String(url) }); - return jsonResponse({ app_code: "aslan", pool_id: "default" }); - }); + const calls = []; + vi.spyOn(window, "fetch").mockImplementation(async (url, init) => { + calls.push({ body: init.body, method: init.method, url: String(url) }); + return jsonResponse({ app_code: "aslan", pool_id: "default" }); + }); - await saveLuckyGiftConfig({ - app_code: "aslan", - pool_id: "default", - enabled: true, - target_rtp_percent: 95, - pool_rate_percent: 96, - settlement_window_wager: 1_000_000, - control_band_percent: 1, - gift_price_reference: 100, - stages: [{ stage: "novice", tiers: [{ multiplier: 1, probabilityPercent: 100, enabled: true }] }] - }); + await saveLuckyGiftConfig({ + anchor_daily_payout_cap: 12_312_000, + anchor_rate_percent: 1, + app_code: "aslan", + control_band_percent: 3, + device_daily_payout_cap: 1_026_000, + enabled: true, + gift_price_reference: 100, + high_water_nonzero_factor_percent: 130, + high_watermark_coins: 20_000_000, + initial_pool_coins: 1_000_000, + jackpot_global_rtp_max_percent: 98, + jackpot_multipliers: [200, 500, 1000], + jackpot_spend_threshold_coins: 50_000, + jackpot_user_72h_rtp_max_percent: 96, + jackpot_user_day_rtp_max_percent: 96, + loss_streak_guarantee: 5, + low_water_nonzero_factor_percent: 70, + low_watermark_coins: 10_000_000, + max_jackpot_hits_per_user_day: 5, + max_single_payout: 50_000, + normal_max_equivalent_draws: 20_000, + novice_max_equivalent_draws: 2_000, + pool_id: "default", + pool_rate_percent: 98, + profit_rate_percent: 1, + recharge_boost_factor_percent: 110, + recharge_boost_window_ms: 300_000, + room_hourly_payout_cap: 684_000, + settlement_window_wager: 1_000_000, + stages: [ + { + min_recharge_30d_coins: 0, + min_recharge_7d_coins: 0, + stage: "novice", + tiers: [{ multiplier: 1, probabilityPercent: 100, enabled: true }], + }, + ], + strategy_version: "dynamic_v3", + target_rtp_percent: 98, + user_daily_payout_cap: 615_600, + user_hourly_payout_cap: 34_200, + }); - expect(calls[0].method).toBe("PUT"); - expect(calls[0].url).toBe("http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/config?app_code=aslan&pool_id=default"); - expect(JSON.parse(calls[0].body)).toMatchObject({ - app_code: "aslan", - enabled: true, - pool_id: "default", - stages: [{ stage: "novice", tiers: [{ probability_percent: 100 }] }], - target_rtp_percent: 95 - }); + expect(calls[0].method).toBe("PUT"); + expect(calls[0].url).toBe( + "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/config?app_code=aslan&pool_id=default", + ); + // 精确比较完整 owner payload;子集断言会让新增/遗漏的策略字段在回归中静默通过。 + expect(JSON.parse(calls[0].body)).toEqual({ + anchor_daily_payout_cap: 12_312_000, + anchor_rate_percent: 1, + app_code: "aslan", + control_band_percent: 3, + device_daily_payout_cap: 1_026_000, + effective_from_ms: 0, + enabled: true, + gift_price_reference: 100, + high_water_nonzero_factor_percent: 130, + high_watermark_coins: 20_000_000, + initial_pool_coins: 1_000_000, + jackpot_global_rtp_max_percent: 98, + jackpot_multipliers: [200, 500, 1000], + jackpot_spend_threshold_coins: 50_000, + jackpot_user_72h_rtp_max_percent: 96, + jackpot_user_day_rtp_max_percent: 96, + loss_streak_guarantee: 5, + low_water_nonzero_factor_percent: 70, + low_watermark_coins: 10_000_000, + max_jackpot_hits_per_user_day: 5, + max_single_payout: 50_000, + normal_max_equivalent_draws: 20_000, + novice_max_equivalent_draws: 2_000, + pool_id: "default", + pool_rate_percent: 98, + profit_rate_percent: 1, + recharge_boost_factor_percent: 110, + recharge_boost_window_ms: 300_000, + room_hourly_payout_cap: 684_000, + settlement_window_wager: 1_000_000, + stages: [ + { + min_recharge_30d_coins: 0, + min_recharge_7d_coins: 0, + stage: "novice", + tiers: [ + { + enabled: true, + high_water_only: false, + multiplier: 1, + probability_percent: 100, + tier_id: "", + }, + ], + }, + ], + strategy_version: "dynamic_v3", + target_rtp_percent: 98, + user_daily_payout_cap: 615_600, + user_hourly_payout_cap: 34_200, + }); }); diff --git a/ops-center/src/components/ConfigsView.jsx b/ops-center/src/components/ConfigsView.jsx index 62f8a2e..dff4af3 100644 --- a/ops-center/src/components/ConfigsView.jsx +++ b/ops-center/src/components/ConfigsView.jsx @@ -8,83 +8,121 @@ import { formatNumber, formatPercent } from "../format.js"; import { OpsStatusBadge } from "./OpsStatusBadge.jsx"; export function ConfigsView({ apps, luckyGifts, onCreate, onEdit, saving }) { - const [appFilter, setAppFilter] = useState(""); + const [appFilter, setAppFilter] = useState(""); - const appOptions = useMemo( - () => apps.map((item) => [item.app_code ?? item.appCode, `${item.app_name ?? item.appName ?? ""} (${item.app_code ?? item.appCode})`]), - [apps] - ); - // 配置列表已在 dashboard 阶段跨应用拉全,应用筛选只做前端过滤,避免重复扇出请求。 - const items = useMemo( - () => (appFilter ? luckyGifts.filter((config) => config.app_code === appFilter) : luckyGifts), - [appFilter, luckyGifts] - ); + const appOptions = useMemo( + () => + apps.map((item) => [ + item.app_code ?? item.appCode, + `${item.app_name ?? item.appName ?? ""} (${item.app_code ?? item.appCode})`, + ]), + [apps], + ); + // 配置列表已在 dashboard 阶段跨应用拉全,应用筛选只做前端过滤,避免重复扇出请求。 + const items = useMemo( + () => (appFilter ? luckyGifts.filter((config) => config.app_code === appFilter) : luckyGifts), + [appFilter, luckyGifts], + ); - const columns = useMemo(() => buildColumns({ onEdit, saving }), [onEdit, saving]); + const columns = useMemo(() => buildColumns({ onEdit, saving }), [onEdit, saving]); - return ( -
- } variant="primary" onClick={onCreate}> - 添加配置 - - } - filters={} - /> - `${item.app_code}:${item.pool_id}:${item.rule_version}`} - title="幸运礼物配置" - total={items.length} - /> -
- ); + return ( +
+ } + variant="primary" + onClick={onCreate} + > + 添加配置 + + } + filters={ + + } + /> + `${item.app_code}:${item.pool_id}:${item.rule_version}`} + title="幸运礼物配置" + total={items.length} + /> +
+ ); } function buildColumns({ onEdit, saving }) { - return [ - { key: "app_code", label: "应用", width: "minmax(88px, 0.7fr)" }, - { key: "pool_id", label: "奖池", width: "minmax(110px, 0.9fr)" }, - { - key: "rule_version", - label: "版本", - render: (item) => (item.is_default ? "-" : `v${item.rule_version}`), - width: "minmax(72px, 0.5fr)" - }, - { - key: "enabled", - label: "状态", - render: (item) => { - // 默认草稿是服务端为未配置奖池生成的占位,不参与线上抽奖,必须与"停用"区分开。 - if (item.is_default) { - return ; - } - return item.enabled ? : ; - }, - width: "minmax(128px, 0.8fr)" - }, - { key: "target_rtp_percent", label: "目标 RTP", render: (item) => formatPercent(item.target_rtp_percent) }, - { key: "pool_rate_percent", label: "奖池回收", render: (item) => formatPercent(item.pool_rate_percent) }, - { key: "control_band_percent", label: "控制带宽", render: (item) => formatPercent(item.control_band_percent) }, - { key: "settlement_window_wager", label: "结算窗口流水", render: (item) => formatNumber(item.settlement_window_wager) }, - { - key: "created_at_ms", - label: "发布时间", - render: (item) => (item.is_default ? "-" : ), - width: "minmax(176px, 1.1fr)" - }, - { - key: "actions", - label: "操作", - render: (item) => ( - - ) - } - ]; + return [ + { key: "app_code", label: "应用", width: "minmax(88px, 0.7fr)" }, + { key: "pool_id", label: "奖池", width: "minmax(110px, 0.9fr)" }, + { + key: "rule_version", + label: "版本", + render: (item) => (item.is_default ? "-" : `v${item.rule_version}`), + width: "minmax(72px, 0.5fr)", + }, + { + key: "strategy_version", + label: "策略", + // 升级前的不可变版本可能没有该字段,owner 会按 fixed_v2 解释;列表保持同一兼容口径, + // 不能把空值展示成 dynamic_v3,避免运营误判已完成策略切换。 + render: (item) => item.strategy_version || "fixed_v2", + width: "minmax(112px, 0.8fr)", + }, + { + key: "enabled", + label: "状态", + render: (item) => { + // 默认草稿是服务端为未配置奖池生成的占位,不参与线上抽奖,必须与"停用"区分开。 + if (item.is_default) { + return ; + } + return item.enabled ? ( + + ) : ( + + ); + }, + width: "minmax(128px, 0.8fr)", + }, + { key: "target_rtp_percent", label: "目标 RTP", render: (item) => formatPercent(item.target_rtp_percent) }, + { key: "pool_rate_percent", label: "公共奖池", render: (item) => formatPercent(item.pool_rate_percent) }, + { + key: "profit_anchor_rate", + label: "平台 / 主播", + render: (item) => `${formatPercent(item.profit_rate_percent)} / ${formatPercent(item.anchor_rate_percent)}`, + width: "minmax(144px, 1fr)", + }, + { key: "control_band_percent", label: "控制带宽", render: (item) => formatPercent(item.control_band_percent) }, + { + key: "settlement_window_wager", + label: "结算窗口流水", + render: (item) => formatNumber(item.settlement_window_wager), + }, + { + key: "created_at_ms", + label: "发布时间", + render: (item) => (item.is_default ? "-" : ), + width: "minmax(176px, 1.1fr)", + }, + { + key: "actions", + label: "操作", + render: (item) => ( + + ), + }, + ]; } diff --git a/ops-center/src/components/LuckyGiftConfigDialog.jsx b/ops-center/src/components/LuckyGiftConfigDialog.jsx index f93438d..54bc3b1 100644 --- a/ops-center/src/components/LuckyGiftConfigDialog.jsx +++ b/ops-center/src/components/LuckyGiftConfigDialog.jsx @@ -12,283 +12,555 @@ import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; import { Button } from "@/shared/ui/Button.jsx"; import { formatDecimal } from "@/shared/utils/rtpProbability.js"; import { - configToForm, - correctAllStageProbabilities, - formToConfig, - nextStageMultiplier, - stageSummary + applyDynamicStrategyDefaults, + configToForm, + correctAllStageProbabilities, + formToConfig, + nextStageMultiplier, + stageSummary, + validateLuckyGiftForm, } from "../configForm.js"; import { formatNumber, formatPercent } from "../format.js"; import { OpsStatusBadge } from "./OpsStatusBadge.jsx"; export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", onClose, onSubmit, saving }) { - const [form, setForm] = useState(() => configToForm(config)); - const [activeStage, setActiveStage] = useState("novice"); + const [form, setForm] = useState(() => configToForm(config)); + const [activeStage, setActiveStage] = useState("novice"); + const [validationErrors, setValidationErrors] = useState([]); - const stageSummaries = useMemo(() => form.stages.map((stage) => stageSummary(stage, form)), [form]); - const activeStageKey = form.stages.some((stage) => stage.stage === activeStage) ? activeStage : form.stages[0]?.stage; - const activeStageConfig = form.stages.find((stage) => stage.stage === activeStageKey); - const activeSummary = stageSummaries.find((summary) => summary.stageKey === activeStageKey); + const stageSummaries = useMemo(() => form.stages.map((stage) => stageSummary(stage, form)), [form]); + const activeStageKey = form.stages.some((stage) => stage.stage === activeStage) + ? activeStage + : form.stages[0]?.stage; + const activeStageConfig = form.stages.find((stage) => stage.stage === activeStageKey); + const activeSummary = stageSummaries.find((summary) => summary.stageKey === activeStageKey); - const updateField = (field, value) => { - setForm((current) => { - const next = { ...current, [field]: value }; - // 目标 RTP 决定各奖档概率的闭合解,改动后全部阶段重算,避免展示的概率和将要发布的值不一致。 - return field === "target_rtp_percent" ? correctAllStageProbabilities(next) : next; - }); - }; + const updateField = (field, value) => { + setValidationErrors([]); + setForm((current) => { + const next = { ...current, [field]: value }; + if (field === "strategy_version" && value === "dynamic_v3" && current.strategy_version !== "dynamic_v3") { + return applyDynamicStrategyDefaults(next); + } + // 目标 RTP 决定各奖档概率的闭合解,改动后全部阶段重算,避免展示的概率和将要发布的值不一致。 + return field === "target_rtp_percent" ? correctAllStageProbabilities(next) : next; + }); + }; - const updateStageTier = (stageKey, tierIndex, patch) => { - setForm((current) => - correctAllStageProbabilities({ - ...current, - stages: current.stages.map((stage) => - stage.stage === stageKey - ? { - ...stage, - tiers: stage.tiers.map((tier, index) => (index === tierIndex ? { ...tier, ...patch } : tier)) - } - : stage - ) - }) - ); - }; + const updateStageField = (stageKey, field, value) => { + setValidationErrors([]); + setForm((current) => ({ + ...current, + stages: current.stages.map((stage) => (stage.stage === stageKey ? { ...stage, [field]: value } : stage)), + })); + }; - const addStageTier = (stageKey) => { - setForm((current) => - correctAllStageProbabilities({ - ...current, - stages: current.stages.map((stage) => - stage.stage === stageKey - ? { - ...stage, - tiers: [ - ...stage.tiers, - { - enabled: true, - // 10 倍以上属于高倍档,默认只在奖池高水位时开放,防止新档位直接击穿保底线。 - highWaterOnly: nextStageMultiplier(stage.tiers) >= 10, - multiplier: String(nextStageMultiplier(stage.tiers)), - probabilityPercent: "0", - tierId: "" - } - ] - } - : stage - ) - }) - ); - }; - - const removeStageTier = (stageKey, tierIndex) => { - setForm((current) => - correctAllStageProbabilities({ - ...current, - stages: current.stages.map((stage) => - stage.stage === stageKey ? { ...stage, tiers: stage.tiers.filter((_, index) => index !== tierIndex) } : stage - ) - }) - ); - }; - - const handleSubmit = (event) => { - event.preventDefault(); - onSubmit(formToConfig(config, form)); - }; - - const isCreate = mode === "create"; - const title = isCreate ? "添加幸运礼物配置" : config.is_default ? "发布幸运礼物配置" : "新增幸运礼物版本"; - - return ( - -
- - {title} - - {form.app_code || "-"} / {form.pool_id || "default"} /{" "} - {config.is_default || isCreate ? "默认草稿" : `当前版本 v${config.rule_version ?? "-"}`} - - - -
- - - - - - -
- -
- - -
-
- {stageSummaries.map((summary) => ( - - ))} -
- {activeStageConfig ? ( -
-
-
-

{activeSummary?.stageLabel}

- -
- -
-
- - {activeStageConfig.tiers.map((tier, index) => ( -
- - updateStageTier(activeStageConfig.stage, index, { - // 10 倍及以上强制只走高水位,输入时同步勾选并锁死,防止高倍档在低水位放开。 - highWaterOnly: Number(event.target.value) >= 10 || tier.highWaterOnly, - multiplier: event.target.value - }) + const updateStageTier = (stageKey, tierIndex, patch) => { + setValidationErrors([]); + setForm((current) => + correctAllStageProbabilities({ + ...current, + stages: current.stages.map((stage) => + stage.stage === stageKey + ? { + ...stage, + tiers: stage.tiers.map((tier, index) => + index === tierIndex ? { ...tier, ...patch } : tier, + ), } + : stage, + ), + }), + ); + }; + + const addStageTier = (stageKey) => { + setValidationErrors([]); + setForm((current) => + correctAllStageProbabilities({ + ...current, + stages: current.stages.map((stage) => + stage.stage === stageKey + ? { + ...stage, + tiers: [ + ...stage.tiers, + { + enabled: true, + // 10 倍以上属于高倍档,默认只在奖池高水位时开放,防止新档位直接击穿保底线。 + highWaterOnly: nextStageMultiplier(stage.tiers) >= 10, + multiplier: String(nextStageMultiplier(stage.tiers)), + probabilityPercent: "0", + tierId: "", + }, + ], + } + : stage, + ), + }), + ); + }; + + const removeStageTier = (stageKey, tierIndex) => { + setValidationErrors([]); + setForm((current) => + correctAllStageProbabilities({ + ...current, + stages: current.stages.map((stage) => + stage.stage === stageKey + ? { ...stage, tiers: stage.tiers.filter((_, index) => index !== tierIndex) } + : stage, + ), + }), + ); + }; + + const handleSubmit = (event) => { + event.preventDefault(); + const errors = validateLuckyGiftForm(form); + if (errors.length) { + setValidationErrors(errors); + return; + } + onSubmit(formToConfig(config, form)); + }; + + const isCreate = mode === "create"; + const title = isCreate ? "添加幸运礼物配置" : config.is_default ? "发布幸运礼物配置" : "新增幸运礼物版本"; + + return ( + + + + {title} + + {form.app_code || "-"} / {form.pool_id || "default"} /{" "} + {config.is_default || isCreate ? "默认草稿" : `当前版本 v${config.rule_version ?? "-"}`} + + + +
+ + + + + - {/* 概率由目标 RTP 和倍率统一校正,保持只读可以避免运营手填后绕过服务端概率闭合校验。 */} - - = 10} - size="small" - slotProps={{ input: { "aria-label": "高水位" } }} - onChange={(event) => updateStageTier(activeStageConfig.stage, index, { highWaterOnly: event.target.checked })} - /> - updateStageTier(activeStageConfig.stage, index, { enabled: event.target.checked })} - /> - -
- ))} -
+ + +
+ + {validationErrors.length ? ( +
+ 配置不能发布 +
    + {validationErrors.map((message) => ( +
  • {message}
  • + ))} +
+
+ ) : null} + +
+ + +
+
+ {stageSummaries.map((summary) => ( + + ))} +
+ {activeStageConfig ? ( +
+
+
+

{activeSummary?.stageLabel}

+ +
+ +
+ {form.strategy_version === "dynamic_v3" ? ( +
+ + updateStageField( + activeStageConfig.stage, + "min_recharge_7d_coins", + event.target.value, + ) + } + /> + + updateStageField( + activeStageConfig.stage, + "min_recharge_30d_coins", + event.target.value, + ) + } + /> +
+ ) : null} +
+ + {activeStageConfig.tiers.map((tier, index) => ( +
+ + updateStageTier(activeStageConfig.stage, index, { + // 10 倍及以上强制只走高水位,输入时同步勾选并锁死,防止高倍档在低水位放开。 + highWaterOnly: + Number(event.target.value) >= 10 || tier.highWaterOnly, + multiplier: event.target.value, + }) + } + /> + {/* 概率由目标 RTP 和倍率统一校正,保持只读可以避免运营手填后绕过服务端概率闭合校验。 */} + + = 10} + size="small" + slotProps={{ input: { "aria-label": "高水位" } }} + onChange={(event) => + updateStageTier(activeStageConfig.stage, index, { + highWaterOnly: event.target.checked, + }) + } + /> + + updateStageTier(activeStageConfig.stage, index, { + enabled: event.target.checked, + }) + } + /> + +
+ ))} +
+
+ ) : null} +
+
+ + + + + + +
+ ); +} + +function DynamicStrategySections({ form, onChange }) { + return ( + <> +
+

动态水位

+
+ + + + + + + +
- ) : null}
- - - - - - - - - ); + +
+

大奖控制

+
+ onChange("jackpot_multipliers", event.target.value)} + /> + + + + + +
+
+ +
+

六维返奖上限

+
+ + + + + + +
+
+ + ); } function ConfigMetric({ label, value }) { - return ( -
- {label} - {value} -
- ); + return ( +
+ {label} + {value} +
+ ); } -function NumberField({ form, label, name, onChange, step = "1" }) { - return ( - onChange(name, event.target.value)} - /> - ); +function NumberField({ form, helperText, label, name, onChange, step = "1" }) { + return ( + onChange(name, event.target.value)} + /> + ); } diff --git a/ops-center/src/configForm.js b/ops-center/src/configForm.js index 8d907df..f814777 100644 --- a/ops-center/src/configForm.js +++ b/ops-center/src/configForm.js @@ -1,158 +1,458 @@ import { stageOptions } from "@/features/lucky-gift/constants.js"; import { - applyProbabilityMap, - correctRTPProbabilities, - decimal, - expectedRTPPercent, - formatDecimal, - probabilityTotal + applyProbabilityMap, + correctRTPProbabilities, + decimal, + expectedRTPPercent, + formatDecimal, + probabilityTotal, } from "@/shared/utils/rtpProbability.js"; import { DEFAULT_POOL_ID } from "./api.js"; -// 幸运礼物配置表单的纯领域逻辑:配置 <-> 表单换算、概率闭合校正、阶段摘要。 -// 与展示层分离,弹窗组件只负责渲染和事件绑定。 +const DYNAMIC_STRATEGY = "dynamic_v3"; +const PPM_SCALE = 1_000_000; + +// 只有跨 App 通用的算法参数才有默认值;50 美元等值门槛和六维金额上限必须由运营按 App 金币口径填写, +// 因而默认保持 0 且规则停用,避免把某个 App 的预算误发到其他 App。 +const dynamicDefaults = { + anchor_daily_payout_cap: 0, + anchor_rate_percent: 1, + control_band_percent: 3, + device_daily_payout_cap: 0, + high_water_nonzero_factor_percent: 130, + high_watermark_coins: 20_000_000, + initial_pool_coins: 1_000_000, + jackpot_global_rtp_max_percent: 98, + jackpot_multipliers: [200, 500, 1000], + jackpot_spend_threshold_coins: 0, + jackpot_user_72h_rtp_max_percent: 96, + jackpot_user_day_rtp_max_percent: 96, + loss_streak_guarantee: 5, + low_water_nonzero_factor_percent: 70, + low_watermark_coins: 10_000_000, + max_jackpot_hits_per_user_day: 5, + max_single_payout: 0, + pool_rate_percent: 98, + profit_rate_percent: 1, + recharge_boost_factor_percent: 110, + recharge_boost_window_ms: 300_000, + room_hourly_payout_cap: 0, + target_rtp_percent: 98, + user_daily_payout_cap: 0, + user_hourly_payout_cap: 0, +}; + +const numericFields = [ + "anchor_daily_payout_cap", + "anchor_rate_percent", + "control_band_percent", + "device_daily_payout_cap", + "effective_from_ms", + "gift_price_reference", + "high_water_nonzero_factor_percent", + "high_watermark_coins", + "initial_pool_coins", + "jackpot_global_rtp_max_percent", + "jackpot_spend_threshold_coins", + "jackpot_user_72h_rtp_max_percent", + "jackpot_user_day_rtp_max_percent", + "loss_streak_guarantee", + "low_water_nonzero_factor_percent", + "low_watermark_coins", + "max_jackpot_hits_per_user_day", + "max_single_payout", + "normal_max_equivalent_draws", + "novice_max_equivalent_draws", + "pool_rate_percent", + "profit_rate_percent", + "recharge_boost_factor_percent", + "recharge_boost_window_ms", + "room_hourly_payout_cap", + "settlement_window_wager", + "target_rtp_percent", + "user_daily_payout_cap", + "user_hourly_payout_cap", +]; + +const payoutCapFields = [ + ["max_single_payout", "单次返奖上限"], + ["user_hourly_payout_cap", "用户小时上限"], + ["user_daily_payout_cap", "用户每日上限"], + ["device_daily_payout_cap", "设备每日上限"], + ["room_hourly_payout_cap", "房间小时上限"], + ["anchor_daily_payout_cap", "主播每日上限"], +]; export function configToForm(config = {}) { - const targetRTPPercent = formNumber(config.target_rtp_percent ?? config.targetRtpPercent); - return { - app_code: config.app_code || config.appCode || "", - control_band_percent: formNumber(config.control_band_percent ?? config.controlBandPercent), - enabled: Boolean(config.enabled), - gift_price_reference: formNumber(config.gift_price_reference ?? config.giftPriceReference), - pool_id: config.pool_id || config.poolId || DEFAULT_POOL_ID, - pool_rate_percent: formNumber(config.pool_rate_percent ?? config.poolRatePercent), - settlement_window_wager: formNumber(config.settlement_window_wager ?? config.settlementWindowWager), - stages: normalizeFormStages(config.stages, targetRTPPercent), - target_rtp_percent: targetRTPPercent - }; + const hasPersistedShape = Object.keys(config).length > 0; + const strategyVersion = stringConfigValue( + config, + "strategy_version", + "strategyVersion", + hasPersistedShape ? "fixed_v2" : DYNAMIC_STRATEGY, + ); + const targetRTPPercent = formConfigNumber( + config, + "target_rtp_percent", + "targetRtpPercent", + strategyVersion === DYNAMIC_STRATEGY ? dynamicDefaults.target_rtp_percent : 95, + ); + const form = { + app_code: config.app_code || config.appCode || "", + enabled: Boolean(config.enabled), + jackpot_multipliers: multiplierListToForm( + config.jackpot_multipliers ?? config.jackpotMultipliers, + strategyVersion, + ), + pool_id: config.pool_id || config.poolId || DEFAULT_POOL_ID, + stages: normalizeFormStages(config.stages, targetRTPPercent, strategyVersion), + strategy_version: strategyVersion, + }; + for (const field of numericFields) { + if (field === "target_rtp_percent") { + form[field] = targetRTPPercent; + continue; + } + form[field] = formConfigNumber(config, field, snakeToCamel(field), defaultNumber(field, strategyVersion)); + } + return form; } export function formToConfig(config, form) { - return { - ...config, - app_code: form.app_code, - control_band_percent: numberFromForm(form.control_band_percent), - enabled: Boolean(form.enabled), - gift_price_reference: numberFromForm(form.gift_price_reference), - pool_id: form.pool_id, - pool_rate_percent: numberFromForm(form.pool_rate_percent), - settlement_window_wager: numberFromForm(form.settlement_window_wager), - stages: form.stages, - target_rtp_percent: numberFromForm(form.target_rtp_percent) - }; + const output = { + ...config, + app_code: form.app_code, + enabled: Boolean(form.enabled), + jackpot_multipliers: multiplierListFromForm(form.jackpot_multipliers), + pool_id: form.pool_id, + stages: (form.stages || []).map((stage) => ({ + min_recharge_30d_coins: numberFromForm(stage.min_recharge_30d_coins), + min_recharge_7d_coins: numberFromForm(stage.min_recharge_7d_coins), + stage: stage.stage, + tiers: (stage.tiers || []).map((tier) => ({ + enabled: tier.enabled !== false, + high_water_only: Boolean(tier.highWaterOnly), + multiplier: numberFromForm(tier.multiplier), + probability_percent: numberFromForm(tier.probabilityPercent), + tier_id: tier.tierId || "", + })), + })), + strategy_version: form.strategy_version, + }; + for (const field of numericFields) { + output[field] = numberFromForm(form[field]); + } + return output; +} + +// 从历史 fixed_v2 显式切换到 dynamic_v3 时,先停用并载入安全草稿;金额型上限仍保持 0, +// 运营补齐后才能通过启用校验,避免一次策略切换直接发布无法结算的规则。 +export function applyDynamicStrategyDefaults(form) { + const next = { + ...form, + enabled: false, + jackpot_multipliers: dynamicDefaults.jackpot_multipliers.join(", "), + strategy_version: DYNAMIC_STRATEGY, + }; + for (const [field, value] of Object.entries(dynamicDefaults)) { + if (field !== "jackpot_multipliers") { + next[field] = String(value); + } + } + next.stages = stageOptions.map(([stage]) => { + const [min7d, min30d] = defaultRechargeThreshold(stage); + return { + min_recharge_7d_coins: String(min7d), + min_recharge_30d_coins: String(min30d), + stage, + // fixed_v2 可能含历史高倍基础档;切换策略时必须回到 owner 的 98% 安全基线, + // 不能只改策略名后把旧概率意外带入 dynamic_v3 的第一版。 + tiers: correctedDefaultTiers(stage, dynamicDefaults.target_rtp_percent), + }; + }); + return next; } // 概率列必须整体闭合到 100% 且期望 RTP 逼近目标值,所以任何倍率/目标 RTP 改动后都重算全阶段概率, // 不允许运营手工填概率绕过服务端的闭合校验。 export function correctAllStageProbabilities(form) { - return { - ...form, - stages: (form.stages || []).map((stage) => { - const probabilityByIndex = correctRTPProbabilities(stage.tiers, form.target_rtp_percent, { - multiplier: (item) => item.multiplier - }); - return { - ...stage, - tiers: probabilityByIndex ? applyProbabilityMap(stage.tiers, probabilityByIndex, formatDecimal) : stage.tiers - }; - }) - }; + return { + ...form, + stages: (form.stages || []).map((stage) => { + const probabilityByIndex = correctRTPProbabilities(stage.tiers, form.target_rtp_percent, { + multiplier: (item) => item.multiplier, + }); + return { + ...stage, + tiers: probabilityByIndex + ? applyProbabilityMap(stage.tiers, probabilityByIndex, formatDecimal) + : stage.tiers, + }; + }), + }; } export function stageSummary(stage, form) { - const probability = probabilityTotal(stage.tiers); - const expected = expectedRTPPercent(stage.tiers, { - multiplier: (item) => item.multiplier - }); - const target = Number(form.target_rtp_percent || 0); - const band = Number(form.control_band_percent || 0); - const probabilityClosed = Math.abs(probability - 100) < 0.0001; - const rtpInBand = expected >= target - band && expected <= target + band; - const fullLabel = stageOptions.find(([key]) => key === stage.stage)?.[1] || stage.stage; + const probability = probabilityTotal(stage.tiers); + const expected = expectedRTPPercent(stage.tiers, { multiplier: (item) => item.multiplier }); + const target = Number(form.target_rtp_percent || 0); + const band = Number(form.control_band_percent || 0); + const probabilityClosed = Math.abs(probability - 100) < 0.0001; + const rtpInBand = expected >= target - band && expected <= target + band; + const fullLabel = stageOptions.find(([key]) => key === stage.stage)?.[1] || stage.stage; + return { + expected, + ok: probabilityClosed && rtpInBand, + probability, + probabilityClosed, + rtpInBand, + stageKey: stage.stage, + stageLabel: fullLabel, + status: probabilityClosed && rtpInBand ? "可发布" : probabilityClosed ? "RTP 偏离" : "概率未闭合", + tabLabel: stage.stage === "normal" ? "普通" : fullLabel.replace(/阶段$/, ""), + }; +} - return { - expected, - ok: probabilityClosed && rtpInBand, - probability, - probabilityClosed, - rtpInBand, - stageKey: stage.stage, - stageLabel: fullLabel, - // 发布按钮不做硬拦截(服务端仍会校验),这里的状态只用于提示运营当前阶段是否可安全发布。 - status: probabilityClosed && rtpInBand ? "可发布" : probabilityClosed ? "RTP 偏离" : "概率未闭合", - tabLabel: stage.stage === "normal" ? "普通" : fullLabel.replace(/阶段$/, "") - }; +export function validateLuckyGiftForm(form) { + const errors = []; + const number = (field) => numberFromForm(form[field]); + if (!String(form.app_code || "").trim()) errors.push("请选择应用"); + if (!String(form.pool_id || "").trim()) errors.push("奖池不能为空"); + if (!["fixed_v2", DYNAMIC_STRATEGY].includes(form.strategy_version)) errors.push("策略版本不受支持"); + if (number("target_rtp_percent") < 10 || number("target_rtp_percent") > 99) + errors.push("目标 RTP 必须在 10%-99% 之间"); + if (number("pool_rate_percent") < number("target_rtp_percent") || number("pool_rate_percent") > 100) + errors.push("奖池比例必须在目标 RTP 与 100% 之间"); + if (number("control_band_percent") <= 0 || number("control_band_percent") > 5) + errors.push("控制带宽必须大于 0 且不超过 5%"); + if (number("settlement_window_wager") <= 0) errors.push("结算窗口流水必须大于 0"); + if (number("gift_price_reference") <= 0) errors.push("礼物参考金额必须大于 0"); + if ( + number("novice_max_equivalent_draws") < 0 || + number("normal_max_equivalent_draws") < number("novice_max_equivalent_draws") + ) + errors.push("等价抽数阶段门槛不正确"); + + // 阶段概率与 RTP 是两代策略共用的 owner 校验,fixed_v2 也不能跳过。 + for (const stageKey of ["novice", "normal", "advanced"]) { + const stage = form.stages?.find((item) => item.stage === stageKey); + if (!stage) { + errors.push("必须配置新手、正常和高阶三个阶段"); + continue; + } + const enabled = stage.tiers.filter((tier) => tier.enabled !== false); + if (enabled.some((tier) => numberFromForm(tier.multiplier) < 0)) + errors.push(`${stageLabel(stageKey)}倍率不能小于 0`); + const probabilityPPM = enabled.reduce((sum, tier) => sum + percentToPPM(tier.probabilityPercent), 0); + if (probabilityPPM !== PPM_SCALE) errors.push(`${stageLabel(stageKey)}启用奖档概率必须合计 100%`); + if (!enabled.some((tier) => Number(tier.multiplier) === 0)) + errors.push(`${stageLabel(stageKey)}必须包含启用的 0x 档`); + if (enabled.some((tier) => Number(tier.probabilityPercent) <= 0)) + errors.push(`${stageLabel(stageKey)}启用奖档概率必须大于 0`); + if (enabled.some((tier) => Number(tier.multiplier) >= 10 && !tier.highWaterOnly)) + errors.push(`${stageLabel(stageKey)}10x 及以上奖档必须限制为高水位`); + const expected = expectedRTPPercent(enabled, { multiplier: (item) => item.multiplier }); + if ( + expected < number("target_rtp_percent") - number("control_band_percent") || + expected > number("target_rtp_percent") + number("control_band_percent") + ) + errors.push(`${stageLabel(stageKey)}期望 RTP 超出控制带宽`); + } + + if (form.strategy_version !== DYNAMIC_STRATEGY) return unique(errors); + + if (number("profit_rate_percent") < 0 || number("anchor_rate_percent") < 0) + errors.push("平台盈利和主播收益比例不能小于 0"); + if ( + percentToPPM(number("pool_rate_percent")) + + percentToPPM(number("profit_rate_percent")) + + percentToPPM(number("anchor_rate_percent")) !== + PPM_SCALE + ) + errors.push("奖池、平台盈利和主播收益比例合计必须等于 100%"); + if (number("initial_pool_coins") <= 0) errors.push("初始奖池必须大于 0"); + if (number("loss_streak_guarantee") <= 0) errors.push("连续未中奖保护次数必须大于 0"); + if (number("low_watermark_coins") <= 0 || number("high_watermark_coins") <= number("low_watermark_coins")) + errors.push("高水位必须大于正数低水位"); + if (number("low_water_nonzero_factor_percent") <= 0 || number("low_water_nonzero_factor_percent") >= 100) + errors.push("低水位非零因子必须大于 0% 且小于 100%"); + if (number("high_water_nonzero_factor_percent") <= 100) errors.push("高水位非零因子必须大于 100%"); + if (number("recharge_boost_window_ms") <= 0 || number("recharge_boost_factor_percent") <= 100) + errors.push("充值加权窗口必须大于 0 且因子必须大于 100%"); + const jackpotTokens = String(form.jackpot_multipliers || "") + .split(/[,,\s]+/) + .filter(Boolean); + const jackpotMultipliers = multiplierListFromForm(form.jackpot_multipliers); + if ( + jackpotTokens.length !== jackpotMultipliers.length || + !jackpotMultipliers.length || + jackpotMultipliers.some((value, index) => value <= 1 || (index > 0 && value <= jackpotMultipliers[index - 1])) + ) { + errors.push("大奖倍率必须大于 1x 并严格递增"); + } + if (number("jackpot_global_rtp_max_percent") <= 0 || number("jackpot_global_rtp_max_percent") > 100) + errors.push("全局大奖 RTP 上限必须在 0%-100% 之间"); + if ( + number("jackpot_user_day_rtp_max_percent") <= 0 || + number("jackpot_user_day_rtp_max_percent") > number("jackpot_global_rtp_max_percent") + ) + errors.push("用户每日 RTP 上限必须大于 0 且不超过全局上限"); + if ( + number("jackpot_user_72h_rtp_max_percent") <= 0 || + number("jackpot_user_72h_rtp_max_percent") > number("jackpot_global_rtp_max_percent") + ) + errors.push("用户 72 小时 RTP 上限必须大于 0 且不超过全局上限"); + if (number("max_jackpot_hits_per_user_day") <= 0) errors.push("用户每日大奖次数必须大于 0"); + if (number("jackpot_spend_threshold_coins") < 0) errors.push("大奖消费门槛不能小于 0"); + for (const [field, label] of payoutCapFields) { + if (number(field) < 0) errors.push(`${label}不能小于 0`); + } + validateRechargeStages(form.stages || [], errors); + if (form.enabled) { + if (number("jackpot_spend_threshold_coins") <= 0) errors.push("启用前必须填写 50 美元等值的大奖消费门槛"); + for (const [field, label] of payoutCapFields) { + if (number(field) <= 0) errors.push(`启用前必须填写${label}`); + } + } + return unique(errors); } export function nextStageMultiplier(tiers = []) { - const positive = tiers.map((tier) => decimal(tier.multiplier)).filter((value) => value > 0); - if (!positive.length) { - return 1; - } - const max = Math.max(...positive); - if (max >= 5) { - return max * 2; - } - if (max >= 2) { - return 5; - } - return max + 1; + const positive = tiers.map((tier) => decimal(tier.multiplier)).filter((value) => value > 0); + if (!positive.length) return 1; + const max = Math.max(...positive); + if (max >= 5) return max * 2; + if (max >= 2) return 5; + return max + 1; } -function normalizeFormStages(stages, targetRTPPercent = 95) { - const byStage = new Map((Array.isArray(stages) ? stages : []).map((stage) => [stage.stage, stage])); - return correctAllStageProbabilities({ - stages: stageOptions.map(([stageKey]) => { - const source = byStage.get(stageKey); - const sourceTiers = Array.isArray(source?.tiers) && source.tiers.length ? source.tiers : defaultStageTiers(stageKey); - return { - stage: stageKey, - tiers: sourceTiers.map((tier) => ({ - enabled: tier.enabled !== false, - highWaterOnly: Boolean(tier.high_water_only ?? tier.highWaterOnly), - multiplier: formNumber(tier.multiplier), - probabilityPercent: formNumber(tier.probability_percent ?? tier.probabilityPercent), - tierId: tier.tier_id || tier.tierId || "" - })) - }; - }), - target_rtp_percent: targetRTPPercent - }).stages; +function normalizeFormStages(stages, targetRTPPercent, strategyVersion) { + const byStage = new Map((Array.isArray(stages) ? stages : []).map((stage) => [stage.stage, stage])); + return stageOptions.map(([stageKey]) => { + const source = byStage.get(stageKey); + const sourceTiers = + Array.isArray(source?.tiers) && source.tiers.length + ? source.tiers + : correctedDefaultTiers(stageKey, targetRTPPercent); + const [default7d, default30d] = + strategyVersion === DYNAMIC_STRATEGY ? defaultRechargeThreshold(stageKey) : [0, 0]; + return { + min_recharge_7d_coins: formNumber(source?.min_recharge_7d_coins ?? source?.minRecharge7DCoins ?? default7d), + min_recharge_30d_coins: formNumber( + source?.min_recharge_30d_coins ?? source?.minRecharge30DCoins ?? default30d, + ), + stage: stageKey, + tiers: sourceTiers.map((tier) => ({ + enabled: tier.enabled !== false, + highWaterOnly: Boolean(tier.high_water_only ?? tier.highWaterOnly), + multiplier: formNumber(tier.multiplier), + probabilityPercent: formNumber(tier.probability_percent ?? tier.probabilityPercent), + tierId: tier.tier_id || tier.tierId || "", + })), + }; + }); } -function defaultStageTiers(stage) { - if (stage === "advanced") { - return [ - defaultTier("advanced_none", 0, 27), - defaultTier("advanced_1x", 1, 60), - defaultTier("advanced_2x", 2, 10), - defaultTier("advanced_5x", 5, 3, { high_water_only: true }) +function correctedDefaultTiers(stage, targetRTPPercent) { + const tiers = [ + defaultTier(`${stage}_none`, 0, 5), + defaultTier(`${stage}_0_5x`, 0.5, 4), + defaultTier(`${stage}_1x`, 1, 86), + defaultTier(`${stage}_2x`, 2, 5), ]; - } - return [ - defaultTier(`${stage}_none`, 0, 10), - defaultTier(`${stage}_0_5x`, 0.5, 20), - defaultTier(`${stage}_1x`, 1, 55), - defaultTier(`${stage}_2x`, 2, 15) - ]; + // dynamic_v3 owner 的 98% 安全基线不只是数学 RTP,还规定了 0x/0.5x/1x/2x 的体验节奏。 + // 这组 5/4/86/5 本身已精确达到 98%,不能再交给无 baseline 的通用求解器重排成另一组同 RTP 概率。 + if (Math.abs(numberFromForm(targetRTPPercent) - dynamicDefaults.target_rtp_percent) < 0.0001) { + return tiers; + } + const probabilities = correctRTPProbabilities(tiers, targetRTPPercent, { multiplier: (item) => item.multiplier }); + return probabilities ? applyProbabilityMap(tiers, probabilities, formatDecimal) : tiers; } -function defaultTier(tierID, multiplier, probabilityPercent, options = {}) { - return { - enabled: true, - highWaterOnly: Boolean(options.high_water_only), - multiplier, - probabilityPercent, - tierId: tierID - }; +function defaultTier(tierID, multiplier, probabilityPercent) { + return { enabled: true, highWaterOnly: false, multiplier, probabilityPercent, tierId: tierID }; +} + +function validateRechargeStages(stages, errors) { + const novice = stages.find((stage) => stage.stage === "novice"); + const normal = stages.find((stage) => stage.stage === "normal"); + const advanced = stages.find((stage) => stage.stage === "advanced"); + const threshold = (stage, field) => numberFromForm(stage?.[field]); + if (threshold(novice, "min_recharge_7d_coins") !== 0 || threshold(novice, "min_recharge_30d_coins") !== 0) + errors.push("新手阶段充值门槛必须为 0 / 0"); + const normal7d = threshold(normal, "min_recharge_7d_coins"); + const normal30d = threshold(normal, "min_recharge_30d_coins"); + const advanced7d = threshold(advanced, "min_recharge_7d_coins"); + const advanced30d = threshold(advanced, "min_recharge_30d_coins"); + if ([normal7d, normal30d, advanced7d, advanced30d].some((value) => value < 0)) { + errors.push("充值阶段门槛不能小于 0"); + return; + } + if (normal7d === 0 && normal30d === 0) errors.push("正常阶段至少一个充值门槛必须大于 0"); + if (advanced7d < normal7d || advanced30d < normal30d || (advanced7d === normal7d && advanced30d === normal30d)) + errors.push("高阶充值门槛必须逐维不低于正常阶段且至少一项更高"); +} + +function defaultRechargeThreshold(stage) { + if (stage === "novice") return [0, 0]; + if (stage === "normal") return [0, 1]; + return [1, 1]; +} + +function defaultNumber(field, strategyVersion) { + if (field === "gift_price_reference") return 100; + if (field === "settlement_window_wager") return 1_000_000; + if (field === "novice_max_equivalent_draws") return 2_000; + if (field === "normal_max_equivalent_draws") return 20_000; + if (field === "effective_from_ms") return 0; + return strategyVersion === DYNAMIC_STRATEGY ? (dynamicDefaults[field] ?? 0) : 0; +} + +function multiplierListToForm(value, strategyVersion) { + if (Array.isArray(value) && value.length) return value.map(formatDecimal).join(", "); + if (typeof value === "string" && value.trim()) return value; + return strategyVersion === DYNAMIC_STRATEGY ? dynamicDefaults.jackpot_multipliers.join(", ") : ""; +} + +function multiplierListFromForm(value) { + return String(value || "") + .split(/[,,\s]+/) + .filter(Boolean) + .map(Number) + .filter(Number.isFinite); +} + +function stringConfigValue(config, snake, camel, fallback) { + const value = config[snake] ?? config[camel]; + return String(value || fallback); +} + +function formConfigNumber(config, snake, camel, fallback) { + return formNumber(config[snake] ?? config[camel] ?? fallback); +} + +function snakeToCamel(value) { + return value.replace(/_([a-z0-9])/g, (_, letter) => letter.toUpperCase()); +} + +function stageLabel(stage) { + return stageOptions.find(([key]) => key === stage)?.[1] || stage; +} + +function percentToPPM(value) { + return Math.round(Number(value || 0) * 10_000); +} + +function unique(values) { + return Array.from(new Set(values)); } export function formNumber(value) { - const parsed = Number(value); - return Number.isFinite(parsed) ? String(parsed) : "0"; + const parsed = Number(value); + return Number.isFinite(parsed) ? String(parsed) : "0"; } export function numberFromForm(value) { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : 0; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; } diff --git a/ops-center/src/configForm.test.js b/ops-center/src/configForm.test.js new file mode 100644 index 0000000..df9118d --- /dev/null +++ b/ops-center/src/configForm.test.js @@ -0,0 +1,217 @@ +import { describe, expect, test } from "vitest"; +import { applyDynamicStrategyDefaults, configToForm, formToConfig, validateLuckyGiftForm } from "./configForm.js"; + +describe("ops center lucky gift dynamic_v3 form", () => { + test("loads safe dynamic defaults without inventing app-specific monetary caps", () => { + const form = configToForm({ app_code: "lalu", pool_id: "default", strategy_version: "dynamic_v3" }); + + expect(form).toMatchObject({ + anchor_rate_percent: "1", + enabled: false, + initial_pool_coins: "1000000", + jackpot_multipliers: "200, 500, 1000", + max_single_payout: "0", + pool_rate_percent: "98", + profit_rate_percent: "1", + strategy_version: "dynamic_v3", + target_rtp_percent: "98", + }); + expect(form.stages.map((stage) => [stage.min_recharge_7d_coins, stage.min_recharge_30d_coins])).toEqual([ + ["0", "0"], + ["0", "1"], + ["1", "1"], + ]); + expect(form.stages.map((stage) => stage.tiers.map((tier) => Number(tier.probabilityPercent)))).toEqual([ + [5, 4, 86, 5], + [5, 4, 86, 5], + [5, 4, 86, 5], + ]); + }); + + test("round-trips every dynamic strategy field and canonical stage keys", () => { + const config = completeDynamicConfig(); + // 使用空原对象可证明每个字段都真正经过 formToConfig 写回;若沿用 config 作为 spread 基底, + // 漏掉的动态字段也会被旧值掩盖,测试无法发现发布 payload 的字段丢失。 + const output = formToConfig({}, configToForm(config)); + + expect(output).toEqual(config); + expect(output.stages[1]).toMatchObject({ + min_recharge_30d_coins: 20_000, + min_recharge_7d_coins: 10_000, + stage: "normal", + }); + expect(output.stages[1].tiers[0]).toEqual({ + enabled: true, + high_water_only: false, + multiplier: 0, + probability_percent: 5, + tier_id: "normal_none", + }); + }); + + test("allows disabled drafts but blocks enabled dynamic rules until app monetary limits are filled", () => { + const draft = configToForm({ app_code: "lalu", pool_id: "default", strategy_version: "dynamic_v3" }); + expect(validateLuckyGiftForm(draft)).toEqual([]); + + const enabled = { ...draft, enabled: true }; + const errors = validateLuckyGiftForm(enabled); + + expect(errors).toContain("启用前必须填写 50 美元等值的大奖消费门槛"); + expect(errors).toContain("启用前必须填写单次返奖上限"); + expect(errors).toContain("启用前必须填写主播每日上限"); + }); + + test("keeps fixed_v2 readable and does not apply dynamic-only validation", () => { + const form = configToForm({ + app_code: "yumi", + control_band_percent: 1, + enabled: true, + gift_price_reference: 100, + normal_max_equivalent_draws: 20_000, + novice_max_equivalent_draws: 2_000, + pool_id: "legacy", + pool_rate_percent: 96, + settlement_window_wager: 1_000_000, + stages: fixedStages(), + strategy_version: "fixed_v2", + target_rtp_percent: 95, + }); + + expect(form.strategy_version).toBe("fixed_v2"); + expect(validateLuckyGiftForm(form)).toEqual([]); + }); + + test("switching a legacy form to dynamic creates a disabled safe draft", () => { + const legacy = configToForm({ + app_code: "yumi", + control_band_percent: 1, + enabled: true, + gift_price_reference: 100, + normal_max_equivalent_draws: 20_000, + novice_max_equivalent_draws: 2_000, + pool_id: "legacy", + pool_rate_percent: 96, + settlement_window_wager: 1_000_000, + stages: fixedStages(), + strategy_version: "fixed_v2", + target_rtp_percent: 95, + }); + const dynamic = applyDynamicStrategyDefaults(legacy); + + expect(dynamic).toMatchObject({ enabled: false, pool_rate_percent: "98", strategy_version: "dynamic_v3" }); + expect(dynamic.stages.map((stage) => stage.tiers.map((tier) => Number(tier.multiplier)))).toEqual([ + [0, 0.5, 1, 2], + [0, 0.5, 1, 2], + [0, 0.5, 1, 2], + ]); + expect(dynamic.stages.map((stage) => stage.tiers.map((tier) => Number(tier.probabilityPercent)))).toEqual([ + [5, 4, 86, 5], + [5, 4, 86, 5], + [5, 4, 86, 5], + ]); + expect(validateLuckyGiftForm(dynamic)).toEqual([]); + }); + + test("rejects invalid disabled draft values that the owner never accepts", () => { + const draft = configToForm({ app_code: "lalu", pool_id: "default", strategy_version: "dynamic_v3" }); + const errors = validateLuckyGiftForm({ + ...draft, + jackpot_spend_threshold_coins: "-1", + max_single_payout: "-1", + profit_rate_percent: "-1", + anchor_rate_percent: "2", + stages: draft.stages.map((stage) => + stage.stage === "normal" ? { ...stage, min_recharge_7d_coins: "-1" } : stage, + ), + }); + + expect(errors).toContain("平台盈利和主播收益比例不能小于 0"); + expect(errors).toContain("大奖消费门槛不能小于 0"); + expect(errors).toContain("单次返奖上限不能小于 0"); + expect(errors).toContain("充值阶段门槛不能小于 0"); + }); +}); + +function completeDynamicConfig() { + return { + anchor_daily_payout_cap: 6_000_000, + anchor_rate_percent: 1, + app_code: "lalu", + control_band_percent: 3, + device_daily_payout_cap: 4_000_000, + effective_from_ms: 0, + enabled: true, + gift_price_reference: 100, + high_water_nonzero_factor_percent: 130, + high_watermark_coins: 20_000_000, + initial_pool_coins: 1_000_000, + jackpot_global_rtp_max_percent: 98, + jackpot_multipliers: [200, 500, 1000], + jackpot_spend_threshold_coins: 50_000, + jackpot_user_72h_rtp_max_percent: 96, + jackpot_user_day_rtp_max_percent: 96, + loss_streak_guarantee: 5, + low_water_nonzero_factor_percent: 70, + low_watermark_coins: 10_000_000, + max_jackpot_hits_per_user_day: 5, + max_single_payout: 1_000_000, + normal_max_equivalent_draws: 20_000, + novice_max_equivalent_draws: 2_000, + pool_id: "default", + pool_rate_percent: 98, + profit_rate_percent: 1, + recharge_boost_factor_percent: 110, + recharge_boost_window_ms: 300_000, + room_hourly_payout_cap: 5_000_000, + settlement_window_wager: 1_000_000, + stages: dynamicStages(), + strategy_version: "dynamic_v3", + target_rtp_percent: 98, + user_daily_payout_cap: 3_000_000, + user_hourly_payout_cap: 2_000_000, + }; +} + +function dynamicStages() { + return [ + dynamicStage("novice", 0, 0), + dynamicStage("normal", 10_000, 20_000), + dynamicStage("advanced", 20_000, 30_000), + ]; +} + +function dynamicStage(stage, min7d, min30d) { + return { + min_recharge_30d_coins: min30d, + min_recharge_7d_coins: min7d, + stage, + tiers: [ + tier(`${stage}_none`, 0, 5), + tier(`${stage}_0_5x`, 0.5, 4), + tier(`${stage}_1x`, 1, 86), + tier(`${stage}_2x`, 2, 5), + ], + }; +} + +function fixedStages() { + return ["novice", "normal", "advanced"].map((stage) => ({ + stage, + tiers: [ + tier(`${stage}_none`, 0, 10), + tier(`${stage}_0_5x`, 0.5, 20), + tier(`${stage}_1x`, 1, 55), + tier(`${stage}_2x`, 2, 15), + ], + })); +} + +function tier(tierId, multiplier, probabilityPercent) { + return { + enabled: true, + high_water_only: false, + multiplier, + probability_percent: probabilityPercent, + tier_id: tierId, + }; +} diff --git a/ops-center/src/styles/index.css b/ops-center/src/styles/index.css index ee7e595..14d2863 100644 --- a/ops-center/src/styles/index.css +++ b/ops-center/src/styles/index.css @@ -1,407 +1,440 @@ :root { - color: var(--text-primary); - background: var(--bg-page); - font-family: var(--font-system); + color: var(--text-primary); + background: var(--bg-page); + font-family: var(--font-system); } body { - margin: 0; - min-width: 320px; - background: var(--bg-page); + margin: 0; + min-width: 320px; + background: var(--bg-page); } #ops-center-root { - min-height: 100vh; + min-height: 100vh; } /* ---- 应用外壳:左侧固定导航 + 内容区,宽度对齐主后台侧边栏(248px) ---- */ .ops-shell { - display: grid; - min-height: 100vh; - grid-template-columns: 248px minmax(0, 1fr); + display: grid; + min-height: 100vh; + grid-template-columns: 248px minmax(0, 1fr); } .ops-sidebar { - display: flex; - flex-direction: column; - gap: var(--space-5); - border-right: 1px solid var(--border); - background: var(--bg-card); - padding: var(--space-5) var(--space-3); + display: flex; + flex-direction: column; + gap: var(--space-5); + border-right: 1px solid var(--border); + background: var(--bg-card); + padding: var(--space-5) var(--space-3); } .ops-brand { - display: flex; - align-items: center; - gap: var(--space-3); - padding: 0 var(--space-2); + display: flex; + align-items: center; + gap: var(--space-3); + padding: 0 var(--space-2); } .ops-brand > span { - display: inline-grid; - width: 36px; - height: 36px; - place-items: center; - border-radius: var(--radius-control); - background: var(--primary); - color: var(--active-contrast, #fff); - font-size: 13px; - font-weight: 800; + display: inline-grid; + width: 36px; + height: 36px; + place-items: center; + border-radius: var(--radius-control); + background: var(--primary); + color: var(--active-contrast, #fff); + font-size: 13px; + font-weight: 800; } .ops-brand strong { - display: block; - font-size: 16px; - line-height: 1.25; + display: block; + font-size: 16px; + line-height: 1.25; } .ops-brand small { - color: var(--text-tertiary); - font-size: 11px; + color: var(--text-tertiary); + font-size: 11px; } .ops-nav { - display: grid; - gap: var(--space-2); + display: grid; + gap: var(--space-2); } .ops-nav button { - display: flex; - width: 100%; - height: 38px; - align-items: center; - gap: var(--space-3); - border: 0; - border-radius: var(--radius-control); - background: transparent; - color: var(--text-secondary); - cursor: pointer; - padding: 0 var(--space-3); - text-align: left; - transition: - background var(--motion-fast) var(--ease-standard), - color var(--motion-fast) var(--ease-standard); + display: flex; + width: 100%; + height: 38px; + align-items: center; + gap: var(--space-3); + border: 0; + border-radius: var(--radius-control); + background: transparent; + color: var(--text-secondary); + cursor: pointer; + padding: 0 var(--space-3); + text-align: left; + transition: + background var(--motion-fast) var(--ease-standard), + color var(--motion-fast) var(--ease-standard); } .ops-nav button:hover { - background: var(--primary-hover); - color: var(--text-primary); + background: var(--primary-hover); + color: var(--text-primary); } /* active 优先级必须高于 hover:hover 到别的项时当前页仍要保持高亮底色 */ .ops-nav button.is-active, .ops-nav button.is-active:hover { - background: var(--primary-surface); - color: var(--primary); - font-weight: 700; + background: var(--primary-surface); + color: var(--primary); + font-weight: 700; } .ops-main { - min-width: 0; - background: var(--bg-page); + min-width: 0; + background: var(--bg-page); } /* ---- 页面骨架 ---- */ .ops-page { - display: grid; - gap: var(--space-5); - padding: var(--space-6); + display: grid; + gap: var(--space-5); + padding: var(--space-6); } .ops-view { - display: grid; - gap: var(--space-5); + display: grid; + gap: var(--space-5); } .ops-kpi-grid { - grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-columns: repeat(4, minmax(0, 1fr)); } .ops-status { - display: inline-flex; - height: 28px; - align-items: center; - border: 1px solid var(--success-border); - border-radius: 999px; - background: var(--success-surface); - color: var(--success); - font-size: 12px; - font-weight: 700; - padding: 0 var(--space-3); + display: inline-flex; + height: 28px; + align-items: center; + border: 1px solid var(--success-border); + border-radius: 999px; + background: var(--success-surface); + color: var(--success); + font-size: 12px; + font-weight: 700; + padding: 0 var(--space-3); } .ops-status.is-loading { - border-color: var(--info-border); - background: var(--info-surface); - color: var(--info); + border-color: var(--info-border); + background: var(--info-surface); + color: var(--info); } .ops-alert { - border: 1px solid var(--warning-border); - border-radius: var(--radius-card); - background: var(--warning-surface); - color: var(--warning); - font-weight: 700; - padding: var(--space-3) var(--space-4); + border: 1px solid var(--warning-border); + border-radius: var(--radius-card); + background: var(--warning-surface); + color: var(--warning); + font-weight: 700; + padding: var(--space-3) var(--space-4); } /* ---- 幸运礼物配置弹窗 ---- */ .ops-config-form { - display: contents; + display: contents; } .ops-config-title { - display: grid; - gap: var(--space-1); + display: grid; + gap: var(--space-1); } .ops-config-title small { - color: var(--text-tertiary); - font-size: 12px; - font-weight: 700; + color: var(--text-tertiary); + font-size: 12px; + font-weight: 700; } .ops-config-metrics { - display: grid; - grid-template-columns: repeat(6, minmax(0, 1fr)); - gap: var(--space-2); - margin-bottom: var(--space-4); + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: var(--space-2); + margin-bottom: var(--space-4); +} + +.ops-config-validation { + display: grid; + gap: var(--space-2); + margin-bottom: var(--space-4); + border: 1px solid var(--danger-border, #fecaca); + border-radius: var(--radius-control); + background: var(--danger-surface, #fef2f2); + color: var(--danger, #dc2626); + padding: var(--space-3) var(--space-4); +} + +.ops-config-validation ul { + display: grid; + gap: 2px; + margin: 0; + padding-left: 20px; } .ops-config-metric { - display: grid; - min-width: 0; - gap: var(--space-1); - border: 1px solid var(--border); - border-radius: var(--radius-control); - background: var(--bg-card-strong, #f8fafc); - padding: 10px var(--space-3); + display: grid; + min-width: 0; + gap: var(--space-1); + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--bg-card-strong, #f8fafc); + padding: 10px var(--space-3); } .ops-config-metric span { - overflow: hidden; - color: var(--text-tertiary); - font-size: 12px; - font-weight: 800; - text-overflow: ellipsis; - white-space: nowrap; + overflow: hidden; + color: var(--text-tertiary); + font-size: 12px; + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; } .ops-config-metric strong { - overflow: hidden; - color: var(--text-primary); - font-size: 15px; - line-height: 1.25; - text-overflow: ellipsis; - white-space: nowrap; + overflow: hidden; + color: var(--text-primary); + font-size: 15px; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; } .ops-config-layout { - display: grid; - grid-template-columns: minmax(280px, 340px) minmax(0, 1fr); - align-items: start; - gap: var(--space-4); + display: grid; + grid-template-columns: minmax(360px, 440px) minmax(0, 1fr); + align-items: start; + gap: var(--space-4); } .ops-config-params { - display: grid; - min-width: 0; - gap: var(--space-4); + display: grid; + min-width: 0; + gap: var(--space-4); } .ops-config-section { - display: grid; - gap: var(--space-3); - border: 1px solid var(--border); - border-radius: var(--radius-card); - background: var(--bg-card); - padding: var(--space-4); + display: grid; + gap: var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-card); + background: var(--bg-card); + padding: var(--space-4); } .ops-config-section h3 { - margin: 0; - color: var(--text-primary); - font-size: 15px; - line-height: 1.3; + margin: 0; + color: var(--text-primary); + font-size: 15px; + line-height: 1.3; } .ops-form-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--space-3); + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-3); +} + +.ops-form-grid__wide { + grid-column: 1 / -1; } .ops-stage-designer { - display: grid; - min-width: 0; - gap: 0; - border: 1px solid var(--border); - border-radius: var(--radius-card); - background: var(--bg-card); + display: grid; + min-width: 0; + gap: 0; + border: 1px solid var(--border); + border-radius: var(--radius-card); + background: var(--bg-card); } .ops-stage-tabs { - display: flex; - min-width: 0; - gap: var(--space-1); - padding: 0 var(--space-3); - border-bottom: 1px solid var(--border); + display: flex; + min-width: 0; + gap: var(--space-1); + padding: 0 var(--space-3); + border-bottom: 1px solid var(--border); } .ops-stage-tab { - display: inline-flex; - min-width: 0; - height: 38px; - align-items: center; - border: 0; - border-bottom: 2px solid transparent; - border-radius: 0; - background: transparent; - color: var(--text-secondary); - cursor: pointer; - font-size: 13px; - font-weight: 800; - padding: 0 var(--space-4); - text-align: center; - transition: - background var(--motion-fast) var(--ease-standard), - border-color var(--motion-fast) var(--ease-standard), - color var(--motion-fast) var(--ease-standard); + display: inline-flex; + min-width: 0; + height: 38px; + align-items: center; + border: 0; + border-bottom: 2px solid transparent; + border-radius: 0; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + font-size: 13px; + font-weight: 800; + padding: 0 var(--space-4); + text-align: center; + transition: + background var(--motion-fast) var(--ease-standard), + border-color var(--motion-fast) var(--ease-standard), + color var(--motion-fast) var(--ease-standard); } .ops-stage-tab:hover { - background: var(--primary-hover); + background: var(--primary-hover); } .ops-stage-tab.is-active { - border-bottom-color: var(--primary); - color: var(--text-primary); + border-bottom-color: var(--primary); + color: var(--text-primary); } /* 阶段有问题(概率未闭合 / RTP 偏离)时 tab 下划线转告警色,切换前就能看到哪个阶段需要处理 */ .ops-stage-tab.is-warn { - color: var(--warning); + color: var(--warning); } .ops-stage-tab.is-active.is-warn { - border-bottom-color: var(--warning); - color: var(--warning); + border-bottom-color: var(--warning); + color: var(--warning); } .ops-stage-stack { - display: grid; - gap: var(--space-3); - padding: var(--space-4); + display: grid; + gap: var(--space-3); + padding: var(--space-4); } .ops-stage-block__head { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-3); + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); } .ops-stage-block__head > div { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--space-3); + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-3); } .ops-stage-block__head h3 { - margin: 0; - font-size: 15px; + margin: 0; + font-size: 15px; +} + +.ops-stage-thresholds { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-3); + padding: var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--bg-card-strong, #f8fafc); } .ops-tier-table { - display: grid; - min-width: 0; - overflow-x: auto; - border: 1px solid var(--border); - border-radius: var(--radius-card); - background: var(--bg-card); + display: grid; + min-width: 0; + overflow-x: auto; + border: 1px solid var(--border); + border-radius: var(--radius-card); + background: var(--bg-card); } .ops-tier-head, .ops-tier-row { - display: grid; - box-sizing: border-box; - min-width: 520px; - grid-template-columns: - minmax(110px, 1fr) - minmax(110px, 1fr) - minmax(64px, auto) - minmax(64px, auto) - minmax(72px, auto); - align-items: center; - gap: var(--space-2); + display: grid; + box-sizing: border-box; + min-width: 520px; + grid-template-columns: + minmax(110px, 1fr) + minmax(110px, 1fr) + minmax(64px, auto) + minmax(64px, auto) + minmax(72px, auto); + align-items: center; + gap: var(--space-2); } .ops-tier-head { - min-height: 34px; - border-bottom: 1px solid var(--row-border); - background: var(--bg-card-strong, #f8fafc); - color: var(--text-tertiary); - font-size: 12px; - font-weight: 900; - padding: 0 var(--space-3); + min-height: 34px; + border-bottom: 1px solid var(--row-border); + background: var(--bg-card-strong, #f8fafc); + color: var(--text-tertiary); + font-size: 12px; + font-weight: 900; + padding: 0 var(--space-3); } .ops-tier-row { - border-bottom: 1px solid var(--row-border); - padding: var(--space-2) var(--space-3); + border-bottom: 1px solid var(--row-border); + padding: var(--space-2) var(--space-3); } .ops-tier-row:last-child { - border-bottom: 0; + border-bottom: 0; } /* ---- 响应式 ---- */ @media (max-width: 960px) { - .ops-shell { - grid-template-columns: 1fr; - } + .ops-shell { + grid-template-columns: 1fr; + } - .ops-sidebar { - min-height: auto; - } + .ops-sidebar { + min-height: auto; + } - .ops-nav, - .ops-kpi-grid, - .ops-config-metrics { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } + .ops-nav, + .ops-kpi-grid, + .ops-config-metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } - .ops-config-layout { - grid-template-columns: minmax(0, 1fr); - } + .ops-config-layout { + grid-template-columns: minmax(0, 1fr); + } } @media (max-width: 640px) { - .ops-page { - padding: var(--space-4); - } + .ops-page { + padding: var(--space-4); + } - .ops-nav, - .ops-kpi-grid, - .ops-config-metrics, - .ops-form-grid, - .ops-tier-row { - grid-template-columns: 1fr; - } + .ops-nav, + .ops-kpi-grid, + .ops-config-metrics, + .ops-form-grid, + .ops-stage-thresholds, + .ops-tier-row { + grid-template-columns: 1fr; + } - .ops-tier-head { - display: none; - } + .ops-tier-head { + display: none; + } } @media (prefers-reduced-motion: reduce) { - .ops-nav button, - .ops-stage-tab { - transition: none; - } + .ops-nav button, + .ops-stage-tab { + transition: none; + } } diff --git a/src/features/lucky-gift/pages/LuckyGiftConfigPage.jsx b/src/features/lucky-gift/pages/LuckyGiftConfigPage.jsx index 996a738..80e0b9f 100644 --- a/src/features/lucky-gift/pages/LuckyGiftConfigPage.jsx +++ b/src/features/lucky-gift/pages/LuckyGiftConfigPage.jsx @@ -1,204 +1,18 @@ -import Dialog from "@mui/material/Dialog"; -import DialogActions from "@mui/material/DialogActions"; -import DialogContent from "@mui/material/DialogContent"; -import DialogTitle from "@mui/material/DialogTitle"; -import RefreshOutlined from "@mui/icons-material/RefreshOutlined"; -import TextField from "@mui/material/TextField"; -import { LuckyGiftConfigDrawer } from "@/features/lucky-gift/components/LuckyGiftConfigDrawer.jsx"; -import { LuckyGiftConfigSummary } from "@/features/lucky-gift/components/LuckyGiftConfigSummary.jsx"; -import { LuckyGiftSimulationPanel } from "@/features/lucky-gift/components/LuckyGiftSimulationPanel.jsx"; -import { drawStatusOptions } from "@/features/lucky-gift/constants.js"; -import { useLuckyGiftPage } from "@/features/lucky-gift/hooks/useLuckyGiftPage.js"; -import styles from "@/features/lucky-gift/lucky-gift.module.css"; -import { AdminFilterResetButton, AdminFilterSelect, AdminListPage } from "@/shared/ui/AdminListLayout.jsx"; -import { Button } from "@/shared/ui/Button.jsx"; +import { useEffect } from "react"; + +export const LUCKY_GIFT_OPS_CENTER_PATH = "/ops-center/?view=configs"; + +export function redirectToLuckyGiftOpsCenter(targetLocation = window.location) { + // Ops Center 是独立 HTML 入口,React Router 的 SPA Navigate 会落回主后台兜底路由; + // 必须触发文档级导航,才能加载新版跨 App 配置页并避免旧页面调用已下线的 activity/v2 接口。 + targetLocation.replace(LUCKY_GIFT_OPS_CENTER_PATH); +} export function LuckyGiftConfigPage() { - const page = useLuckyGiftPage(); + useEffect(() => { + redirectToLuckyGiftOpsCenter(); + }, []); - return ( - - - - - - - - ); -} - -function LuckyGiftDrawSummaryPanel({ page }) { - const summary = page.drawSummary || {}; - const netSpent = Number(summary.totalSpentCoins || 0) - Number(summary.totalRewardCoins || 0); - const stats = [ - { - hint: `房间 ${formatNumber(summary.uniqueRooms)}`, - label: "参与用户", - value: formatNumber(summary.uniqueUsers), - }, - { - hint: `成功 ${formatNumber(summary.grantedDraws)} / 待发放 ${formatNumber(summary.pendingDraws)} / 失败 ${formatNumber(summary.failedDraws)}`, - label: "抽奖次数", - value: formatNumber(summary.totalDraws), - }, - { - hint: `目标 ${formatPercent(page.config?.targetRTPPercent)}`, - label: "实际 RTP", - value: formatPPM(summary.actualRTPPPM), - }, - { - hint: `净消耗 ${formatNumber(netSpent)}`, - label: "消耗金币", - value: formatNumber(summary.totalSpentCoins), - }, - { - hint: `基础 ${formatNumber(summary.baseRewardCoins)}`, - label: "返还金币", - value: formatNumber(summary.totalRewardCoins), - }, - { - hint: `房间 ${formatNumber(summary.roomAtmosphereRewardCoins)} / 活动 ${formatNumber(summary.activitySubsidyCoins)}`, - label: "额外预算", - value: formatNumber( - Number(summary.roomAtmosphereRewardCoins || 0) + Number(summary.activitySubsidyCoins || 0), - ), - }, - ]; - - return ( -
-
-
-

抽奖数据概览

- 奖池 {summary.poolId || page.poolId} -
- -
-
- - page.changeGiftId(event.target.value)} - /> - page.changeUserId(event.target.value)} - /> - page.changeRoomId(event.target.value)} - /> - - -
- {page.drawSummaryError ? ( -
- {page.drawSummaryError} - -
- ) : ( -
- {stats.map((item) => ( -
- {item.label} - {page.drawSummaryLoading ? "-" : item.value} - {page.drawSummaryLoading ? "" : item.hint} -
- ))} -
- )} -
- ); -} - -function AddPoolDialog({ disabled, onChange, onClose, onSubmit, open, value }) { - return ( - -
- 添加奖池 - - onChange(event.target.value)} - /> - - - - - -
-
- ); -} - -function formatPPM(value) { - return `${(Number(value || 0) / 10_000).toFixed(2).replace(/\.?0+$/, "")}%`; -} - -function formatPercent(value) { - return `${Number(value || 0) - .toFixed(2) - .replace(/\.?0+$/, "")}%`; -} - -function formatNumber(value) { - return new Intl.NumberFormat("zh-CN").format(Number(value || 0)); + // 文档跳转通常立即完成;保留可读状态,供慢网络和辅助技术明确当前动作。 + return

正在打开新版幸运礼物配置…

; } diff --git a/src/features/lucky-gift/pages/LuckyGiftConfigPage.test.jsx b/src/features/lucky-gift/pages/LuckyGiftConfigPage.test.jsx new file mode 100644 index 0000000..79bd5c2 --- /dev/null +++ b/src/features/lucky-gift/pages/LuckyGiftConfigPage.test.jsx @@ -0,0 +1,14 @@ +import { expect, test, vi } from "vitest"; +import { + LUCKY_GIFT_OPS_CENTER_PATH, + redirectToLuckyGiftOpsCenter, +} from "@/features/lucky-gift/pages/LuckyGiftConfigPage.jsx"; + +test("moves the legacy lucky gift route to the ops-center document entry", () => { + const targetLocation = { replace: vi.fn() }; + + redirectToLuckyGiftOpsCenter(targetLocation); + + expect(targetLocation.replace).toHaveBeenCalledWith(LUCKY_GIFT_OPS_CENTER_PATH); + expect(LUCKY_GIFT_OPS_CENTER_PATH).toBe("/ops-center/?view=configs"); +}); diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index 7125e62..2fdeb39 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -233,6 +233,7 @@ export const API_OPERATIONS = { listLoginLogs: "listLoginLogs", listLuckyGiftConfigs: "listLuckyGiftConfigs", listLuckyGiftDraws: "listLuckyGiftDraws", + listLuckyGiftPoolBalances: "listLuckyGiftPoolBalances", listManagers: "listManagers", listOperationLogs: "listOperationLogs", listPermissions: "listPermissions", @@ -1329,14 +1330,14 @@ export const API_ENDPOINTS: Record = { getLuckyGiftConfig: { method: "GET", operationId: API_OPERATIONS.getLuckyGiftConfig, - path: "/v1/admin/activity/lucky-gifts/v2/config", + path: "/v1/admin/ops-center/lucky-gifts/config", permission: "lucky-gift:view", permissions: ["lucky-gift:view"] }, getLuckyGiftDrawSummary: { method: "GET", operationId: API_OPERATIONS.getLuckyGiftDrawSummary, - path: "/v1/admin/activity/lucky-gifts/v2/draw-summary", + path: "/v1/admin/ops-center/lucky-gifts/summary", permission: "lucky-gift:view", permissions: ["lucky-gift:view"] }, @@ -1936,14 +1937,21 @@ export const API_ENDPOINTS: Record = { listLuckyGiftConfigs: { method: "GET", operationId: API_OPERATIONS.listLuckyGiftConfigs, - path: "/v1/admin/activity/lucky-gifts/v2/configs", + path: "/v1/admin/ops-center/lucky-gifts/configs", permission: "lucky-gift:view", permissions: ["lucky-gift:view"] }, listLuckyGiftDraws: { method: "GET", operationId: API_OPERATIONS.listLuckyGiftDraws, - path: "/v1/admin/activity/lucky-gifts/v2/draws", + path: "/v1/admin/ops-center/lucky-gifts/draws", + permission: "lucky-gift:view", + permissions: ["lucky-gift:view"] + }, + listLuckyGiftPoolBalances: { + method: "GET", + operationId: API_OPERATIONS.listLuckyGiftPoolBalances, + path: "/v1/admin/ops-center/lucky-gifts/pools", permission: "lucky-gift:view", permissions: ["lucky-gift:view"] }, @@ -3098,7 +3106,7 @@ export const API_ENDPOINTS: Record = { upsertLuckyGiftConfig: { method: "PUT", operationId: API_OPERATIONS.upsertLuckyGiftConfig, - path: "/v1/admin/activity/lucky-gifts/v2/config", + path: "/v1/admin/ops-center/lucky-gifts/config", permission: "lucky-gift:update", permissions: ["lucky-gift:update"] }, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 401af3d..d979218 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -244,7 +244,7 @@ export interface paths { patch?: never; trace?: never; }; - "/admin/activity/lucky-gifts/v2/config": { + "/admin/ops-center/lucky-gifts/config": { parameters: { query?: never; header?: never; @@ -260,7 +260,7 @@ export interface paths { patch?: never; trace?: never; }; - "/admin/activity/lucky-gifts/v2/configs": { + "/admin/ops-center/lucky-gifts/configs": { parameters: { query?: never; header?: never; @@ -276,7 +276,7 @@ export interface paths { patch?: never; trace?: never; }; - "/admin/activity/lucky-gifts/v2/draws": { + "/admin/ops-center/lucky-gifts/draws": { parameters: { query?: never; header?: never; @@ -292,7 +292,7 @@ export interface paths { patch?: never; trace?: never; }; - "/admin/activity/lucky-gifts/v2/draw-summary": { + "/admin/ops-center/lucky-gifts/summary": { parameters: { query?: never; header?: never; @@ -308,6 +308,22 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/ops-center/lucky-gifts/pools": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listLuckyGiftPoolBalances"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/admin/activity/wheel/config": { parameters: { query?: never; @@ -8210,6 +8226,18 @@ export interface operations { 200: components["responses"]["EmptyResponse"]; }; }; + listLuckyGiftPoolBalances: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; getWheelConfig: { parameters: { query?: never; From d6cafb98b1a2a043252e6e821fa5932eb45e9a14 Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 15 Jul 2026 19:34:11 +0800 Subject: [PATCH 08/15] feat(admin): enhance resource and management workflows --- contracts/admin-openapi.json | 85 +++ databi/src/DatabiApp.test.jsx | 105 ++++ databi/src/api.test.js | 18 + databi/src/social/SocialBiApp.jsx | 80 +++ databi/src/social/state.js | 11 +- databi/src/social/useSocialBiData.js | 11 +- databi/src/social/views/TeamKpiView.jsx | 65 +- databi/src/social/views/team-kpi-view.css | 55 +- databi/src/styles/social-v2.css | 19 + external-admin/index.html | 3 +- external-admin/src/i18n/emotionCache.js | 8 +- external-admin/src/i18n/emotionCache.test.js | 19 +- external-admin/src/i18n/messages.js | 37 +- .../src/layout/ExternalAdminLayout.jsx | 115 +++- .../src/layout/ExternalAdminLayout.test.jsx | 48 +- external-admin/src/main.jsx | 9 +- external-admin/src/pages/BannersPage.jsx | 2 +- external-admin/src/pages/BansPage.jsx | 2 +- .../src/pages/ChangePasswordPage.jsx | 2 +- external-admin/src/pages/GrantsPage.jsx | 4 +- external-admin/src/pages/LoginPage.jsx | 2 +- external-admin/src/pages/LoginPage.test.jsx | 4 +- external-admin/src/pages/OrganizationPage.jsx | 2 +- external-admin/src/pages/OverviewPage.jsx | 68 +- external-admin/src/pages/RoomsPage.jsx | 2 +- external-admin/src/pages/UsersPage.jsx | 4 +- external-admin/src/shared/PageComponents.jsx | 9 +- external-admin/src/styles/index.css | 588 ++++++++++++++++-- external-admin/src/theme.js | 69 ++ src/features/app-users/api.test.ts | 33 + src/features/app-users/api.ts | 23 + src/features/app-users/app-users.module.css | 7 +- .../components/AppUserDetailProvider.jsx | 4 +- .../components/AppUserDetailProvider.test.jsx | 89 ++- .../components/AppUserDetailView.jsx | 90 ++- .../app-users/pages/AppUserDetailPage.jsx | 2 +- .../pages/AppUserDetailPage.test.jsx | 38 +- src/features/resources/api.test.ts | 3 +- src/features/resources/api.ts | 1 + .../resources/hooks/useResourcePages.js | 233 +++++-- .../resources/hooks/useResourcePages.test.js | 43 ++ src/features/resources/mp4AlphaLayout.js | 6 +- .../resources/pages/ResourceListPage.jsx | 69 +- .../resources/pages/ResourceShopPage.jsx | 136 ++-- src/features/resources/resources.module.css | 42 +- src/features/resources/schema.js | 45 +- src/features/resources/schema.test.ts | 34 +- src/shared/api/generated/endpoints.ts | 8 + src/shared/api/generated/schema.d.ts | 57 +- src/shared/ui/Mp4Preview.jsx | 462 ++++++++++++++ src/shared/ui/Mp4Preview.module.css | 88 +++ src/shared/ui/UploadField.jsx | 75 ++- src/shared/ui/UploadField.module.css | 8 +- 53 files changed, 2654 insertions(+), 388 deletions(-) create mode 100644 external-admin/src/theme.js create mode 100644 src/shared/ui/Mp4Preview.jsx create mode 100644 src/shared/ui/Mp4Preview.module.css diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index 96afce1..38e8beb 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -5018,6 +5018,47 @@ "x-permissions": ["resource-grant:revoke"] } }, + "/admin/users/{user_id}/resources/{entitlement_id}/revoke": { + "post": { + "operationId": "revokeAppUserResource", + "parameters": [ + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { "type": "string" } + }, + { + "in": "path", + "name": "entitlement_id", + "required": true, + "schema": { "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["reason"], + "additionalProperties": false, + "properties": { + "reason": { "type": "string", "minLength": 1, "maxLength": 256 } + } + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "resource-grant:revoke", + "x-permissions": ["resource-grant:revoke"] + } + }, "/admin/resource-grants/resource": { "post": { "operationId": "grantResource", @@ -5055,6 +5096,50 @@ }, "post": { "operationId": "upsertResourceShopItems", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["items"], + "additionalProperties": false, + "properties": { + "items": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "durationDays", + "effectiveFromMs", + "effectiveToMs", + "resourceId", + "sortOrder", + "status" + ], + "additionalProperties": false, + "properties": { + "coinPrice": { + "type": "integer", + "minimum": 0, + "description": "售卖规格金币价;新建时省略或传 0 使用资源列表价格,更新已有规格时省略或传 0 保留当前售价" + }, + "durationDays": { "type": "integer", "enum": [1, 3, 7] }, + "effectiveFromMs": { "type": "integer", "minimum": 0 }, + "effectiveToMs": { "type": "integer", "minimum": 0 }, + "resourceId": { "type": "integer", "minimum": 1 }, + "shopItemId": { "type": "integer", "minimum": 1 }, + "sortOrder": { "type": "integer", "minimum": 0 }, + "status": { "type": "string", "enum": ["active", "disabled"] } + } + } + } + } + } + } + } + }, "responses": { "200": { "$ref": "#/components/responses/EmptyResponse" diff --git a/databi/src/DatabiApp.test.jsx b/databi/src/DatabiApp.test.jsx index 60a7131..6373b28 100644 --- a/databi/src/DatabiApp.test.jsx +++ b/databi/src/DatabiApp.test.jsx @@ -369,6 +369,111 @@ test("routes databi social page to Social BI and loads real overview rows", asyn expect(screen.getAllByText("$900").length).toBeGreaterThan(0); }); +test("filters operation KPI by person and shows totals with App and region amounts", async () => { + window.history.pushState(null, "", "/databi/social/?view=team&operator=9"); + fetchSocialBiMaster.mockResolvedValue({ + access: { all: true, scopes: [] }, + apps: [ + { app_code: "aslan", app_name: "Aslan", kind: "legacy" }, + { app_code: "yumi", app_name: "Yumi", kind: "legacy" } + ], + operators: [ + { + account: "omar", + name: "Omar", + scopes: [{ app_code: "aslan", region_id: 11 }, { app_code: "yumi", region_id: 20 }], + team: "中东运营部", + team_id: 2, + user_id: 9 + } + ], + permissions: {}, + regions: [ + { app_code: "aslan", region_id: 11, region_name: "中东大区" }, + { app_code: "yumi", region_id: 20, region_name: "南亚大区" } + ] + }); + fetchSocialBiOverview.mockResolvedValue({ access: { all: true, scopes: [] }, apps: [] }); + fetchSocialBiKpi.mockResolvedValue({ + items: [ + { + app_code: "aslan", + app_name: "Aslan", + month_recharge_usd_minor: 90_000, + operator_account: "omar", + operator_name: "Omar", + operator_user_id: 9, + range_recharge_usd_minor: 5000, + region_id: 11, + region_name: "中东大区", + team: "中东运营部" + }, + { + app_code: "yumi", + app_name: "Yumi", + month_recharge_usd_minor: 50_000, + operator_account: "omar", + operator_name: "Omar", + operator_user_id: 9, + range_recharge_usd_minor: 3000, + region_id: 20, + region_name: "南亚大区", + team: "中东运营部" + } + ], + operator_app_rows: [ + { + app_code: "aslan", + app_name: "Aslan", + month_recharge_usd_minor: 90_000, + operator_account: "omar", + operator_name: "Omar", + operator_user_id: 9, + range_recharge_usd_minor: 5000, + region_count: 1, + region_names: "中东大区", + team: "中东运营部" + }, + { + app_code: "yumi", + app_name: "Yumi", + month_recharge_usd_minor: 50_000, + operator_account: "omar", + operator_name: "Omar", + operator_user_id: 9, + range_recharge_usd_minor: 3000, + region_count: 1, + region_names: "南亚大区", + team: "中东运营部" + } + ], + period_month: "2026-06", + summary: { month_recharge_usd_minor: 140_000, operator_count: 1, range_recharge_usd_minor: 8000 } + }); + + render(); + await flushEffects(); + + expect(fetchSocialBiKpi).toHaveBeenCalledWith(expect.objectContaining({ operatorUserId: 9 })); + expect(screen.getByRole("combobox", { name: "运营人员筛选" })).toHaveValue("Omar"); + expect(window.location.search).toContain("operator=9"); + const summary = screen.getByLabelText("充值金额汇总"); + expect(within(summary).getByText("Omar")).toBeTruthy(); + expect(within(summary).getByText("$80")).toBeTruthy(); + expect(within(summary).getByText("$1,400")).toBeTruthy(); + expect(screen.getByRole("cell", { name: "中东大区" })).toBeTruthy(); + expect(screen.getByRole("cell", { name: "南亚大区" })).toBeTruthy(); + + await act(async () => { + screen.getByRole("radio", { name: "按人员×App" }).click(); + }); + + expect(screen.getByRole("cell", { name: "中东大区" })).toBeTruthy(); + expect(screen.getByRole("cell", { name: "南亚大区" })).toBeTruthy(); + expect(screen.getAllByText("$50").length).toBeGreaterThan(0); + expect(screen.getAllByText("$30").length).toBeGreaterThan(0); +}); + test("renders social BI data requirements table with section filters and CSV export", async () => { window.history.pushState(null, "", "/databi/social/?view=table®ions=lalu:9,lalu:11"); fetchSocialBiMaster.mockResolvedValue({ diff --git a/databi/src/api.test.js b/databi/src/api.test.js index 62d7beb..3f3fffb 100644 --- a/databi/src/api.test.js +++ b/databi/src/api.test.js @@ -2,6 +2,7 @@ import { afterEach, expect, test, vi } from "vitest"; import { fetchFilterOptions, fetchSocialBiFilterOptions, + fetchSocialBiKpi, fetchSocialBiOverview, fetchSocialBiRequirements, fetchStatisticsOverview, @@ -138,6 +139,23 @@ test("loads social BI overview with an explicit multi-region Finance scope", asy expect(requestURL.searchParams.get("region_ids")).toBe("9,11"); }); +test("loads social BI KPI for the selected operation user", async () => { + vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ items: [], summary: {} }))); + + await fetchSocialBiKpi({ + appCodes: ["aslan", "yumi"], + endMs: 20, + operatorUserId: 9, + startMs: 10, + statTz: "Asia/Shanghai" + }); + + const requestURL = new URL(String(fetch.mock.calls[0][0])); + expect(requestURL.pathname).toBe("/api/v1/admin/databi/social/kpi"); + expect(requestURL.searchParams.get("app_codes")).toBe("aslan,yumi"); + expect(requestURL.searchParams.get("operator_user_id")).toBe("9"); +}); + test("omits app scope headers and query params for all databi apps", async () => { vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ updated_at_ms: 1 }))); diff --git a/databi/src/social/SocialBiApp.jsx b/databi/src/social/SocialBiApp.jsx index 04a7de3..1f79363 100644 --- a/databi/src/social/SocialBiApp.jsx +++ b/databi/src/social/SocialBiApp.jsx @@ -9,6 +9,8 @@ import PublicOutlined from "@mui/icons-material/PublicOutlined"; import RefreshOutlined from "@mui/icons-material/RefreshOutlined"; import RepeatOutlined from "@mui/icons-material/RepeatOutlined"; import TableChartOutlined from "@mui/icons-material/TableChartOutlined"; +import Autocomplete from "@mui/material/Autocomplete"; +import TextField from "@mui/material/TextField"; import { ALL, DATE_PRESETS, @@ -40,6 +42,7 @@ const VIEWS = [ ]; const SocialBiContext = createContext(null); +const EMPTY_OPERATORS = []; export function useSocialBi() { const context = useContext(SocialBiContext); @@ -160,6 +163,7 @@ function TopBar({ data, filters, updateFilter, viewKey }) {
+ {viewKey === "team" ? : null} {rangeLabel(data.range)} {data.isLoading ? : null}
@@ -167,6 +171,82 @@ function TopBar({ data, filters, updateFilter, viewKey }) { ); } +function operatorIdentityLabel(operator) { + if (String(operator?.user_id) === ALL) { + return "全部运营人员"; + } + return operator?.name || operator?.account || `#${operator?.user_id}`; +} + +function filterOperatorOptions(options, { inputValue }) { + const keyword = String(inputValue || "").trim().toLowerCase(); + if (!keyword) { + return options; + } + return options.filter((operator) => + [operatorIdentityLabel(operator), operator.account, operator.team, operator.user_id] + .filter(Boolean) + .join(" ") + .toLowerCase() + .includes(keyword) + ); +} + +function OperatorSelect({ data, filters, updateFilter }) { + const operators = data.master?.operators || EMPTY_OPERATORS; + const options = useMemo(() => [{ name: "全部运营人员", user_id: ALL }, ...operators], [operators]); + const selected = options.find((operator) => String(operator.user_id) === String(filters.operator)) || options[0]; + + useEffect(() => { + if (!data.master || filters.operator === ALL) { + return; + } + // 人员离职或数据权限收回后,旧分享链接里的 ID 会失效;回退到全部,避免页面显示一个无法解释的空筛选。 + if (!operators.some((operator) => String(operator.user_id) === String(filters.operator))) { + updateFilter("operator", ALL); + } + }, [data.master, filters.operator, operators, updateFilter]); + + return ( + String(option.user_id) === String(value.user_id)} + noOptionsText="无匹配运营人员" + options={options} + size="small" + value={selected} + onChange={(_, operator) => updateFilter("operator", String(operator?.user_id || ALL))} + renderOption={(props, operator) => { + const { key, ...optionProps } = props; + const details = [operator.account, operator.team].filter(Boolean).join(" · "); + return ( +
  • + {operatorIdentityLabel(operator)} + {details ? {details} : null} +
  • + ); + }} + renderInput={(params) => ( + + )} + /> + ); +} + function DatePresetControl({ filters, updateFilter }) { return (
    diff --git a/databi/src/social/state.js b/databi/src/social/state.js index bb41257..efdc835 100644 --- a/databi/src/social/state.js +++ b/databi/src/social/state.js @@ -1,4 +1,4 @@ -// 社交 BI v2 的全局筛选状态:日期预设/自定义区间、App 多选、区域多选、趋势粒度, +// 社交 BI v2 的全局筛选状态:日期预设/自定义区间、App 多选、区域多选、运营人员、趋势粒度, // 并同步到 URL query,保证筛选状态可以直接作为链接分享。 import { lastDaysRange, thisMonthRange, todayRange } from "../utils/time.js"; @@ -27,6 +27,7 @@ export function createDefaultFilters() { customEnd: "", customStart: "", granularity: "day", + operator: ALL, preset: "yesterday", regions: [ALL] }; @@ -116,7 +117,7 @@ export function bucketDay(statDay, granularity) { return statDay; } -const URL_KEYS = ["preset", "start", "end", "apps", "regions", "view", "granularity"]; +const URL_KEYS = ["preset", "start", "end", "apps", "regions", "operator", "view", "granularity"]; export function readStateFromURL(location = window.location) { const params = new URLSearchParams(location.search); @@ -133,6 +134,9 @@ export function readStateFromURL(location = window.location) { if (params.get("regions")) { filters.regions = params.get("regions").split(",").map((item) => item.trim()).filter(Boolean); } + if (/^\d+$/.test(params.get("operator") || "")) { + filters.operator = params.get("operator"); + } if (params.get("granularity") && GRANULARITIES.some((item) => item.key === params.get("granularity"))) { filters.granularity = params.get("granularity"); } @@ -168,6 +172,9 @@ export function writeStateToURL(filters, view, history = window.history, locatio if (filters.regions.length && !filters.regions.includes(ALL)) { params.set("regions", filters.regions.join(",")); } + if (filters.operator !== ALL) { + params.set("operator", filters.operator); + } if (filters.granularity !== "day") { params.set("granularity", filters.granularity); } diff --git a/databi/src/social/useSocialBiData.js b/databi/src/social/useSocialBiData.js index 07d68bb..c188285 100644 --- a/databi/src/social/useSocialBiData.js +++ b/databi/src/social/useSocialBiData.js @@ -69,6 +69,11 @@ export function useSocialBiData(filters, { includeFunnel = false, includeKpi = f const selected = filters.apps.filter((appCode) => available.includes(appCode)); return selected.length ? selected : available; }, [filters.apps, master]); + // 运营人员筛选只接受 master 目录中的正整数 ID;URL 中的脏值不能直接进入后端统计查询。 + const operatorUserId = useMemo(() => { + const value = String(filters.operator || ""); + return /^\d+$/.test(value) ? Number(value) : undefined; + }, [filters.operator]); const overviewRequestScopes = useMemo( () => buildOverviewRequestScopes(appCodes, filters.regions, master?.regions || []), [appCodes, filters.regions, master?.regions] @@ -87,7 +92,9 @@ export function useSocialBiData(filters, { includeFunnel = false, includeKpi = f ? fetchSocialBiFunnel({ appCodes: funnelAppCodes, endMs, startMs, statTz: SOCIAL_BI_TZ }) : Promise.resolve(null); // KPI 会执行当前区间和自然月两组负责范围聚合,只在运营中心可见时请求,避免其他视图承担额外查询成本。 - const kpiRequest = includeKpi ? fetchSocialBiKpi({ appCodes, endMs, startMs, statTz: SOCIAL_BI_TZ }) : Promise.resolve(null); + const kpiRequest = includeKpi + ? fetchSocialBiKpi({ appCodes, endMs, operatorUserId, startMs, statTz: SOCIAL_BI_TZ }) + : Promise.resolve(null); // 默认全量只请求一次;用户显式选择区域时按 App 下发已选区域,让后端只补这些区域的 Finance 逐日渠道。 const overviewRequest = overviewRequestScopes.length ? Promise.all( @@ -144,7 +151,7 @@ export function useSocialBiData(filters, { includeFunnel = false, includeKpi = f setErrors(nextErrors); setIsLoading(false); }); - }, [appCodes, funnelAppCodes, includeFunnel, includeKpi, isMasterSettled, master?.access, overviewRequestScopes, range, refreshToken]); + }, [appCodes, funnelAppCodes, includeFunnel, includeKpi, isMasterSettled, master?.access, operatorUserId, overviewRequestScopes, range, refreshToken]); const loadRequirements = useCallback( ({ newUserType, payerType, section, userRole } = {}) => { diff --git a/databi/src/social/views/TeamKpiView.jsx b/databi/src/social/views/TeamKpiView.jsx index 348d81a..fa3edd9 100644 --- a/databi/src/social/views/TeamKpiView.jsx +++ b/databi/src/social/views/TeamKpiView.jsx @@ -4,6 +4,7 @@ import { useMemo, useState } from "react"; import { SocialAppIdentity, useSocialBi } from "../SocialBiApp.jsx"; import { formatCount, formatMoneyMinor, isBlank } from "../format.js"; +import { ALL } from "../state.js"; import "./team-kpi-view.css"; const DIMENSIONS = [ @@ -40,7 +41,7 @@ function operatorCountOf(items) { } export function TeamKpiView() { - const { isLoading, kpi } = useSocialBi(); + const { filters, isLoading, kpi, master } = useSocialBi(); const [dimension, setDimension] = useState("person"); const items = useMemo(() => kpi?.items || [], [kpi]); @@ -88,21 +89,15 @@ export function TeamKpiView() { return rows; }, [items]); - if (!items.length) { - return isLoading ? ( - - ) : ( -
    -
    - 暂无充值数据 - 先在用户管理为运营人员分配 App/区域数据范围 -
    -
    - ); + if (isLoading && !kpi) { + return ; } - const summary = kpi.summary || {}; + const summary = kpi?.summary || {}; const operatorCount = isBlank(summary.operator_count) ? operatorCountOf(items) : summary.operator_count; + const selectedOperator = filters.operator === ALL + ? null + : (master?.operators || []).find((operator) => String(operator.user_id) === String(filters.operator)); return (
    @@ -110,7 +105,7 @@ export function TeamKpiView() {
    充值排行 - {kpi.period_month} · {formatCount(operatorCount)} 名运营 + {kpi?.period_month || "--"} · {formatCount(operatorCount)} 名运营
    @@ -129,6 +124,7 @@ export function TeamKpiView() {
    +
    {dimension === "person" ? ( @@ -143,6 +139,36 @@ export function TeamKpiView() { ); } +function KpiSummary({ operator, operatorCount, summary }) { + const details = operator + ? [operator.account, operator.team].filter(Boolean).join(" · ") + : `${formatCount(operatorCount)} 名运营`; + return ( +
    +
    + {operator ? "当前人员" : "当前范围"} + {operator ? operatorIdentity(operator) : "全部运营人员"} + {details} +
    + + +
    + ); +} + +function operatorIdentity(operator) { + return operator?.name || operator?.account || `#${operator?.user_id}`; +} + +function SummaryAmount({ label, value }) { + return ( +
    + {label} + {formatMoneyMinor(value)} +
    + ); +} + function KpiSkeleton() { return (
    @@ -184,6 +210,7 @@ function OperatorAppTable({ rows }) { + {!rows.length ? : null} {rows.map((row, index) => { const item = row.item; return ( @@ -230,6 +257,7 @@ function PersonTable({ rows }) { + {!rows.length ? : null} {rows.map((row, index) => { const item = row.item; return ( @@ -279,6 +307,7 @@ function TeamTable({ rows }) { + {!rows.length ? : null} {rows.map((row, index) => ( @@ -295,6 +324,14 @@ function TeamTable({ rows }) { ); } +function EmptyTableRow({ colSpan }) { + return ( + + 当前无数据 + + ); +} + function OperatorCell({ item }) { return (
    diff --git a/databi/src/social/views/team-kpi-view.css b/databi/src/social/views/team-kpi-view.css index be703f6..448bb69 100644 --- a/databi/src/social/views/team-kpi-view.css +++ b/databi/src/social/views/team-kpi-view.css @@ -8,10 +8,57 @@ .sbi-kpi-board-scroll { max-height: 620px; - margin-top: 12px; border-radius: 0 0 var(--sbi-radius) var(--sbi-radius); } +.sbi-kpi-summary { + display: grid; + grid-template-columns: minmax(220px, 1fr) repeat(2, minmax(170px, 220px)); + margin-top: 12px; + border-top: 1px solid var(--sbi-border); + border-bottom: 1px solid var(--sbi-border); + background: #f7fafd; +} + +.sbi-kpi-summary-person, +.sbi-kpi-summary-amount { + display: grid; + gap: 3px; + min-width: 0; + padding: 14px 18px; +} + +.sbi-kpi-summary-amount { + align-content: center; + border-left: 1px solid var(--sbi-border); + text-align: right; +} + +.sbi-kpi-summary span, +.sbi-kpi-summary small { + color: var(--sbi-text-3); + font-size: 11.5px; +} + +.sbi-kpi-summary-person strong { + overflow: hidden; + font-size: 14px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sbi-kpi-summary-amount strong { + color: var(--sbi-accent); + font-size: 22px; + font-variant-numeric: tabular-nums; +} + +.sbi-table td.sbi-kpi-empty-cell { + height: 120px; + color: var(--sbi-text-3); + text-align: center; +} + .sbi-kpi-rank { display: inline-grid; width: 24px; @@ -67,3 +114,9 @@ gap: 14px; padding: 18px; } + +@media (max-width: 1280px) { + .sbi-kpi-summary { + grid-template-columns: minmax(180px, 1fr) repeat(2, minmax(150px, 190px)); + } +} diff --git a/databi/src/styles/social-v2.css b/databi/src/styles/social-v2.css index ff19fdc..05219de 100644 --- a/databi/src/styles/social-v2.css +++ b/databi/src/styles/social-v2.css @@ -344,6 +344,25 @@ font-size: 12.5px; } +.sbi-operator-filter { + width: 210px; +} + +.sbi-operator-option { + display: grid !important; + gap: 2px; +} + +.sbi-operator-option strong { + color: var(--sbi-text); + font-size: 13px; +} + +.sbi-operator-option small { + color: var(--sbi-text-3); + font-size: 11.5px; +} + .sbi-range-label { color: var(--sbi-text-3); font-size: 12.5px; diff --git a/external-admin/index.html b/external-admin/index.html index 61d85dd..8c5c043 100644 --- a/external-admin/index.html +++ b/external-admin/index.html @@ -3,8 +3,9 @@ - + External Admin +
    diff --git a/external-admin/src/i18n/emotionCache.js b/external-admin/src/i18n/emotionCache.js index 7f86a56..cb5ada7 100644 --- a/external-admin/src/i18n/emotionCache.js +++ b/external-admin/src/i18n/emotionCache.js @@ -1,9 +1,13 @@ import createCache from "@emotion/cache"; import rtlPlugin from "@mui/stylis-plugin-rtl"; -import { prefixer } from "stylis"; +// Emotion compiles styles with its own bundled stylis (4.2); running the standalone +// stylis 4.4 `prefixer` over those foreign AST nodes crashes in lift() on +// ::placeholder/:read-only rules because 4.2 elements carry no `siblings` field. +// The RTL cache therefore only runs the direction-flipping plugin; vendor prefixing +// is left out, which modern mobile browsers no longer need. const externalLtrCache = createCache({ key: "external-ltr", prepend: true }); -const externalRtlCache = createCache({ key: "external-rtl", prepend: true, stylisPlugins: [prefixer, rtlPlugin] }); +const externalRtlCache = createCache({ key: "external-rtl", prepend: true, stylisPlugins: [rtlPlugin] }); export function externalEmotionCache(direction) { return direction === "rtl" ? externalRtlCache : externalLtrCache; diff --git a/external-admin/src/i18n/emotionCache.test.js b/external-admin/src/i18n/emotionCache.test.js index 62592e8..e861ab8 100644 --- a/external-admin/src/i18n/emotionCache.test.js +++ b/external-admin/src/i18n/emotionCache.test.js @@ -1,5 +1,9 @@ +import { CacheProvider } from "@emotion/react"; +import styled from "@emotion/styled"; import rtlPlugin from "@mui/stylis-plugin-rtl"; -import { compile, middleware, prefixer, serialize, stringify } from "stylis"; +import { render } from "@testing-library/react"; +import { createElement } from "react"; +import { compile, middleware, serialize, stringify } from "stylis"; import { describe, expect, test } from "vitest"; import { externalEmotionCache } from "./emotionCache.js"; @@ -13,7 +17,7 @@ describe("external admin RTL Emotion cache", () => { test("mirrors physical MUI-style declarations for Arabic", () => { const css = serialize( compile(".control{margin-left:8px;padding-right:12px;text-align:left}"), - middleware([prefixer, rtlPlugin, stringify]) + middleware([rtlPlugin, stringify]) ); expect(css).toContain("margin-right:8px"); @@ -21,4 +25,15 @@ describe("external admin RTL Emotion cache", () => { expect(css).toContain("text-align:right"); expect(css).not.toContain("margin-left:8px"); }); + + test("inserts ::placeholder rules through the RTL cache without crashing", () => { + // Regression: the standalone stylis 4.4 prefixer crashed in lift() when Emotion's + // bundled stylis 4.2 compiled a ::placeholder rule, unmounting the whole app in Arabic. + const Input = styled.input({ "&::placeholder": { color: "red", textAlign: "left" } }); + + expect(() => render( + createElement(CacheProvider, { value: externalEmotionCache("rtl") }, + createElement(Input, { placeholder: "مثال" })) + )).not.toThrow(); + }); }); diff --git a/external-admin/src/i18n/messages.js b/external-admin/src/i18n/messages.js index 71b14ec..c420b8d 100644 --- a/external-admin/src/i18n/messages.js +++ b/external-admin/src/i18n/messages.js @@ -35,9 +35,9 @@ const en = { "common.save": "Save", "common.status": "Status", "common.user": "User", - "auth.account": "External admin account", - "auth.changePassword": "Change external admin password", - "auth.checkingSession": "Checking external admin session", + "auth.account": "Account", + "auth.changePassword": "Change password", + "auth.checkingSession": "Checking your session", "auth.confirmNewPassword": "Confirm new password", "auth.currentPassword": "Current password", "auth.fillAccountPassword": "Enter your account and password", @@ -48,7 +48,7 @@ const en = { "auth.loginFailed": "Sign-in failed", "auth.logout": "Sign out", "auth.newPassword": "New password", - "auth.noPermission": "This external admin account does not have access to this feature.", + "auth.noPermission": "This account does not have access to this feature.", "auth.password": "Password", "auth.passwordChangedFailed": "Failed to change password", "auth.passwordMismatch": "The new passwords do not match", @@ -56,12 +56,13 @@ const en = { "auth.passwordMinError": "The new password must be at least 8 characters", "auth.passwordWhitespace": "The new password cannot be empty or contain only whitespace", "auth.recheck": "Check again", - "auth.returnOverview": "Return to permission overview", + "auth.returnOverview": "Back to Home", "auth.savePassword": "Save password", "auth.sessionFailed": "Session verification failed", "auth.showPassword": "Show password", "nav.expand": "Open navigation", - "nav.overview": "Permission overview", + "nav.more": "More", + "nav.overview": "Home", "nav.users": "User management", "nav.bans": "Ban management", "nav.hosts": "Hosts", @@ -94,8 +95,10 @@ const en = { "capability.userLevelGrant": "Grant wealth/VIP levels", "capability.teamView": "My team", "overview.currentApp": "Current App", - "overview.account": "External admin account", + "overview.account": "Account", "overview.permissions": "Permissions enabled", + "overview.welcome": "Welcome back", + "overview.features": "Features", "overview.enabled": "Enabled", "overview.notEnabled": "Not enabled", "users.title": "User management", @@ -325,13 +328,13 @@ const zhCN = { "app.shortTitle": "外管后台", "language.label": "语言", "common.actions": "操作", "common.active": "正常", "common.all": "全部", "common.allStatuses": "全部状态", "common.banned": "已封禁", "common.cancel": "取消", "common.confirm": "确认", "common.create": "创建", "common.disabled": "禁用", "common.edit": "编辑", "common.enabled": "启用", "common.expired": "过期", "common.loading": "正在加载", "common.noData": "当前无数据", "common.normal": "正常", "common.query": "查询", "common.reason": "原因", "common.reload": "重新加载", "common.retry": "重试", "common.save": "保存", "common.status": "状态", "common.user": "用户", - "auth.account": "外管账号", "auth.changePassword": "修改外管密码", "auth.checkingSession": "正在校验外管会话", "auth.confirmNewPassword": "确认新密码", "auth.currentPassword": "当前密码", "auth.fillAccountPassword": "请填写账号和密码", "auth.firstLoginChange": "首次登录必须修改密码", "auth.hidePassword": "隐藏密码", "auth.invalidCredentials": "账号或密码错误", "auth.login": "登录", "auth.loginFailed": "登录失败", "auth.logout": "退出登录", "auth.newPassword": "新密码", "auth.noPermission": "当前外管账号没有访问此功能的权限。", "auth.password": "密码", "auth.passwordChangedFailed": "密码修改失败", "auth.passwordMismatch": "两次输入的新密码不一致", "auth.passwordMin": "至少 8 个字符", "auth.passwordMinError": "新密码至少 8 个字符", "auth.passwordWhitespace": "新密码不能为空或全为空白字符", "auth.recheck": "重新校验", "auth.returnOverview": "返回权限总览", "auth.savePassword": "保存密码", "auth.sessionFailed": "会话校验失败", "auth.showPassword": "显示密码", - "nav.expand": "展开菜单", "nav.overview": "权限总览", "nav.users": "用户管理", "nav.bans": "封禁管理", "nav.hosts": "主播列表", "nav.agencies": "公会列表", "nav.bds": "BD 列表", "nav.bdManagers": "BD Manager 列表", "nav.superAdmins": "Super Admin 列表", "nav.rooms": "房间管理", "nav.grants": "资源与靓号", "nav.banners": "Banner 管理", "nav.team": "我的团队", + "auth.account": "账号", "auth.changePassword": "修改密码", "auth.checkingSession": "正在校验会话", "auth.confirmNewPassword": "确认新密码", "auth.currentPassword": "当前密码", "auth.fillAccountPassword": "请填写账号和密码", "auth.firstLoginChange": "首次登录必须修改密码", "auth.hidePassword": "隐藏密码", "auth.invalidCredentials": "账号或密码错误", "auth.login": "登录", "auth.loginFailed": "登录失败", "auth.logout": "退出登录", "auth.newPassword": "新密码", "auth.noPermission": "当前账号没有访问此功能的权限。", "auth.password": "密码", "auth.passwordChangedFailed": "密码修改失败", "auth.passwordMismatch": "两次输入的新密码不一致", "auth.passwordMin": "至少 8 个字符", "auth.passwordMinError": "新密码至少 8 个字符", "auth.passwordWhitespace": "新密码不能为空或全为空白字符", "auth.recheck": "重新校验", "auth.returnOverview": "返回首页", "auth.savePassword": "保存密码", "auth.sessionFailed": "会话校验失败", "auth.showPassword": "显示密码", + "nav.expand": "展开菜单", "nav.more": "更多", "nav.overview": "首页", "nav.users": "用户管理", "nav.bans": "封禁管理", "nav.hosts": "主播列表", "nav.agencies": "公会列表", "nav.bds": "BD 列表", "nav.bdManagers": "BD Manager 列表", "nav.superAdmins": "Super Admin 列表", "nav.rooms": "房间管理", "nav.grants": "资源与靓号", "nav.banners": "Banner 管理", "nav.team": "我的团队", "capability.userList": "用户列表", "capability.userUpdate": "用户信息编辑", "capability.banList": "账号封禁列表", "capability.ban": "执行封禁", "capability.unban": "解除封禁", "capability.hostList": "主播列表", "capability.agencyList": "公会列表", "capability.bdList": "BD 列表", "capability.bdManagerList": "BD Manager 列表", "capability.superAdminList": "Super Admin 列表", "capability.roomList": "房间管理", "capability.roomUpdate": "房间编辑", "capability.privilegeList": "用户特权道具列表", "capability.privilegeGrant": "特权道具发放", "capability.bannerCreate": "Banner 创建", "capability.prettyIdGrant": "靓号下发", "capability.userTitleGrant": "用户称号下发", "capability.roomBackgroundGrant": "房间背景图下发", "capability.userLevelGrant": "财富/VIP 等级下发", "capability.teamView": "我的团队", - "overview.currentApp": "当前 App", "overview.account": "外管账号", "overview.permissions": "已开通权限", "overview.enabled": "已开通", "overview.notEnabled": "未开通", + "overview.currentApp": "当前 App", "overview.account": "登录账号", "overview.permissions": "已开通权限", "overview.welcome": "欢迎回来", "overview.features": "功能入口", "overview.enabled": "已开通", "overview.notEnabled": "未开通", "users.title": "用户管理", "users.search": "用户 ID / 短 ID / 名称", "users.prettyId": "短 ID(靓号)", "users.countryGender": "国家/性别", "users.level": "等级", "users.levelSummary": "财富 {wealth} / 魅力 {charm} / VIP {vip}", "users.edit": "编辑", "users.grantLevel": "等级", "users.ban": "封禁", "users.updated": "用户信息已更新", "users.banned": "用户已封禁", "users.levelGranted": "用户等级已下发", "users.operationFailed": "用户操作失败", "users.editTitle": "编辑用户", "users.avatar": "头像", "users.name": "用户名称", "users.gender": "性别", "users.countryCode": "国家/地区码", "users.notSet": "未设置", "users.genderMale": "男", "users.genderFemale": "女", "users.genderUnknown": "未知", "users.banTitle": "封禁用户 {user}", "users.banDays": "封禁天数", "users.banForeverHint": "填 0 表示永久封禁", "users.banReason": "封禁原因", "users.levelTitle": "等级下发 {user}", "users.levelType": "等级类型", "users.wealthLevel": "财富等级", "users.charmLevel": "魅力等级", "users.vipLevel": "VIP 等级", "users.validDays": "有效天数", "users.validDaysMin": "有效期至少 1 天", "users.grantReason": "下发原因", "bans.title": "账号封禁列表", "bans.reason": "封禁原因", "bans.unban": "解除封禁", "bans.unbanned": "用户已解除封禁", "bans.unbanFailed": "解除封禁失败", "bans.confirmTitle": "解除封禁", "bans.confirmContent": "确认解除用户 {user} 的封禁?", - "organization.title": "组织管理", "organization.agencies": "公会列表", "organization.bds": "BD 列表", "organization.bdManagers": "BD Manager 列表", "organization.hosts": "主播列表", "organization.managers": "管理员列表", "organization.superAdmins": "Super Admin 列表", "organization.team": "我的团队", "organization.agency": "公会", "organization.owner": "负责人", "organization.parentBd": "上级 BD", "organization.member": "成员", "organization.country": "国家/地区", "organization.userReference": "用户 {id}", "organization.agencyReference": "公会 {id}", "organization.teamSearch": "公会 ID / 负责人用户 ID / 上级 BD ID", "organization.noPermission": "当前外管账号没有访问该组织列表的权限。", "organization.bdLeaderCount": "BD Leader:{count}", "organization.bdCount": "BD:{count}", "organization.agencyCount": "公会:{count}", "organization.truncated": "团队规模超过接口上限,当前结果不完整", + "organization.title": "组织管理", "organization.agencies": "公会列表", "organization.bds": "BD 列表", "organization.bdManagers": "BD Manager 列表", "organization.hosts": "主播列表", "organization.managers": "管理员列表", "organization.superAdmins": "Super Admin 列表", "organization.team": "我的团队", "organization.agency": "公会", "organization.owner": "负责人", "organization.parentBd": "上级 BD", "organization.member": "成员", "organization.country": "国家/地区", "organization.userReference": "用户 {id}", "organization.agencyReference": "公会 {id}", "organization.teamSearch": "公会 ID / 负责人用户 ID / 上级 BD ID", "organization.noPermission": "当前账号没有访问该组织列表的权限。", "organization.bdLeaderCount": "BD Leader:{count}", "organization.bdCount": "BD:{count}", "organization.agencyCount": "公会:{count}", "organization.truncated": "团队规模超过接口上限,当前结果不完整", "rooms.title": "房间管理", "rooms.room": "房间", "rooms.owner": "房主", "rooms.visibleRegion": "可见区域", "rooms.search": "房间 ID / 名称 / 房主", "rooms.updated": "房间信息已更新", "rooms.updateFailed": "房间更新失败", "rooms.editTitle": "编辑房间", "rooms.background": "房间背景图", "rooms.name": "房间名称", "rooms.description": "房间介绍", "rooms.visibleRegionId": "可见区域 ID", "rooms.allRegions": "全部", "grants.title": "资源与靓号", "grants.grantResource": "发放道具", "grants.grantPrettyId": "下发靓号", "grants.availableResources": "可发放资源", "grants.targetUser": "目标用户", "grants.resource": "资源", "grants.quantity": "数量", "grants.resourceGranted": "特权道具已发放", "grants.prettyIdGranted": "靓号已下发", "grants.failed": "发放失败", "grants.resourceTitle": "特权道具发放", "grants.prettyIdTitle": "靓号下发", "grants.target": "用户 ID / 短 ID(靓号)", "grants.currentTarget": "用户 ID / 当前短 ID", "grants.prettyId": "下发靓号", "grants.validDays": "有效天数", "grants.permanentHint": "填 0 表示永久有效", "grants.reason": "发放原因", "grants.confirm": "确认发放", "banners.title": "Banner 管理", "banners.banner": "Banner", "banners.type": "类型", "banners.platform": "平台", "banners.sort": "排序", "banners.create": "创建 Banner", "banners.created": "Banner 已创建", "banners.createFailed": "Banner 创建失败", "banners.image": "Banner 图片", "banners.roomImage": "房间内小屏图", "banners.description": "描述", "banners.jumpType": "跳转类型", "banners.h5Param": "参数(H5 链接)", "banners.appParam": "参数(APP 公共跳转 JSON)", "banners.displayScope": "展示范围", "banners.scopeHome": "首页", "banners.scopeRoom": "房间", "banners.scopeRecharge": "充值", "banners.scopeMe": "我的", "banners.regionId": "区域 ID", "banners.countryCode": "国家码", "banners.countryCodeHint": "2-3 位字母,留空为全部", "banners.schedule": "投放时间", @@ -347,10 +350,10 @@ const ar = { ...en, "app.title": "الإدارة الخارجية", "app.shortTitle": "الإدارة الخارجية", "language.label": "اللغة", "common.actions": "الإجراءات", "common.active": "نشط", "common.all": "الكل", "common.allStatuses": "كل الحالات", "common.banned": "محظور", "common.cancel": "إلغاء", "common.confirm": "تأكيد", "common.create": "إنشاء", "common.disabled": "معطّل", "common.edit": "تعديل", "common.enabled": "مفعّل", "common.expired": "منتهي", "common.loading": "جارٍ التحميل", "common.noData": "لا توجد بيانات", "common.normal": "طبيعي", "common.query": "بحث", "common.reason": "السبب", "common.reload": "إعادة التحميل", "common.retry": "إعادة المحاولة", "common.save": "حفظ", "common.status": "الحالة", "common.user": "المستخدم", - "auth.account": "حساب الإدارة الخارجية", "auth.changePassword": "تغيير كلمة مرور الإدارة الخارجية", "auth.checkingSession": "جارٍ التحقق من الجلسة", "auth.confirmNewPassword": "تأكيد كلمة المرور الجديدة", "auth.currentPassword": "كلمة المرور الحالية", "auth.fillAccountPassword": "أدخل الحساب وكلمة المرور", "auth.firstLoginChange": "يجب تغيير كلمة المرور عند تسجيل الدخول لأول مرة", "auth.hidePassword": "إخفاء كلمة المرور", "auth.invalidCredentials": "الحساب أو كلمة المرور غير صحيحة", "auth.login": "تسجيل الدخول", "auth.loginFailed": "فشل تسجيل الدخول", "auth.logout": "تسجيل الخروج", "auth.newPassword": "كلمة المرور الجديدة", "auth.noPermission": "لا يملك هذا الحساب صلاحية الوصول إلى هذه الميزة.", "auth.password": "كلمة المرور", "auth.passwordChangedFailed": "فشل تغيير كلمة المرور", "auth.passwordMismatch": "كلمتا المرور الجديدتان غير متطابقتين", "auth.passwordMin": "8 أحرف على الأقل", "auth.passwordMinError": "يجب أن تتكون كلمة المرور الجديدة من 8 أحرف على الأقل", "auth.passwordWhitespace": "لا يمكن أن تكون كلمة المرور الجديدة فارغة أو مسافات فقط", "auth.recheck": "إعادة التحقق", "auth.returnOverview": "العودة إلى ملخص الصلاحيات", "auth.savePassword": "حفظ كلمة المرور", "auth.sessionFailed": "فشل التحقق من الجلسة", "auth.showPassword": "إظهار كلمة المرور", - "nav.expand": "فتح القائمة", "nav.overview": "ملخص الصلاحيات", "nav.users": "إدارة المستخدمين", "nav.bans": "إدارة الحظر", "nav.hosts": "المضيفون", "nav.agencies": "الوكالات", "nav.bds": "قائمة BD", "nav.bdManagers": "مديرو BD", "nav.superAdmins": "المشرفون العامون", "nav.rooms": "إدارة الغرف", "nav.grants": "الموارد والمعرّفات المميزة", "nav.banners": "إدارة اللافتات", "nav.team": "فريقي", + "auth.account": "الحساب", "auth.changePassword": "تغيير كلمة المرور", "auth.checkingSession": "جارٍ التحقق من الجلسة", "auth.confirmNewPassword": "تأكيد كلمة المرور الجديدة", "auth.currentPassword": "كلمة المرور الحالية", "auth.fillAccountPassword": "أدخل الحساب وكلمة المرور", "auth.firstLoginChange": "يجب تغيير كلمة المرور عند تسجيل الدخول لأول مرة", "auth.hidePassword": "إخفاء كلمة المرور", "auth.invalidCredentials": "الحساب أو كلمة المرور غير صحيحة", "auth.login": "تسجيل الدخول", "auth.loginFailed": "فشل تسجيل الدخول", "auth.logout": "تسجيل الخروج", "auth.newPassword": "كلمة المرور الجديدة", "auth.noPermission": "لا يملك هذا الحساب صلاحية الوصول إلى هذه الميزة.", "auth.password": "كلمة المرور", "auth.passwordChangedFailed": "فشل تغيير كلمة المرور", "auth.passwordMismatch": "كلمتا المرور الجديدتان غير متطابقتين", "auth.passwordMin": "8 أحرف على الأقل", "auth.passwordMinError": "يجب أن تتكون كلمة المرور الجديدة من 8 أحرف على الأقل", "auth.passwordWhitespace": "لا يمكن أن تكون كلمة المرور الجديدة فارغة أو مسافات فقط", "auth.recheck": "إعادة التحقق", "auth.returnOverview": "العودة إلى الرئيسية", "auth.savePassword": "حفظ كلمة المرور", "auth.sessionFailed": "فشل التحقق من الجلسة", "auth.showPassword": "إظهار كلمة المرور", + "nav.expand": "فتح القائمة", "nav.more": "المزيد", "nav.overview": "الرئيسية", "nav.users": "إدارة المستخدمين", "nav.bans": "إدارة الحظر", "nav.hosts": "المضيفون", "nav.agencies": "الوكالات", "nav.bds": "قائمة BD", "nav.bdManagers": "مديرو BD", "nav.superAdmins": "المشرفون العامون", "nav.rooms": "إدارة الغرف", "nav.grants": "الموارد والمعرّفات المميزة", "nav.banners": "إدارة اللافتات", "nav.team": "فريقي", "capability.userList": "قائمة المستخدمين", "capability.userUpdate": "تعديل بيانات المستخدم", "capability.banList": "قائمة الحسابات المحظورة", "capability.ban": "حظر المستخدمين", "capability.unban": "إلغاء حظر المستخدمين", "capability.hostList": "قائمة المضيفين", "capability.agencyList": "قائمة الوكالات", "capability.bdList": "قائمة BD", "capability.bdManagerList": "قائمة مديري BD", "capability.superAdminList": "قائمة المشرفين العامين", "capability.roomList": "إدارة الغرف", "capability.roomUpdate": "تعديل الغرف", "capability.privilegeList": "قائمة عناصر الامتياز", "capability.privilegeGrant": "منح عناصر الامتياز", "capability.bannerCreate": "إنشاء اللافتات", "capability.prettyIdGrant": "منح المعرّفات المميزة", "capability.userTitleGrant": "منح ألقاب المستخدمين", "capability.roomBackgroundGrant": "منح خلفيات الغرف", "capability.userLevelGrant": "منح مستويات الثروة/VIP", "capability.teamView": "فريقي", - "overview.currentApp": "التطبيق الحالي", "overview.account": "حساب الإدارة الخارجية", "overview.permissions": "الصلاحيات المفعّلة", "overview.enabled": "مفعّلة", "overview.notEnabled": "غير مفعّلة", + "overview.currentApp": "التطبيق الحالي", "overview.account": "الحساب", "overview.permissions": "الصلاحيات المفعّلة", "overview.welcome": "مرحبًا بعودتك", "overview.features": "الخدمات", "overview.enabled": "مفعّلة", "overview.notEnabled": "غير مفعّلة", "users.title": "إدارة المستخدمين", "users.search": "معرّف المستخدم / المعرّف المميز / الاسم", "users.prettyId": "المعرّف المميز", "users.countryGender": "الدولة / الجنس", "users.level": "المستوى", "users.levelSummary": "الثروة {wealth} / الجاذبية {charm} / VIP {vip}", "users.edit": "تعديل", "users.grantLevel": "المستوى", "users.ban": "حظر", "users.updated": "تم تحديث بيانات المستخدم", "users.banned": "تم حظر المستخدم", "users.levelGranted": "تم منح مستوى المستخدم", "users.operationFailed": "فشلت عملية المستخدم", "users.editTitle": "تعديل المستخدم", "users.avatar": "الصورة الشخصية", "users.name": "اسم المستخدم", "users.gender": "الجنس", "users.countryCode": "رمز الدولة/المنطقة", "users.notSet": "غير محدد", "users.genderMale": "ذكر", "users.genderFemale": "أنثى", "users.genderUnknown": "غير معروف", "users.banTitle": "حظر المستخدم {user}", "users.banDays": "مدة الحظر (بالأيام)", "users.banForeverHint": "أدخل 0 للحظر الدائم", "users.banReason": "سبب الحظر", "users.levelTitle": "منح مستوى إلى {user}", "users.levelType": "نوع المستوى", "users.wealthLevel": "مستوى الثروة", "users.charmLevel": "مستوى الجاذبية", "users.vipLevel": "مستوى VIP", "users.validDays": "الصلاحية (بالأيام)", "users.validDaysMin": "يجب ألا تقل الصلاحية عن يوم واحد", "users.grantReason": "سبب المنح", "bans.title": "قائمة الحسابات المحظورة", "bans.reason": "سبب الحظر", "bans.unban": "إلغاء الحظر", "bans.unbanned": "تم إلغاء حظر المستخدم", "bans.unbanFailed": "فشل إلغاء حظر المستخدم", "bans.confirmTitle": "إلغاء حظر المستخدم", "bans.confirmContent": "هل تريد إلغاء حظر {user}؟", "organization.title": "المؤسسة", "organization.agencies": "قائمة الوكالات", "organization.bds": "قائمة BD", "organization.bdManagers": "قائمة مديري BD", "organization.hosts": "قائمة المضيفين", "organization.managers": "قائمة المديرين", "organization.superAdmins": "قائمة المشرفين العامين", "organization.team": "فريقي", "organization.agency": "الوكالة", "organization.owner": "المالك", "organization.parentBd": "BD الأعلى", "organization.member": "العضو", "organization.country": "الدولة / المنطقة", "organization.userReference": "المستخدم {id}", "organization.agencyReference": "الوكالة {id}", "organization.teamSearch": "معرّف الوكالة / معرّف المالك / معرّف BD الأعلى", "organization.noPermission": "لا يملك هذا الحساب صلاحية الوصول إلى قائمة المؤسسة هذه.", "organization.bdLeaderCount": "قادة BD: {count}", "organization.bdCount": "BD: {count}", "organization.agencyCount": "الوكالات: {count}", "organization.truncated": "يتجاوز حجم الفريق حد الواجهة. النتائج الحالية غير مكتملة.", @@ -369,10 +372,10 @@ const tr = { ...en, "app.title": "Harici Yönetim", "app.shortTitle": "Harici Yönetim", "language.label": "Dil", "common.actions": "İşlemler", "common.active": "Aktif", "common.all": "Tümü", "common.allStatuses": "Tüm durumlar", "common.banned": "Yasaklı", "common.cancel": "İptal", "common.confirm": "Onayla", "common.create": "Oluştur", "common.disabled": "Devre dışı", "common.edit": "Düzenle", "common.enabled": "Etkin", "common.expired": "Süresi dolmuş", "common.loading": "Yükleniyor", "common.noData": "Veri yok", "common.normal": "Normal", "common.query": "Ara", "common.reason": "Neden", "common.reload": "Yeniden yükle", "common.retry": "Tekrar dene", "common.save": "Kaydet", "common.status": "Durum", "common.user": "Kullanıcı", - "auth.account": "Harici yönetim hesabı", "auth.changePassword": "Harici yönetim parolasını değiştir", "auth.checkingSession": "Oturum kontrol ediliyor", "auth.confirmNewPassword": "Yeni parolayı doğrula", "auth.currentPassword": "Mevcut parola", "auth.fillAccountPassword": "Hesabınızı ve parolanızı girin", "auth.firstLoginChange": "İlk girişte parolanızı değiştirmeniz gerekir", "auth.hidePassword": "Parolayı gizle", "auth.invalidCredentials": "Hesap veya parola hatalı", "auth.login": "Giriş yap", "auth.loginFailed": "Giriş başarısız", "auth.logout": "Çıkış yap", "auth.newPassword": "Yeni parola", "auth.noPermission": "Bu harici yönetim hesabının bu özelliğe erişimi yok.", "auth.password": "Parola", "auth.passwordChangedFailed": "Parola değiştirilemedi", "auth.passwordMismatch": "Yeni parolalar eşleşmiyor", "auth.passwordMin": "En az 8 karakter", "auth.passwordMinError": "Yeni parola en az 8 karakter olmalıdır", "auth.passwordWhitespace": "Yeni parola boş veya yalnızca boşluk olamaz", "auth.recheck": "Tekrar kontrol et", "auth.returnOverview": "Yetki özetine dön", "auth.savePassword": "Parolayı kaydet", "auth.sessionFailed": "Oturum doğrulanamadı", "auth.showPassword": "Parolayı göster", - "nav.expand": "Menüyü aç", "nav.overview": "Yetki özeti", "nav.users": "Kullanıcı yönetimi", "nav.bans": "Yasak yönetimi", "nav.hosts": "Yayıncılar", "nav.agencies": "Ajanslar", "nav.bds": "BD listesi", "nav.bdManagers": "BD Yöneticileri", "nav.superAdmins": "Süper Yöneticiler", "nav.rooms": "Oda yönetimi", "nav.grants": "Kaynaklar ve özel ID'ler", "nav.banners": "Banner yönetimi", "nav.team": "Ekibim", + "auth.account": "Hesap", "auth.changePassword": "Parolayı değiştir", "auth.checkingSession": "Oturum kontrol ediliyor", "auth.confirmNewPassword": "Yeni parolayı doğrula", "auth.currentPassword": "Mevcut parola", "auth.fillAccountPassword": "Hesabınızı ve parolanızı girin", "auth.firstLoginChange": "İlk girişte parolanızı değiştirmeniz gerekir", "auth.hidePassword": "Parolayı gizle", "auth.invalidCredentials": "Hesap veya parola hatalı", "auth.login": "Giriş yap", "auth.loginFailed": "Giriş başarısız", "auth.logout": "Çıkış yap", "auth.newPassword": "Yeni parola", "auth.noPermission": "Bu hesabın bu özelliğe erişimi yok.", "auth.password": "Parola", "auth.passwordChangedFailed": "Parola değiştirilemedi", "auth.passwordMismatch": "Yeni parolalar eşleşmiyor", "auth.passwordMin": "En az 8 karakter", "auth.passwordMinError": "Yeni parola en az 8 karakter olmalıdır", "auth.passwordWhitespace": "Yeni parola boş veya yalnızca boşluk olamaz", "auth.recheck": "Tekrar kontrol et", "auth.returnOverview": "Ana sayfaya dön", "auth.savePassword": "Parolayı kaydet", "auth.sessionFailed": "Oturum doğrulanamadı", "auth.showPassword": "Parolayı göster", + "nav.expand": "Menüyü aç", "nav.more": "Daha fazla", "nav.overview": "Ana sayfa", "nav.users": "Kullanıcı yönetimi", "nav.bans": "Yasak yönetimi", "nav.hosts": "Yayıncılar", "nav.agencies": "Ajanslar", "nav.bds": "BD listesi", "nav.bdManagers": "BD Yöneticileri", "nav.superAdmins": "Süper Yöneticiler", "nav.rooms": "Oda yönetimi", "nav.grants": "Kaynaklar ve özel ID'ler", "nav.banners": "Banner yönetimi", "nav.team": "Ekibim", "capability.userList": "Kullanıcı listesi", "capability.userUpdate": "Kullanıcı bilgilerini düzenleme", "capability.banList": "Yasaklı hesap listesi", "capability.ban": "Kullanıcı yasaklama", "capability.unban": "Kullanıcı yasağını kaldırma", "capability.hostList": "Yayıncı listesi", "capability.agencyList": "Ajans listesi", "capability.bdList": "BD listesi", "capability.bdManagerList": "BD Yöneticisi listesi", "capability.superAdminList": "Süper Yönetici listesi", "capability.roomList": "Oda yönetimi", "capability.roomUpdate": "Oda düzenleme", "capability.privilegeList": "Ayrıcalık öğesi listesi", "capability.privilegeGrant": "Ayrıcalık öğesi verme", "capability.bannerCreate": "Banner oluşturma", "capability.prettyIdGrant": "Özel ID verme", "capability.userTitleGrant": "Kullanıcı unvanı verme", "capability.roomBackgroundGrant": "Oda arka planı verme", "capability.userLevelGrant": "Servet/VIP seviyesi verme", "capability.teamView": "Ekibim", - "overview.currentApp": "Geçerli uygulama", "overview.account": "Harici yönetim hesabı", "overview.permissions": "Etkin yetkiler", "overview.enabled": "Etkin", "overview.notEnabled": "Etkin değil", + "overview.currentApp": "Geçerli uygulama", "overview.account": "Hesap", "overview.permissions": "Etkin yetkiler", "overview.welcome": "Tekrar hoş geldiniz", "overview.features": "Özellikler", "overview.enabled": "Etkin", "overview.notEnabled": "Etkin değil", "users.title": "Kullanıcı yönetimi", "users.search": "Kullanıcı ID / özel ID / ad", "users.prettyId": "Özel ID", "users.countryGender": "Ülke / cinsiyet", "users.level": "Seviye", "users.levelSummary": "Servet {wealth} / Cazibe {charm} / VIP {vip}", "users.edit": "Düzenle", "users.grantLevel": "Seviye", "users.ban": "Yasakla", "users.updated": "Kullanıcı bilgileri güncellendi", "users.banned": "Kullanıcı yasaklandı", "users.levelGranted": "Kullanıcı seviyesi verildi", "users.operationFailed": "Kullanıcı işlemi başarısız", "users.editTitle": "Kullanıcıyı düzenle", "users.avatar": "Avatar", "users.name": "Kullanıcı adı", "users.gender": "Cinsiyet", "users.countryCode": "Ülke/bölge kodu", "users.notSet": "Ayarlanmadı", "users.genderMale": "Erkek", "users.genderFemale": "Kadın", "users.genderUnknown": "Bilinmiyor", "users.banTitle": "{user} kullanıcısını yasakla", "users.banDays": "Yasak süresi (gün)", "users.banForeverHint": "Kalıcı yasak için 0 girin", "users.banReason": "Yasak nedeni", "users.levelTitle": "{user} kullanıcısına seviye ver", "users.levelType": "Seviye türü", "users.wealthLevel": "Servet seviyesi", "users.charmLevel": "Cazibe seviyesi", "users.vipLevel": "VIP seviyesi", "users.validDays": "Geçerlilik (gün)", "users.validDaysMin": "Geçerlilik en az 1 gün olmalıdır", "users.grantReason": "Verme nedeni", "bans.title": "Yasaklı hesap listesi", "bans.reason": "Yasak nedeni", "bans.unban": "Yasağı kaldır", "bans.unbanned": "Kullanıcı yasağı kaldırıldı", "bans.unbanFailed": "Kullanıcı yasağı kaldırılamadı", "bans.confirmTitle": "Kullanıcı yasağını kaldır", "bans.confirmContent": "{user} kullanıcısının yasağı kaldırılsın mı?", "organization.title": "Organizasyon", "organization.agencies": "Ajans listesi", "organization.bds": "BD listesi", "organization.bdManagers": "BD Yöneticisi listesi", "organization.hosts": "Yayıncı listesi", "organization.managers": "Yönetici listesi", "organization.superAdmins": "Süper Yönetici listesi", "organization.team": "Ekibim", "organization.agency": "Ajans", "organization.owner": "Sahip", "organization.parentBd": "Üst BD", "organization.member": "Üye", "organization.country": "Ülke / bölge", "organization.userReference": "Kullanıcı {id}", "organization.agencyReference": "Ajans {id}", "organization.teamSearch": "Ajans ID / sahip kullanıcı ID / üst BD ID", "organization.noPermission": "Bu hesabın bu organizasyon listesine erişimi yok.", "organization.bdLeaderCount": "BD Liderleri: {count}", "organization.bdCount": "BD: {count}", "organization.agencyCount": "Ajanslar: {count}", "organization.truncated": "Ekip API sınırını aşıyor. Mevcut sonuçlar eksik.", diff --git a/external-admin/src/layout/ExternalAdminLayout.jsx b/external-admin/src/layout/ExternalAdminLayout.jsx index aedfaf5..b9ea997 100644 --- a/external-admin/src/layout/ExternalAdminLayout.jsx +++ b/external-admin/src/layout/ExternalAdminLayout.jsx @@ -1,10 +1,11 @@ import ApartmentOutlined from "@mui/icons-material/ApartmentOutlined"; import BadgeOutlined from "@mui/icons-material/BadgeOutlined"; -import DashboardOutlined from "@mui/icons-material/DashboardOutlined"; +import GridViewOutlined from "@mui/icons-material/GridViewOutlined"; import GroupsOutlined from "@mui/icons-material/GroupsOutlined"; +import HomeOutlined from "@mui/icons-material/HomeOutlined"; import ImageOutlined from "@mui/icons-material/ImageOutlined"; +import KeyOutlined from "@mui/icons-material/KeyOutlined"; import LogoutOutlined from "@mui/icons-material/LogoutOutlined"; -import MenuOutlined from "@mui/icons-material/MenuOutlined"; import MeetingRoomOutlined from "@mui/icons-material/MeetingRoomOutlined"; import PeopleOutlineOutlined from "@mui/icons-material/PeopleOutlineOutlined"; import PersonOffOutlined from "@mui/icons-material/PersonOffOutlined"; @@ -16,14 +17,12 @@ import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import Divider from "@mui/material/Divider"; import Drawer from "@mui/material/Drawer"; -import IconButton from "@mui/material/IconButton"; import List from "@mui/material/List"; import ListItemButton from "@mui/material/ListItemButton"; import ListItemIcon from "@mui/material/ListItemIcon"; import ListItemText from "@mui/material/ListItemText"; import Stack from "@mui/material/Stack"; import Toolbar from "@mui/material/Toolbar"; -import Tooltip from "@mui/material/Tooltip"; import Typography from "@mui/material/Typography"; import useMediaQuery from "@mui/material/useMediaQuery"; import { useTheme } from "@mui/material/styles"; @@ -38,7 +37,7 @@ import { useExternalSessionBrand } from "../shared/useExternalSessionBrand.js"; const drawerWidth = 248; const navigation = [ - { icon: DashboardOutlined, labelKey: "nav.overview", path: "/overview" }, + { icon: HomeOutlined, labelKey: "nav.overview", path: "/overview" }, { capabilities: ["user:list", "user:update", "user:ban", "user-level:grant"], icon: PeopleOutlineOutlined, labelKey: "nav.users", path: "/users" }, { capabilities: ["user-ban:list", "user:unban"], icon: PersonOffOutlined, labelKey: "nav.bans", path: "/bans" }, { capabilities: ["host:list"], icon: BadgeOutlined, labelKey: "nav.hosts", path: "/organization/hosts" }, @@ -52,27 +51,43 @@ const navigation = [ { capabilities: ["team:view"], icon: GroupsOutlined, labelKey: "nav.team", path: "/organization/team" } ]; +const isItemActive = (item, pathname) => pathname === item.path || pathname.startsWith(`${item.path}/`); + export function ExternalAdminLayout() { const theme = useTheme(); const mobile = useMediaQuery(theme.breakpoints.down("md")); - const [drawerOpen, setDrawerOpen] = useState(false); + const [sheetOpen, setSheetOpen] = useState(false); const { logout, session } = useExternalAuth(); - const { direction, t } = useExternalI18n(); + const { t } = useExternalI18n(); const location = useLocation(); const navigate = useNavigate(); const visibleNavigation = useMemo( () => navigation.filter((item) => !item.capabilities || hasAnyExternalCapability(session, item.capabilities)), [session] ); - const current = [...visibleNavigation].reverse().find((item) => location.pathname.startsWith(item.path)); + const current = [...visibleNavigation].reverse().find((item) => isItemActive(item, location.pathname)); + // The H5 tab bar keeps three primary destinations reachable with a thumb; everything else lives in the "More" sheet. + const tabs = visibleNavigation.slice(0, 3); + const moreActive = !tabs.some((item) => isItemActive(item, location.pathname)); useExternalSessionBrand(session, t("app.title")); const handleLogout = async () => { + setSheetOpen(false); await logout(); navigate("/login", { replace: true }); }; - const drawer = ( + const accountCard = ( + <> + {String(session?.account || "A").slice(0, 1).toUpperCase()} + + {session?.account} + {session?.appName || session?.appCode}{session?.displayName ? ` · ${session.displayName}` : ""} + + + ); + + const sidebar = ( setDrawerOpen(false)} > @@ -102,47 +116,88 @@ export function ExternalAdminLayout() { ); })} + + {accountCard} + + + ); return ( + {/* Physical margins/anchors are written for LTR; the RTL Emotion cache flips them for Arabic. */} - {mobile ? setDrawerOpen(true)}> : null} - {current ? t(current.labelKey) : t("app.title")} + {mobile && !current ? t("app.shortTitle") : t(current?.labelKey || "app.shortTitle")} - + - {String(session?.account || "A").slice(0, 1).toUpperCase()} - {!mobile ? ( - - {session?.account} - {session?.appCode}{session?.displayName ? ` · ${session.displayName}` : ""} - - ) : null} - - - + {!mobile ? accountCard : null} - setDrawerOpen(false)} ModalProps={{ keepMounted: true }} sx={{ display: { md: "none" }, "& .MuiDrawer-paper": { width: drawerWidth } }}>{drawer} - {drawer} - - + {sidebar} + + + {/* Shown below the md breakpoint via index.css; sx display rules would lose to the stylesheet cascade. */} + + {tabs.map((item) => { + const Icon = item.icon; + return ( + + + {t(item.labelKey)} + + ); + })} + + + setSheetOpen(false)} + > + + {accountCard} + + {visibleNavigation.map((item) => { + const Icon = item.icon; + return ( + setSheetOpen(false)} + > + + {t(item.labelKey)} + + ); + })} + + + + + + + ); } diff --git a/external-admin/src/layout/ExternalAdminLayout.test.jsx b/external-admin/src/layout/ExternalAdminLayout.test.jsx index 7419cad..c767ab0 100644 --- a/external-admin/src/layout/ExternalAdminLayout.test.jsx +++ b/external-admin/src/layout/ExternalAdminLayout.test.jsx @@ -1,7 +1,9 @@ -import { render, screen } from "@testing-library/react"; +import { CacheProvider } from "@emotion/react"; +import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import { beforeEach, describe, expect, test, vi } from "vitest"; +import { externalEmotionCache } from "../i18n/emotionCache.js"; import { ExternalI18nProvider } from "../i18n/ExternalI18nProvider.jsx"; import { EXTERNAL_LOCALE_STORAGE_KEY } from "../i18n/messages.js"; import { ExternalAdminLayout } from "./ExternalAdminLayout.jsx"; @@ -34,17 +36,22 @@ describe("ExternalAdminLayout RTL", () => { }; }); - test("opens the mobile navigation from the right in Arabic", async () => { + test("shows the H5 tab bar and opens the more sheet in Arabic", async () => { const user = userEvent.setup(); viewport.mobile = true; renderLayout(); - await user.click(screen.getByRole("button", { name: "فتح القائمة" })); + const tabbar = document.querySelector(".external-tabbar"); + expect(tabbar).toBeInTheDocument(); + expect(within(tabbar).getByRole("link", { name: "الرئيسية" })).toBeInTheDocument(); - const openTemporaryDrawer = Array.from(document.querySelectorAll(".MuiDrawer-paper")).find((node) => node.closest(".MuiModal-root")); - expect(openTemporaryDrawer).toBeInTheDocument(); - expect(openTemporaryDrawer.parentElement).not.toHaveStyle({ visibility: "hidden" }); - expect(screen.getAllByRole("link", { name: "ملخص الصلاحيات" }).length).toBeGreaterThan(0); + await user.click(within(tabbar).getByRole("button", { name: "المزيد" })); + + const sheet = document.querySelector(".MuiDrawer-paper.external-sheet"); + expect(sheet).toBeInTheDocument(); + expect(sheet.closest(".MuiModal-root")).not.toBeNull(); + expect(within(sheet).getByRole("link", { name: "الرئيسية" })).toBeInTheDocument(); + expect(within(sheet).getByRole("button", { name: "تسجيل الخروج" })).toBeInTheDocument(); }); test("places the desktop navigation drawer on the right for Arabic", () => { @@ -53,7 +60,8 @@ describe("ExternalAdminLayout RTL", () => { expect(document.documentElement).toHaveAttribute("dir", "rtl"); expect(document.documentElement).toHaveAttribute("lang", "ar"); const permanentDrawer = document.querySelectorAll(".MuiDrawer-paper")[0]; - const generatedClass = Array.from(permanentDrawer.classList).find((className) => className.startsWith("css-")); + // Emotion prefixes generated classes with the cache key, so the RTL cache yields external-rtl-*. + const generatedClass = Array.from(permanentDrawer.classList).find((className) => className.startsWith("external-rtl-") || className.startsWith("css-")); const drawerRule = Array.from(document.styleSheets) .flatMap((sheet) => Array.from(sheet.cssRules || [])) .map((rule) => rule.cssText) @@ -65,7 +73,7 @@ describe("ExternalAdminLayout RTL", () => { expect(screen.queryByText("إدارة HYApp الخارجية")).not.toBeInTheDocument(); expect(document.title).toBe("Fami"); expect(document.head.querySelector('link[data-external-app-favicon="true"]')).toHaveAttribute("href", "https://media.example.com/apps/fami.png"); - expect(screen.getAllByText("ملخص الصلاحيات").length).toBeGreaterThan(0); + expect(screen.getAllByText("الرئيسية").length).toBeGreaterThan(0); expect(screen.getByRole("combobox", { name: "اللغة" })).toHaveTextContent("العربية"); view.unmount(); @@ -95,15 +103,19 @@ describe("ExternalAdminLayout RTL", () => { }); function renderLayout() { + // The RTL Emotion cache is part of the production setup: physical styles are written + // for LTR and flipped by the cache, so the test must run the same pipeline. return render( - - - - }> - overview content
    } path="/overview" /> - - - - + + + + + }> + overview content
    } path="/overview" /> + + + + + ); } diff --git a/external-admin/src/main.jsx b/external-admin/src/main.jsx index 7cad114..2ee3265 100644 --- a/external-admin/src/main.jsx +++ b/external-admin/src/main.jsx @@ -1,12 +1,12 @@ import { CacheProvider } from "@emotion/react"; import CssBaseline from "@mui/material/CssBaseline"; -import { createTheme, ThemeProvider } from "@mui/material/styles"; +import { ThemeProvider } from "@mui/material/styles"; import React, { useMemo } from "react"; import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import { ToastProvider } from "@/shared/ui/ToastProvider.jsx"; -import { theme } from "@/theme.js"; import { ExternalAdminApp } from "./ExternalAdminApp.jsx"; +import { createExternalTheme } from "./theme.js"; import { ExternalAuthProvider } from "./auth/ExternalAuthProvider.jsx"; import { ExternalI18nProvider, useExternalI18n } from "./i18n/ExternalI18nProvider.jsx"; import { externalEmotionCache } from "./i18n/emotionCache.js"; @@ -25,10 +25,7 @@ createRoot(document.getElementById("external-admin-root")).render( function ExternalThemeRoot() { const { direction } = useExternalI18n(); // A direction-aware MUI theme keeps inputs, menus and dialog controls consistent with the Arabic document direction. - const externalTheme = useMemo(() => createTheme(theme, { - direction, - typography: { fontFamily: 'Inter, "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif' } - }), [direction]); + const externalTheme = useMemo(() => createExternalTheme(direction), [direction]); return ( diff --git a/external-admin/src/pages/BannersPage.jsx b/external-admin/src/pages/BannersPage.jsx index 29ec076..567aefa 100644 --- a/external-admin/src/pages/BannersPage.jsx +++ b/external-admin/src/pages/BannersPage.jsx @@ -21,7 +21,7 @@ export default function BannersPage() { const load = useCallback(() => listBanners({ page, page_size: 20 }), [page]); const { data, error, loading, reload } = usePagedResource(load); const columns = useMemo(() => [ - { key: "banner", label: t("banners.banner"), render: (item) => {item.coverUrl ? : null}{item.description || "-"}ID {item.id || "-"} }, + { key: "banner", label: t("banners.banner"), render: (item) => {item.coverUrl ? : null}{item.description || "-"}ID {item.id || "-"} }, { key: "type", label: t("banners.type"), render: (item) => item.bannerType || "-" }, { key: "platform", label: t("banners.platform"), render: (item) => item.platform || t("common.all") }, { key: "sort", label: t("banners.sort"), render: (item) => item.sortOrder }, diff --git a/external-admin/src/pages/BansPage.jsx b/external-admin/src/pages/BansPage.jsx index 1efaf84..1d52c88 100644 --- a/external-admin/src/pages/BansPage.jsx +++ b/external-admin/src/pages/BansPage.jsx @@ -43,7 +43,7 @@ export default function BansPage() { }; const columns = useMemo(() => [ - { key: "user", label: t("common.user"), render: (user) => {(user.username || "-").slice(0, 1)}{user.username || "-"}ID {user.id} }, + { key: "user", label: t("common.user"), render: (user) => {(user.username || "-").slice(0, 1)}{user.username || "-"}ID {user.id} }, { key: "prettyId", label: t("users.prettyId"), render: (user) => user.prettyId || "-" }, { key: "reason", label: t("bans.reason"), render: (user) => user.reason || "-" }, { key: "status", label: t("common.status"), render: () => }, diff --git a/external-admin/src/pages/ChangePasswordPage.jsx b/external-admin/src/pages/ChangePasswordPage.jsx index 02b533f..31d923d 100644 --- a/external-admin/src/pages/ChangePasswordPage.jsx +++ b/external-admin/src/pages/ChangePasswordPage.jsx @@ -69,7 +69,7 @@ export default function ChangePasswordPage() { showLogoFallback={false} size="large" /> - {t("auth.changePassword")} + {t("auth.changePassword")} {session?.passwordChangeRequired ? t("auth.firstLoginChange") : session?.account} diff --git a/external-admin/src/pages/GrantsPage.jsx b/external-admin/src/pages/GrantsPage.jsx index 9f33fbd..4316ffb 100644 --- a/external-admin/src/pages/GrantsPage.jsx +++ b/external-admin/src/pages/GrantsPage.jsx @@ -91,8 +91,8 @@ export default function GrantsPage() { {canGrant ? ( {t("grants.availableResources")} - - {resourcesState.data.items.slice(0, 12).map((resource) => R{resource.name})} + + {resourcesState.data.items.slice(0, 12).map((resource) => R{resource.name})} {!resourcesState.loading && !resourcesState.data.items.length ? {t("common.noData")} : null} diff --git a/external-admin/src/pages/LoginPage.jsx b/external-admin/src/pages/LoginPage.jsx index 963cc02..d631aad 100644 --- a/external-admin/src/pages/LoginPage.jsx +++ b/external-admin/src/pages/LoginPage.jsx @@ -66,7 +66,7 @@ export default function LoginPage() { - {t("app.title")} + {t("app.title")} {error ? {error} : null} diff --git a/external-admin/src/pages/LoginPage.test.jsx b/external-admin/src/pages/LoginPage.test.jsx index 000df23..0511095 100644 --- a/external-admin/src/pages/LoginPage.test.jsx +++ b/external-admin/src/pages/LoginPage.test.jsx @@ -31,7 +31,7 @@ describe("LoginPage", () => { expect(document.title).toBe("External Admin"); expect(document.head.querySelector('link[data-external-app-favicon="true"]')).not.toBeInTheDocument(); expect(screen.queryByRole("combobox", { name: "App" })).not.toBeInTheDocument(); - await user.type(screen.getByRole("textbox", { name: "External admin account" }), "fami-manager"); + await user.type(screen.getByRole("textbox", { name: "Account" }), "fami-manager"); expect(screen.getByRole("heading", { name: "External Admin" })).toBeInTheDocument(); expect(container.querySelector("img")).toBeNull(); await user.type(container.querySelector('input[autocomplete="current-password"]'), "secret-password"); @@ -45,7 +45,7 @@ describe("LoginPage", () => { auth.login.mockRejectedValueOnce({ code: 40100, message: "账号已禁用", status: 401 }); const { container } = renderLogin(); - await user.type(screen.getByRole("textbox", { name: "External admin account" }), "unknown-account"); + await user.type(screen.getByRole("textbox", { name: "Account" }), "unknown-account"); await user.type(container.querySelector('input[autocomplete="current-password"]'), "bad-password"); await user.click(screen.getByRole("button", { name: "Sign in" })); diff --git a/external-admin/src/pages/OrganizationPage.jsx b/external-admin/src/pages/OrganizationPage.jsx index 6f03ee5..56a8238 100644 --- a/external-admin/src/pages/OrganizationPage.jsx +++ b/external-admin/src/pages/OrganizationPage.jsx @@ -39,7 +39,7 @@ export default function OrganizationPage() { { key: "parent", label: t("organization.parentBd"), render: (item) => item.parentBdUserId || "-" }, { key: "status", label: t("common.status"), render: (item) => } ] : [ - { key: "identity", label: t("organization.member"), render: (item) => {(item.name || item.username || "-").slice(0, 1)}{item.name || item.username || "-"}ID {item.id || "-"}{item.userId && item.userId !== item.id ? ` / ${t("organization.userReference", { id: item.userId })}` : ""} }, + { key: "identity", label: t("organization.member"), render: (item) => {(item.name || item.username || "-").slice(0, 1)}{item.name || item.username || "-"}ID {item.id || "-"}{item.userId && item.userId !== item.id ? ` / ${t("organization.userReference", { id: item.userId })}` : ""} }, { key: "prettyId", label: t("users.prettyId"), render: (item) => item.prettyId || "-" }, { key: "country", label: t("organization.country"), render: (item) => item.country || "-" }, { key: "status", label: t("common.status"), render: (item) => } diff --git a/external-admin/src/pages/OverviewPage.jsx b/external-admin/src/pages/OverviewPage.jsx index 965b803..9945f85 100644 --- a/external-admin/src/pages/OverviewPage.jsx +++ b/external-admin/src/pages/OverviewPage.jsx @@ -1,5 +1,21 @@ -import CheckCircleOutlineOutlined from "@mui/icons-material/CheckCircleOutlineOutlined"; -import LockOutlined from "@mui/icons-material/LockOutlined"; +import ApartmentOutlined from "@mui/icons-material/ApartmentOutlined"; +import BadgeOutlined from "@mui/icons-material/BadgeOutlined"; +import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined"; +import EditOutlined from "@mui/icons-material/EditOutlined"; +import GppBadOutlined from "@mui/icons-material/GppBadOutlined"; +import GppGoodOutlined from "@mui/icons-material/GppGoodOutlined"; +import GroupsOutlined from "@mui/icons-material/GroupsOutlined"; +import ImageOutlined from "@mui/icons-material/ImageOutlined"; +import ListAltOutlined from "@mui/icons-material/ListAltOutlined"; +import ManageAccountsOutlined from "@mui/icons-material/ManageAccountsOutlined"; +import MeetingRoomOutlined from "@mui/icons-material/MeetingRoomOutlined"; +import MilitaryTechOutlined from "@mui/icons-material/MilitaryTechOutlined"; +import NumbersOutlined from "@mui/icons-material/NumbersOutlined"; +import PeopleOutlineOutlined from "@mui/icons-material/PeopleOutlineOutlined"; +import PersonOffOutlined from "@mui/icons-material/PersonOffOutlined"; +import SecurityOutlined from "@mui/icons-material/SecurityOutlined"; +import TrendingUpOutlined from "@mui/icons-material/TrendingUpOutlined"; +import WallpaperOutlined from "@mui/icons-material/WallpaperOutlined"; import Box from "@mui/material/Box"; import Card from "@mui/material/Card"; import CardActionArea from "@mui/material/CardActionArea"; @@ -13,28 +29,54 @@ import { EXTERNAL_CAPABILITIES, hasExternalCapability } from "../config/capabili import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; import { ExternalPage } from "../shared/PageComponents.jsx"; +const CAPABILITY_ICONS = { + "agency:list": ApartmentOutlined, + "banner:create": ImageOutlined, + "bd-manager:list": SecurityOutlined, + "bd:list": GroupsOutlined, + "host:list": BadgeOutlined, + "pretty-id:grant": NumbersOutlined, + "privilege:grant": CardGiftcardOutlined, + "privilege:list": ListAltOutlined, + "room-background:grant": WallpaperOutlined, + "room:list": MeetingRoomOutlined, + "room:update": EditOutlined, + "super-admin:list": SecurityOutlined, + "team:view": GroupsOutlined, + "user-ban:list": PersonOffOutlined, + "user-level:grant": TrendingUpOutlined, + "user-title:grant": MilitaryTechOutlined, + "user:ban": GppBadOutlined, + "user:list": PeopleOutlineOutlined, + "user:unban": GppGoodOutlined, + "user:update": ManageAccountsOutlined +}; + export default function OverviewPage() { const { session } = useExternalAuth(); const { t } = useExternalI18n(); + const enabledCount = EXTERNAL_CAPABILITIES.filter((item) => hasExternalCapability(session, item.code)).length; return ( - - - - {t("overview.currentApp")}{session.appName || session.appCode} ({session.appCode}) - {t("overview.account")}{session.account} - {t("overview.permissions")}{EXTERNAL_CAPABILITIES.filter((item) => hasExternalCapability(session, item.code)).length} / {EXTERNAL_CAPABILITIES.length} - - - + + {t("overview.welcome")} + {session.appName || session.appCode} + + {t("overview.currentApp")}{session.appName || session.appCode} ({session.appCode}) + {t("overview.account")}{session.account} + {t("overview.permissions")}{enabledCount} / {EXTERNAL_CAPABILITIES.length} + + + {t("overview.features")} {EXTERNAL_CAPABILITIES.map((item) => { const enabled = hasExternalCapability(session, item.code); + const Icon = CAPABILITY_ICONS[item.code] || ListAltOutlined; const content = ( - - {enabled ? : } + + {t(item.labelKey)} diff --git a/external-admin/src/pages/RoomsPage.jsx b/external-admin/src/pages/RoomsPage.jsx index 63c883e..89e2fb5 100644 --- a/external-admin/src/pages/RoomsPage.jsx +++ b/external-admin/src/pages/RoomsPage.jsx @@ -29,7 +29,7 @@ export default function RoomsPage() { const canEdit = hasExternalCapability(session, "room:update"); const columns = useMemo(() => [ - { key: "room", label: t("rooms.room"), render: (item) => R{item.title || "-"}ID {item.id} }, + { key: "room", label: t("rooms.room"), render: (item) => R{item.title || "-"}ID {item.id} }, { key: "owner", label: t("rooms.owner"), render: (item) => item.ownerName || "-" }, { key: "region", label: t("rooms.visibleRegion"), render: (item) => item.visibleRegionId || t("rooms.allRegions") }, { key: "status", label: t("common.status"), render: (item) => }, diff --git a/external-admin/src/pages/UsersPage.jsx b/external-admin/src/pages/UsersPage.jsx index 020c76d..36fa92d 100644 --- a/external-admin/src/pages/UsersPage.jsx +++ b/external-admin/src/pages/UsersPage.jsx @@ -49,12 +49,12 @@ export default function UsersPage() { }; const columns = useMemo(() => [ - { key: "user", label: t("common.user"), render: (user) => {(user.username || "-").slice(0, 1)}{user.username || "-"}ID {user.id} }, + { key: "user", label: t("common.user"), render: (user) => {(user.username || "-").slice(0, 1)}{user.username || "-"}ID {user.id} }, { key: "prettyId", label: t("users.prettyId"), render: (user) => user.prettyId || "-" }, { key: "country", label: t("users.countryGender"), render: (user) => `${user.country || "-"} / ${translatedGender(user.gender, t)}` }, { key: "level", label: t("users.level"), render: (user) => t("users.levelSummary", { wealth: user.wealthLevel, charm: user.charmLevel, vip: user.vipLevel }) }, { key: "status", label: t("common.status"), render: (user) => }, - { key: "actions", label: t("common.actions"), render: (user) => {canEdit ? : null}{canGrantLevel ? : null}{canBan && !user.banned ? : null} } + { key: "actions", label: t("common.actions"), render: (user) => {canEdit ? : null}{canGrantLevel ? : null}{canBan && !user.banned ? : null} } ], [canBan, canEdit, canGrantLevel, t]); return ( diff --git a/external-admin/src/shared/PageComponents.jsx b/external-admin/src/shared/PageComponents.jsx index fa1a04e..c6f7dfb 100644 --- a/external-admin/src/shared/PageComponents.jsx +++ b/external-admin/src/shared/PageComponents.jsx @@ -21,11 +21,11 @@ import { translateExternalError } from "../i18n/errors.js"; export function ExternalPage({ actions, children, title }) { return ( - + {title} - {actions ? {actions} : null} + {actions ? {actions} : null} {children} @@ -55,7 +55,8 @@ export function ExternalPageState({ error, loading, onRetry }) { export function ResponsiveDataList({ columns, emptyText, items, rowKey }) { const theme = useTheme(); - const mobile = useMediaQuery(theme.breakpoints.down("sm")); + // H5 is the primary surface: anything below desktop gets record cards instead of a sideways-scrolling table. + const mobile = useMediaQuery(theme.breakpoints.down("md")); const { t } = useExternalI18n(); if (!items.length) { @@ -113,7 +114,7 @@ export function PagePagination({ page, pageSize, total, onChange }) { const { t } = useExternalI18n(); const totalPages = Math.max(1, Math.ceil(total / pageSize)); return ( - + {t("pagination.total", { count: total })} diff --git a/external-admin/src/styles/index.css b/external-admin/src/styles/index.css index 3bff03d..89fdf4b 100644 --- a/external-admin/src/styles/index.css +++ b/external-admin/src/styles/index.css @@ -1,3 +1,104 @@ +/* + * HY Partner Center — black & gold identity. + * This bundle loads after @/styles/tokens.css, so the :root block below re-skins + * every shared token for this entry only (the internal admin keeps its light theme). + * All directional rules use CSS logical properties because the app also runs RTL (Arabic). + */ + +:root { + --bg-page: #0d0d11; + --bg-sidebar: #101015; + --bg-header: rgba(14, 14, 19, 0.88); + --bg-card: #16161c; + --bg-card-strong: #1e1e26; + --bg-hover: rgba(214, 181, 110, 0.08); + --bg-input: rgba(255, 255, 255, 0.035); + --bg-input-strong: rgba(255, 255, 255, 0.07); + --bg-flyout: rgba(24, 24, 31, 0.98); + + --border: rgba(214, 181, 110, 0.2); + --border-soft: rgba(214, 181, 110, 0.12); + --row-border: rgba(214, 181, 110, 0.1); + --detail-border: rgba(214, 181, 110, 0.12); + + --primary: #d6b56e; + --primary-strong: #e4ca8c; + --primary-hover: rgba(214, 181, 110, 0.1); + --primary-surface: rgba(214, 181, 110, 0.12); + --primary-surface-strong: rgba(214, 181, 110, 0.16); + --primary-border: rgba(214, 181, 110, 0.45); + --primary-border-strong: rgba(214, 181, 110, 0.62); + --primary-border-active: rgba(214, 181, 110, 0.75); + --brand-secondary: #a8823f; + --brand-gradient: linear-gradient(135deg, #efdcab 0%, #d6b56e 45%, #a8823f 100%); + --brand-shadow: 0 12px 28px rgba(0, 0, 0, 0.55), 0 2px 10px rgba(214, 181, 110, 0.28); + --active-gradient: linear-gradient(90deg, rgba(214, 181, 110, 0.95), rgba(168, 130, 63, 0.4)); + --active-inset: inset 0 0 0 1px rgba(214, 181, 110, 0.24); + + --success: #66bd8b; + --success-surface: rgba(102, 189, 139, 0.12); + --success-border: rgba(102, 189, 139, 0.36); + --success-border-strong: rgba(102, 189, 139, 0.72); + --success-glow: rgba(102, 189, 139, 0.26); + --warning: #d9a441; + --warning-surface: rgba(217, 164, 65, 0.12); + --warning-border: rgba(217, 164, 65, 0.36); + --warning-glow: rgba(217, 164, 65, 0.26); + --danger: #e07b6d; + --danger-surface: rgba(224, 123, 109, 0.12); + --danger-border: rgba(224, 123, 109, 0.34); + --danger-border-strong: rgba(224, 123, 109, 0.72); + --danger-glow: rgba(224, 123, 109, 0.26); + --info: #9fb4cc; + --info-surface: rgba(159, 180, 204, 0.12); + --info-surface-strong: rgba(159, 180, 204, 0.18); + --info-border: rgba(159, 180, 204, 0.34); + --stopped: #8c8577; + --neutral-surface: rgba(255, 255, 255, 0.05); + --neutral-surface-strong: rgba(255, 255, 255, 0.09); + --neutral-border: rgba(255, 255, 255, 0.14); + --admin-tag-bg: var(--neutral-surface); + --admin-tag-border: var(--neutral-border); + --admin-tag-color: var(--text-secondary); + + --chart-grid: rgba(214, 181, 110, 0.12); + --chart-tooltip-bg: #1e1e26; + --chart-tooltip-border: rgba(214, 181, 110, 0.3); + --chart-tooltip-text: #f0e9d8; + + --text-primary: #f0e9d8; + --text-secondary: #b8ad92; + --text-tertiary: #8c836d; + --active-contrast: #1a1206; + + --shadow-soft: 0 12px 28px rgba(0, 0, 0, 0.45); + --shadow-panel: 0 20px 50px rgba(0, 0, 0, 0.55); + --shadow-dialog: 0 30px 90px rgba(0, 0, 0, 0.7); + --shadow-drawer: 0 -18px 60px rgba(0, 0, 0, 0.6); + --overlay-scrim: rgba(4, 4, 7, 0.66); + --focus-ring: rgba(214, 181, 110, 0.26); + --login-gradient: radial-gradient(120% 70% at 50% 0%, rgba(214, 181, 110, 0.12), transparent 60%); + --skeleton-surface: rgba(255, 255, 255, 0.045); + --skeleton-shine: rgba(214, 181, 110, 0.12); + --skeleton-gradient: linear-gradient(90deg, transparent, var(--skeleton-shine), transparent); + + --font-display: "Didot", "Bodoni MT", "Playfair Display", Georgia, "Times New Roman", serif; + --tabbar-height: 62px; +} + +/* Bigger touch targets on handsets; 16px inputs also stop iOS Safari focus-zoom. */ +@media (max-width: 899px) { + :root { + --control-height: 44px; + --control-font-size: 16px; + --control-line-height: 22px; + --control-padding-x: 14px; + --admin-font-size: 15px; + --admin-line-height: 22px; + --radius-control: 10px; + } +} + html, body, #external-admin-root { @@ -6,29 +107,63 @@ body, } body { - background: var(--bg-page); + background: + radial-gradient(1100px 460px at 50% -8%, rgba(214, 181, 110, 0.08), transparent 62%), + radial-gradient(900px 500px at 100% 110%, rgba(214, 181, 110, 0.045), transparent 55%), + var(--bg-page); + background-attachment: fixed; color: var(--text-primary); font-family: var(--font-system); + -webkit-tap-highlight-color: transparent; } +::selection { + background: rgba(214, 181, 110, 0.32); + color: #fdf9ef; +} + +* { + scrollbar-width: thin; + scrollbar-color: rgba(214, 181, 110, 0.28) transparent; +} + +/* --------------------------------------------------------------------------- + * Login / password screens + * ------------------------------------------------------------------------- */ + .external-login-page { min-height: 100dvh; display: grid; place-items: center; padding: max(24px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right)) max(24px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left)); - background: radial-gradient(circle at 50% 0, rgba(37, 99, 235, 0.13), transparent 42%), var(--bg-page); + background: + var(--login-gradient), + radial-gradient(700px 380px at 50% 108%, rgba(214, 181, 110, 0.07), transparent 60%), + var(--bg-page); } .external-login-card { width: min(100%, 420px); border: 1px solid var(--border); + border-radius: 20px !important; + background: + linear-gradient(180deg, rgba(214, 181, 110, 0.05), transparent 34%), + var(--bg-card) !important; box-shadow: var(--shadow-panel); } .external-login-card .MuiCardContent-root { display: grid; - gap: 28px; - padding: 32px; + gap: 26px; + padding: 30px 28px 34px; +} + +.external-login-title { + font-family: var(--font-display) !important; + font-size: 24px !important; + letter-spacing: 0.05em; + color: var(--primary-strong) !important; + padding-top: 6px; } .external-language-switcher { @@ -36,8 +171,11 @@ body { } .external-change-password-brand { + color: var(--primary-strong); + font-family: var(--font-display); font-size: 18px; font-weight: 750; + letter-spacing: 0.03em; } .external-full-state { @@ -49,34 +187,61 @@ body { padding: 24px; } +/* --------------------------------------------------------------------------- + * App shell + * ------------------------------------------------------------------------- */ + .external-shell, .external-main { min-height: 100dvh; } +.external-main { + background: transparent; +} + .external-header { - border-bottom: 1px solid var(--border); - background: rgba(255, 255, 255, 0.96) !important; - backdrop-filter: blur(12px); + border-bottom: 1px solid var(--border-soft); + background: var(--bg-header) !important; + backdrop-filter: blur(16px) saturate(130%); + -webkit-backdrop-filter: blur(16px) saturate(130%); + color: var(--text-primary) !important; + padding-top: env(safe-area-inset-top); } .external-header .MuiToolbar-root { - min-height: 64px; + min-height: 60px; gap: 10px; } +.external-toolbar-spacer { + min-height: calc(60px + env(safe-area-inset-top)) !important; +} + +.external-header-title { + font-family: var(--font-display) !important; + font-size: 17px !important; + letter-spacing: 0.03em; +} + .external-user-avatar { width: 34px !important; height: 34px !important; font-size: 13px !important; - background: var(--primary) !important; + background: var(--brand-gradient) !important; + color: var(--active-contrast) !important; + font-weight: 700; } +/* Desktop sidebar */ + .external-sidebar { min-height: 100%; display: flex; flex-direction: column; - background: var(--bg-sidebar); + background: + linear-gradient(180deg, rgba(214, 181, 110, 0.05), transparent 18%), + var(--bg-sidebar); } .external-logo { @@ -85,36 +250,47 @@ body { display: flex; align-items: center; gap: 12px; - padding: 12px 20px; + padding: 14px 20px; } .external-sidebar-brand { + color: var(--primary-strong); + font-family: var(--font-display); font-size: 16px; font-weight: 750; + letter-spacing: 0.05em; } .external-nav { padding: 12px !important; overflow-y: auto; + flex: 1; } .external-nav .MuiListItemButton-root { min-height: 44px; margin-bottom: 4px; - border-radius: 8px; + border-radius: 10px; color: var(--text-secondary); - transition: color var(--motion-fast) var(--ease-standard), background-color var(--motion-fast) var(--ease-standard), transform var(--motion-base) var(--ease-emphasized); + transition: color var(--motion-fast) var(--ease-standard), background-color var(--motion-fast) var(--ease-standard); } .external-nav .MuiListItemButton-root:hover { background: var(--bg-hover); + color: var(--text-primary); } .external-nav .MuiListItemButton-root.Mui-selected, .external-nav .MuiListItemButton-root.Mui-selected:hover { - background: var(--primary-surface-strong); - color: var(--primary); + background: linear-gradient(90deg, rgba(214, 181, 110, 0.16), rgba(214, 181, 110, 0.05)); + color: var(--primary-strong); font-weight: 700; + box-shadow: inset 3px 0 0 0 var(--primary); +} + +[dir="rtl"] .external-nav .MuiListItemButton-root.Mui-selected, +[dir="rtl"] .external-nav .MuiListItemButton-root.Mui-selected:hover { + box-shadow: inset -3px 0 0 0 var(--primary); } .external-nav .MuiListItemIcon-root { @@ -122,10 +298,158 @@ body { color: inherit; } -.external-main { - background: var(--bg-page); +.external-sidebar-footer { + padding: 14px 16px calc(14px + env(safe-area-inset-bottom)); + border-top: 1px solid var(--border-soft); + display: grid; + gap: 10px; } +/* --------------------------------------------------------------------------- + * Mobile bottom tab bar + "more" sheet + * ------------------------------------------------------------------------- */ + +.external-tabbar { + position: fixed; + inset-inline: 0; + bottom: 0; + z-index: 1100; + display: none; + align-items: stretch; + min-height: var(--tabbar-height); + padding: 6px max(8px, env(safe-area-inset-right)) max(6px, env(safe-area-inset-bottom)) max(8px, env(safe-area-inset-left)); + background: var(--bg-header); + backdrop-filter: blur(16px) saturate(130%); + -webkit-backdrop-filter: blur(16px) saturate(130%); + border-top: 1px solid var(--border-soft); +} + +.external-tab { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 3px; + min-height: 50px; + padding: 4px 2px; + border: 0; + border-radius: 12px; + background: none; + color: var(--text-tertiary); + font-family: inherit; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + text-decoration: none; + cursor: pointer; + -webkit-user-select: none; + user-select: none; + transition: color var(--motion-fast) var(--ease-standard), background-color var(--motion-fast) var(--ease-standard); +} + +.external-tab .MuiSvgIcon-root { + font-size: 22px; +} + +.external-tab-label { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.external-tab.is-active { + color: var(--primary-strong); +} + +.external-tab.is-active .MuiSvgIcon-root { + filter: drop-shadow(0 0 10px rgba(214, 181, 110, 0.45)); +} + +.external-tab:active { + background: var(--bg-hover); +} + +/* Bottom sheet (mobile navigation drawer + dialogs share the look) */ + +.external-sheet.MuiDrawer-paper { + border-radius: 22px 22px 0 0; + border-top: 1px solid var(--primary-border); + background: + linear-gradient(180deg, rgba(214, 181, 110, 0.06), transparent 30%), + var(--bg-card); + background-color: var(--bg-card); + box-shadow: var(--shadow-drawer); + padding: 10px 18px calc(18px + env(safe-area-inset-bottom)); + max-height: 86dvh; +} + +.external-sheet-handle { + width: 44px; + height: 4px; + margin: 2px auto 14px; + border-radius: 999px; + background: rgba(214, 181, 110, 0.35); +} + +.external-sheet-account { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + margin-bottom: 14px; + border: 1px solid var(--border-soft); + border-radius: 14px; + background: linear-gradient(135deg, rgba(214, 181, 110, 0.1), rgba(214, 181, 110, 0.02)); +} + +.external-sheet-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + overflow-y: auto; + padding-bottom: 4px; +} + +.external-sheet-item { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 7px; + min-height: 76px; + padding: 10px 4px; + border-radius: 14px; + color: var(--text-secondary); + font-size: 12px; + text-align: center; + text-decoration: none; + transition: color var(--motion-fast) var(--ease-standard), background-color var(--motion-fast) var(--ease-standard); +} + +.external-sheet-item .MuiSvgIcon-root { + font-size: 24px; + color: var(--primary); +} + +.external-sheet-item:active, +.external-sheet-item.is-active { + background: var(--primary-surface); + color: var(--primary-strong); +} + +.external-sheet-item span { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* --------------------------------------------------------------------------- + * Page scaffolding + * ------------------------------------------------------------------------- */ + .external-page { display: grid; gap: 20px; @@ -133,12 +457,17 @@ body { animation: external-page-enter var(--motion-slow) var(--ease-emphasized) both; } +.external-page > .MuiStack-root:first-child h1 { + font-family: var(--font-display); + letter-spacing: 0.03em; +} + .external-session-card, .external-table-container, .external-filter-bar, .external-form-panel, .external-empty { - border: 1px solid var(--border); + border: 1px solid var(--border-soft); border-radius: var(--radius-card); background: var(--bg-card); } @@ -152,32 +481,6 @@ body { min-width: min(100%, 220px); } -.external-capability-grid { - display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); - gap: 12px; -} - -.external-capability-card { - min-height: 150px; - opacity: 0.68; -} - -.external-capability-card.is-enabled { - opacity: 1; -} - -.external-capability-card.is-enabled:hover { - border-color: var(--primary-border); - transform: translateY(-2px); - box-shadow: var(--shadow-soft); -} - -.external-capability-card .MuiCardActionArea-root, -.external-capability-card .MuiCardContent-root { - height: 100%; -} - .external-table-container { overflow-x: auto; } @@ -186,11 +489,23 @@ body { background: var(--bg-card-strong); } +.external-table-container .MuiTableHead-root .MuiTableCell-root { + color: var(--text-tertiary); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + .external-table-container .MuiTableCell-root { white-space: nowrap; border-color: var(--row-border); } +.external-table-container .MuiTableRow-hover:hover { + background: var(--bg-hover) !important; +} + .external-empty { min-height: 200px; display: grid; @@ -211,31 +526,151 @@ body { place-items: center; } +/* Mobile record cards */ + .external-mobile-card .external-mobile-row { display: grid; - grid-template-columns: minmax(92px, 0.38fr) minmax(0, 1fr); + grid-template-columns: minmax(92px, 0.36fr) minmax(0, 1fr); align-items: start; gap: 12px; - padding: 10px 0; + padding: 11px 0; border-bottom: 1px solid var(--row-border); } .external-mobile-card .external-mobile-row:last-child { border-bottom: 0; + padding-bottom: 2px; } +.external-mobile-card .external-mobile-row .MuiButton-root { + min-height: 40px; +} + +/* --------------------------------------------------------------------------- + * Home (overview) + * ------------------------------------------------------------------------- */ + +.external-hero { + position: relative; + overflow: hidden; + padding: 22px 24px; + border: 1px solid var(--primary-border); + border-radius: 18px; + background: + radial-gradient(420px 180px at 12% -30%, rgba(214, 181, 110, 0.22), transparent 70%), + linear-gradient(135deg, #1d1a12 0%, #14141a 55%, #101014 100%); + box-shadow: var(--shadow-soft), inset 0 1px 0 rgba(255, 255, 255, 0.06); +} + +.external-hero-eyebrow { + color: var(--text-tertiary) !important; + font-size: 11px !important; + letter-spacing: 0.28em; + text-transform: uppercase; +} + +.external-hero-title { + font-family: var(--font-display) !important; + font-size: 26px !important; + letter-spacing: 0.03em; + margin: 4px 0 14px !important; +} + +.external-hero-meta { + display: flex; + flex-wrap: wrap; + gap: 10px 28px; +} + +.external-section-title { + color: var(--text-tertiary) !important; + font-size: 12px !important; + font-weight: 700 !important; + letter-spacing: 0.22em; + text-transform: uppercase; +} + +.external-capability-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; +} + +.external-capability-card { + min-height: 140px; + opacity: 0.62; + border-color: var(--border-soft) !important; +} + +.external-capability-card.is-enabled { + opacity: 1; +} + +.external-capability-card.is-enabled:hover { + border-color: var(--primary-border) !important; + transform: translateY(-2px); + box-shadow: var(--shadow-soft), 0 0 0 1px rgba(214, 181, 110, 0.12); +} + +.external-capability-card .MuiCardActionArea-root, +.external-capability-card .MuiCardContent-root { + height: 100%; +} + +.external-capability-icon { + width: 40px; + height: 40px; + display: grid; + place-items: center; + border-radius: 12px; + border: 1px solid rgba(214, 181, 110, 0.35); + background: linear-gradient(135deg, rgba(214, 181, 110, 0.16), rgba(214, 181, 110, 0.04)); + color: var(--primary-strong); +} + +.external-capability-card:not(.is-enabled) .external-capability-icon { + border-color: var(--neutral-border); + background: var(--neutral-surface); + color: var(--text-tertiary); +} + +/* --------------------------------------------------------------------------- + * Dialogs, inputs, misc chrome + * ------------------------------------------------------------------------- */ + .external-dialog-fields { padding-top: 8px; } +.MuiDialog-paper { + border: 1px solid var(--border); + background-image: linear-gradient(180deg, rgba(214, 181, 110, 0.05), transparent 26%) !important; + box-shadow: var(--shadow-dialog) !important; +} + +.MuiBackdrop-root { + background-color: var(--overlay-scrim) !important; + backdrop-filter: blur(3px); +} + +.MuiButton-containedPrimary:not(.Mui-disabled) { + background-image: var(--brand-gradient); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.3), 0 6px 18px rgba(0, 0, 0, 0.4); +} + +.MuiButton-containedPrimary:not(.Mui-disabled):hover { + background-image: linear-gradient(135deg, #f5e6bd 0%, #e0c079 45%, #b28a45 100%); +} + .external-image-field { display: flex; align-items: center; gap: 12px; min-height: 92px; padding: 12px; - border: 1px dashed var(--border); + border: 1px dashed var(--primary-border); border-radius: var(--radius-control); + background: rgba(214, 181, 110, 0.03); } .external-image-field img, @@ -243,7 +678,7 @@ body { width: 104px; height: 68px; object-fit: cover; - border-radius: 6px; + border-radius: 8px; background: var(--bg-card-strong); } @@ -251,7 +686,7 @@ body { width: 88px; height: 50px; object-fit: cover; - border-radius: 6px; + border-radius: 8px; background: var(--bg-card-strong); } @@ -267,23 +702,66 @@ body { to { opacity: 1; transform: translateY(0); } } +/* --------------------------------------------------------------------------- + * Responsive + * ------------------------------------------------------------------------- */ + @media (max-width: 1199px) { .external-capability-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } } @media (max-width: 899px) { - .external-page { padding: 18px; } + .external-tabbar { display: flex; } + .external-page { + padding: 16px 16px calc(var(--tabbar-height) + 24px + env(safe-area-inset-bottom)); + } + /* The fixed top bar already names the page on handsets; the in-page h1 would repeat it. */ + .external-page > .MuiStack-root:first-child h1 { display: none; } .external-capability-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .external-filter-bar .MuiTextField-root { width: 100%; } + .external-filter-bar .MuiButton-root { min-height: var(--control-height); } + .external-hero { padding: 20px; } + .external-hero-title { font-size: 23px !important; } } @media (max-width: 599px) { - .external-login-card .MuiCardContent-root { padding: 24px 20px; } - .external-page { padding: 14px; gap: 14px; } - .external-capability-grid { grid-template-columns: 1fr; } - .external-capability-card { min-height: 128px; } - .external-filter-bar .MuiTextField-root { width: 100%; } + .external-login-card .MuiCardContent-root { padding: 26px 20px 30px; } + .external-page { gap: 14px; padding-inline: 14px; } + .external-capability-grid { gap: 10px; } + .external-capability-card { min-height: 118px; } .external-image-field { align-items: flex-start; flex-direction: column; } .external-header .external-language-switcher { min-width: 94px !important; max-width: 112px; } + .external-hero-meta { gap: 8px 20px; } + + /* Every dialog becomes an app-style bottom sheet on handsets. */ + .MuiDialog-container { + align-items: flex-end !important; + } + .MuiDialog-paper:not(.MuiDialog-paperFullScreen) { + margin: 0 !important; + width: 100% !important; + max-width: 100% !important; + max-height: 90dvh !important; + border-radius: 22px 22px 0 0 !important; + border-bottom: 0; + padding-bottom: env(safe-area-inset-bottom); + } + .MuiDialog-paper:not(.MuiDialog-paperFullScreen)::before { + content: ""; + display: block; + width: 44px; + height: 4px; + margin: 10px auto 0; + border-radius: 999px; + background: rgba(214, 181, 110, 0.35); + } + .MuiDialogActions-root { + padding: 12px 16px 16px !important; + } + .MuiDialogActions-root .MuiButton-root { + min-height: 44px; + flex: 1; + } } @media (prefers-reduced-motion: reduce) { diff --git a/external-admin/src/theme.js b/external-admin/src/theme.js new file mode 100644 index 0000000..1c0f3f2 --- /dev/null +++ b/external-admin/src/theme.js @@ -0,0 +1,69 @@ +import { createTheme } from "@mui/material/styles"; +import { theme as adminTheme } from "@/theme.js"; + +// The partner-facing app ships its own black-gold identity instead of the internal +// admin's light palette; component overrides are reused because they resolve through +// CSS tokens that external-admin/src/styles/index.css re-defines for this bundle. +const palette = { + mode: "dark", + primary: { + main: "#d6b56e", + light: "#ecd7a0", + dark: "#a8823f", + contrastText: "#1a1206" + }, + secondary: { + main: "#9c8a5e" + }, + success: { + main: "#66bd8b" + }, + warning: { + main: "#d9a441" + }, + error: { + main: "#e07b6d" + }, + info: { + main: "#9fb4cc" + }, + background: { + default: "#0d0d11", + paper: "#16161c" + }, + text: { + primary: "#f0e9d8", + secondary: "#b8ad92", + disabled: "#7d7460" + }, + divider: "rgba(214, 181, 110, 0.16)" +}; + +export function createExternalTheme(direction) { + return createTheme({ + direction, + palette, + typography: { + fontFamily: 'Inter, "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", Arial, sans-serif', + fontSize: 14, + button: { + fontSize: 14, + textTransform: "none", + fontWeight: 650 + }, + h5: { + fontSize: 20, + fontWeight: 700, + letterSpacing: "0.01em" + }, + h6: { + fontSize: 17, + fontWeight: 700 + } + }, + shape: { + borderRadius: 10 + }, + components: adminTheme.components + }); +} diff --git a/src/features/app-users/api.test.ts b/src/features/app-users/api.test.ts index b4fe01a..218fad0 100644 --- a/src/features/app-users/api.test.ts +++ b/src/features/app-users/api.test.ts @@ -9,6 +9,7 @@ import { listAppUsers, listPrettyDisplayIDs, recyclePrettyDisplayID, + revokeAppUserResource, unbanAppUser, } from "./api"; @@ -362,6 +363,38 @@ test("getAppUser uses detail endpoint and normalizes assets", async () => { }); }); +test("revokeAppUserResource uses the precise entitlement endpoint", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + JSON.stringify({ + code: 0, + data: { + entitlementId: "ent-host-badge", + equipped: false, + remainingQuantity: 0, + resourceId: 91, + status: "revoked", + }, + }), + ), + ), + ); + + await revokeAppUserResource("318705991371722752", "ent-host-badge", { + reason: "后台用户详情撤回素材:host", + }); + const [url, init] = vi.mocked(fetch).mock.calls[0]; + + expect(String(url)).toContain( + "/api/v1/admin/users/318705991371722752/resources/ent-host-badge/revoke", + ); + expect(init?.method).toBe("POST"); + expect(JSON.parse(String(init?.body))).toEqual({ reason: "后台用户详情撤回素材:host" }); +}); + test("app user projection normalizes levels roles ban and successful login fields", async () => { vi.stubGlobal( "fetch", diff --git a/src/features/app-users/api.ts b/src/features/app-users/api.ts index 0b08f77..6868f7d 100644 --- a/src/features/app-users/api.ts +++ b/src/features/app-users/api.ts @@ -48,6 +48,29 @@ export async function getAppUser(userId: EntityId): Promise { return normalizeAppUser(data); } +export interface RevokeAppUserResourcePayload { + reason: string; +} + +// 详情页按 entitlement 撤回单个素材,不能复用 grant 级撤销;后者会把同一资源组内的其它素材一并回收。 +export function revokeAppUserResource( + userId: EntityId, + entitlementId: EntityId, + payload: RevokeAppUserResourcePayload, +): Promise { + const endpoint = API_ENDPOINTS.revokeAppUserResource; + return apiRequest( + apiEndpointPath(API_OPERATIONS.revokeAppUserResource, { + entitlement_id: entitlementId, + user_id: userId, + }), + { + body: payload, + method: endpoint.method, + }, + ); +} + export async function listAppUserLoginLogs(query: PageQuery = {}): Promise> { const endpoint = API_ENDPOINTS.appListLoginLogs; const data = await apiRequest & { page_size?: number }>( diff --git a/src/features/app-users/app-users.module.css b/src/features/app-users/app-users.module.css index 8cbaab7..d1198fa 100644 --- a/src/features/app-users/app-users.module.css +++ b/src/features/app-users/app-users.module.css @@ -913,7 +913,7 @@ .resourceItem { display: grid; - grid-template-columns: 44px minmax(0, 1fr); + grid-template-columns: 44px minmax(0, 1fr) auto; gap: var(--space-3); align-items: center; min-width: 0; @@ -923,6 +923,11 @@ background: var(--bg-card); } +.resourceRevokeButton { + min-width: 72px; + white-space: nowrap; +} + .resourceThumb, .resourceThumbFallback { width: 44px; diff --git a/src/features/app-users/components/AppUserDetailProvider.jsx b/src/features/app-users/components/AppUserDetailProvider.jsx index cfec46c..cb0cb84 100644 --- a/src/features/app-users/components/AppUserDetailProvider.jsx +++ b/src/features/app-users/components/AppUserDetailProvider.jsx @@ -60,12 +60,13 @@ export function AppUserDetailProvider({ children }) { open={Boolean(detailUser)} user={detailUser} onClose={closeAppUserDetail} + onReloadUser={() => openAppUserDetail(detailUser)} /> ); } -function AppUserDetailDrawer({ loading, onClose, open, user }) { +function AppUserDetailDrawer({ loading, onClose, onReloadUser, open, user }) { if (!open || !user) { return null; } @@ -83,6 +84,7 @@ function AppUserDetailDrawer({ loading, onClose, open, user }) { loading={loading} user={user} onOpenFullDetails={onClose} + onReloadUser={onReloadUser} /> ); diff --git a/src/features/app-users/components/AppUserDetailProvider.test.jsx b/src/features/app-users/components/AppUserDetailProvider.test.jsx index 4ee72ee..7fc1a11 100644 --- a/src/features/app-users/components/AppUserDetailProvider.test.jsx +++ b/src/features/app-users/components/AppUserDetailProvider.test.jsx @@ -1,12 +1,17 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, expect, test, vi } from "vitest"; import { MemoryRouter } from "react-router-dom"; +import { ConfirmProvider } from "@/shared/ui/ConfirmProvider.jsx"; import { ToastProvider } from "@/shared/ui/ToastProvider.jsx"; -import { getAppUser, issueAppUserAccessToken } from "@/features/app-users/api"; +import { getAppUser, issueAppUserAccessToken, revokeAppUserResource } from "@/features/app-users/api"; import { useAppUserDetail } from "@/features/app-users/components/AppUserDetailContext.jsx"; import { AppUserDetailProvider } from "@/features/app-users/components/AppUserDetailProvider.jsx"; -vi.mock("@/features/app-users/api", () => ({ getAppUser: vi.fn(), issueAppUserAccessToken: vi.fn() })); +vi.mock("@/features/app-users/api", () => ({ + getAppUser: vi.fn(), + issueAppUserAccessToken: vi.fn(), + revokeAppUserResource: vi.fn(), +})); beforeEach(() => { vi.clearAllMocks(); @@ -81,11 +86,13 @@ test("detail drawer keeps the identity hero and shows the expanded admin project render( - - - - - + + + + + + + , ); fireEvent.click(screen.getByRole("button", { name: "打开详情" })); @@ -129,6 +136,72 @@ test("detail drawer keeps the identity hero and shows the expanded admin project expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); +test("detail drawer revokes one material by entitlement and reloads owner state", async () => { + const userWithHostBadge = { + defaultDisplayUserId: "163337", + equippedResources: [ + { + entitlementId: "ent-host-badge", + equipped: true, + name: "host", + quantity: 1, + remainingQuantity: 1, + resourceId: 91, + resourceType: "badge", + status: "active", + }, + ], + resources: [ + { + entitlementId: "ent-host-badge", + equipped: true, + name: "host", + quantity: 1, + remainingQuantity: 1, + resourceId: 91, + resourceType: "badge", + status: "active", + }, + ], + status: "active", + userId: "10001", + username: "tester", + }; + vi.mocked(getAppUser) + .mockResolvedValueOnce(userWithHostBadge) + .mockResolvedValueOnce({ ...userWithHostBadge, equippedResources: [], resources: [] }); + vi.mocked(revokeAppUserResource).mockResolvedValue({}); + + render( + + + + + + + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "打开详情" })); + fireEvent.click(await screen.findByRole("tab", { name: "资源 (2)" })); + + const revokeButtons = screen.getAllByRole("button", { name: "撤回素材 host" }); + expect(revokeButtons).toHaveLength(2); + fireEvent.click(revokeButtons[0]); + expect(screen.getByText(/同一资源组的其他素材不受影响/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "确认撤回" })); + + await waitFor(() => + expect(revokeAppUserResource).toHaveBeenCalledWith("10001", "ent-host-badge", { + reason: "后台用户详情撤回素材:host", + }), + ); + await waitFor(() => expect(getAppUser).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(screen.queryByRole("button", { name: "撤回素材 host" })).not.toBeInTheDocument()); + expect(screen.getAllByText("当前无数据")).toHaveLength(2); +}); + function DetailTrigger() { const { openAppUserDetail } = useAppUserDetail(); return ( diff --git a/src/features/app-users/components/AppUserDetailView.jsx b/src/features/app-users/components/AppUserDetailView.jsx index 0e33379..392297d 100644 --- a/src/features/app-users/components/AppUserDetailView.jsx +++ b/src/features/app-users/components/AppUserDetailView.jsx @@ -1,13 +1,16 @@ import ContentCopyOutlined from "@mui/icons-material/ContentCopyOutlined"; import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined"; +import UndoOutlined from "@mui/icons-material/UndoOutlined"; import Tab from "@mui/material/Tab"; import Tabs from "@mui/material/Tabs"; import { useEffect, useState } from "react"; import { Link, useLocation } from "react-router-dom"; import { AdminPrettyDisplayID, AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx"; import { Button } from "@/shared/ui/Button.jsx"; +import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx"; +import { useToast } from "@/shared/ui/ToastProvider.jsx"; import { formatMillis } from "@/shared/utils/time.js"; -import { issueAppUserAccessToken } from "@/features/app-users/api"; +import { issueAppUserAccessToken, revokeAppUserResource } from "@/features/app-users/api"; import { appUserStatusLabels } from "@/features/app-users/constants.js"; import { BanCountdown, @@ -33,6 +36,7 @@ export function AppUserDetailView({ loading = false, mode = "drawer", onOpenFullDetails, + onReloadUser, user, }) { const location = useLocation(); @@ -103,7 +107,12 @@ export function AppUserDetailView({ {activeTab === "overview" ? : null} {activeTab === "assets" ? : null} {activeTab === "resources" ? ( - + ) : null} {activeTab === "status" ? : null} {activeTab === "device" ? : null} @@ -167,14 +176,65 @@ function AssetsPanel({ balances, user }) { ); } -function ResourcesPanel({ equippedResources, resources }) { +function ResourcesPanel({ equippedResources, onReloadUser, resources, user }) { + const confirm = useConfirm(); + const { showToast } = useToast(); + const [revokingEntitlementId, setRevokingEntitlementId] = useState(""); + + const revokeResource = async (resource) => { + const entitlementId = String(resource?.entitlementId || "").trim(); + if (!entitlementId || revokingEntitlementId) { + return; + } + const resourceName = resourceDisplayName(resource); + const accepted = await confirm({ + title: "撤回用户素材", + message: `撤回后,用户「${user.username || user.displayUserId || user.userId}」将立即失去素材「${resourceName}」;若正在佩戴会同时卸下。同一资源组的其他素材不受影响。`, + confirmText: "确认撤回", + tone: "danger", + }); + if (!accepted) { + return; + } + + setRevokingEntitlementId(entitlementId); + try { + // entitlement 是用户素材实例的唯一标识;按它撤回才能避免 sourceGrantId 对应资源组时误伤同组素材。 + await revokeAppUserResource(user.userId, entitlementId, { + reason: `后台用户详情撤回素材:${resourceName}`.slice(0, 256), + }); + } catch (error) { + showToast(error.message || "素材撤回失败", "error"); + setRevokingEntitlementId(""); + return; + } + + showToast(`素材「${resourceName}」已撤回`, "success"); + try { + // 撤回成功后重新读取 owner 服务事实,不能只在前端本地删行掩盖后端未生效或残留佩戴状态。 + await onReloadUser?.(); + } catch (error) { + showToast(error.message || "素材已撤回,但用户详情刷新失败", "warning"); + } finally { + setRevokingEntitlementId(""); + } + }; + return ( <> - + - + ); @@ -437,7 +497,7 @@ function AssetBalanceList({ balances }) { ); } -function ResourceList({ resources }) { +function ResourceList({ onRevokeResource, resources, revokingEntitlementId }) { if (!resources.length) { return
    当前无数据
    ; } @@ -448,7 +508,7 @@ function ResourceList({ resources }) {
    - {resource.name || resource.resourceCode || `资源 ${resource.resourceId}`} + {resourceDisplayName(resource)} {resource.equipped ? 佩戴中 : null}
    @@ -456,12 +516,28 @@ function ResourceList({ resources }) {
    到期 {formatExpireTime(resource.expiresAtMs)}
    + {resource.entitlementId ? ( + + ) : null} ))} ); } +function resourceDisplayName(resource) { + return resource?.name || resource?.resourceCode || `资源 ${resource?.resourceId || "-"}`; +} + function ResourceThumb({ resource }) { const imageUrl = resource.previewUrl || resource.assetUrl || resource.animationUrl; if (!imageUrl) { diff --git a/src/features/app-users/pages/AppUserDetailPage.jsx b/src/features/app-users/pages/AppUserDetailPage.jsx index e1fd0bf..8a91ec4 100644 --- a/src/features/app-users/pages/AppUserDetailPage.jsx +++ b/src/features/app-users/pages/AppUserDetailPage.jsx @@ -47,7 +47,7 @@ export function AppUserDetailPage() {
    - {data ? : null} + {data ? : null}
    diff --git a/src/features/app-users/pages/AppUserDetailPage.test.jsx b/src/features/app-users/pages/AppUserDetailPage.test.jsx index 50fceae..8962610 100644 --- a/src/features/app-users/pages/AppUserDetailPage.test.jsx +++ b/src/features/app-users/pages/AppUserDetailPage.test.jsx @@ -2,11 +2,17 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { expect, test, vi } from "vitest"; import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; import { QueryProvider } from "@/shared/query/QueryProvider.jsx"; +import { ConfirmProvider } from "@/shared/ui/ConfirmProvider.jsx"; +import { ToastProvider } from "@/shared/ui/ToastProvider.jsx"; import { getAppUser } from "@/features/app-users/api"; import { AppUserDetailPage } from "@/features/app-users/pages/AppUserDetailPage.jsx"; // AppUserDetailView 会静态导入 issueAppUserAccessToken,mock 工厂缺少该导出会直接导致模块加载失败。 -vi.mock("@/features/app-users/api", () => ({ getAppUser: vi.fn(), issueAppUserAccessToken: vi.fn() })); +vi.mock("@/features/app-users/api", () => ({ + getAppUser: vi.fn(), + issueAppUserAccessToken: vi.fn(), + revokeAppUserResource: vi.fn(), +})); test("full user detail route loads the URL user and returns to its source list", async () => { vi.mocked(getAppUser).mockResolvedValue({ @@ -26,19 +32,23 @@ test("full user detail route loads the URL user and returns to its source list", render( - - - } /> - } /> - - + + + + + } /> + } /> + + + + , ); diff --git a/src/features/resources/api.test.ts b/src/features/resources/api.test.ts index 04e36a9..5e15a85 100644 --- a/src/features/resources/api.test.ts +++ b/src/features/resources/api.test.ts @@ -433,6 +433,7 @@ test("resource shop APIs use generated admin paths", async () => { await upsertResourceShopItems({ items: [ { + coinPrice: 120, durationDays: 3, effectiveFromMs: 0, effectiveToMs: 0, @@ -462,7 +463,7 @@ test("resource shop APIs use generated admin paths", async () => { expect(String(upsertUrl)).toContain("/api/v1/admin/resource-shop/items"); expect(upsertInit?.method).toBe("POST"); expect(JSON.parse(String(upsertInit?.body))).toMatchObject({ - items: [{ durationDays: 3, resourceId: 11, status: "active" }], + items: [{ coinPrice: 120, durationDays: 3, resourceId: 11, status: "active" }], }); expect(String(enableUrl)).toContain("/api/v1/admin/resource-shop/items/99/enable"); expect(enableInit?.method).toBe("POST"); diff --git a/src/features/resources/api.ts b/src/features/resources/api.ts index f407fab..be656ff 100644 --- a/src/features/resources/api.ts +++ b/src/features/resources/api.ts @@ -328,6 +328,7 @@ export interface ResourceGroupPayload { } export interface ResourceShopItemPayload { + coinPrice: number; durationDays: number; effectiveFromMs: number; effectiveToMs: number; diff --git a/src/features/resources/hooks/useResourcePages.js b/src/features/resources/hooks/useResourcePages.js index 6a834c7..323ba78 100644 --- a/src/features/resources/hooks/useResourcePages.js +++ b/src/features/resources/hooks/useResourcePages.js @@ -49,6 +49,7 @@ import { cpRelationTypeOptions, defaultGiftTypeOptions, resourceIdentityAutoGrantOptions, + resourceShopDurationOptions, resourceShopSellableTypes, } from "@/features/resources/constants.js"; import { useResourceAbilities } from "@/features/resources/permissions.js"; @@ -314,6 +315,68 @@ export async function fetchAllOptionPages(fetcher, query = {}, pageSize = option throw new Error("资源选项分页过多,请收窄筛选条件"); } +export function groupResourceShopItems(items = []) { + const itemsByResourceId = new Map(); + + // 同一资源可以同时售卖 1/3/7 天规格,按资源分组时必须保留完整数组,不能再用单值 Map 覆盖前面的规格。 + items.forEach((item) => { + const resourceId = Number(item?.resourceId || 0); + if (!Number.isInteger(resourceId) || resourceId <= 0) { + return; + } + const resourceItems = itemsByResourceId.get(resourceId) || []; + resourceItems.push(item); + itemsByResourceId.set(resourceId, resourceItems); + }); + itemsByResourceId.forEach((resourceItems) => { + resourceItems.sort((left, right) => Number(left.durationDays || 0) - Number(right.durationDays || 0)); + }); + return itemsByResourceId; +} + +export function buildResourceShopDraftGroup(resource, existingItems = [], fallbackSortOrder = 0) { + const resourceId = String(resource?.resourceId || ""); + const allowedDurations = new Set(resourceShopDurationOptions.map(([durationDays]) => durationDays)); + const specsByDuration = {}; + + existingItems.forEach((item) => { + const durationDays = String(item?.durationDays || "1"); + if (!allowedDurations.has(durationDays)) { + return; + } + specsByDuration[durationDays] = resourceShopSpecDraft(resource, durationDays, item, fallbackSortOrder); + }); + + // 首次选择资源默认创建 1 天规格并继承资源列表价格;其他天数由运营按需勾选且各自维护价格。 + if (Object.keys(specsByDuration).length === 0) { + specsByDuration["1"] = resourceShopSpecDraft(resource, "1", null, fallbackSortOrder); + } + return { resourceId, specsByDuration }; +} + +export function canUnselectResourceShopDraftSpec(spec) { + // 已落库规格只能在商店列表通过状态 Switch 启停;从抽屉静默移除不会删除数据库行,因此必须阻止这种误导操作。 + return !spec?.shopItemId; +} + +export function canUnselectResourceShopDraftGroup(draftGroup) { + // 父级取消会一次丢弃全部规格;只要其中一条已落库,就必须保留整组并让运营逐条通过列表 Switch 启停。 + return Object.values(draftGroup?.specsByDuration || {}).every(canUnselectResourceShopDraftSpec); +} + +function resourceShopSpecDraft(resource, durationDays, existingItem, fallbackSortOrder) { + return { + coinPrice: String(existingItem?.coinPrice ?? resource?.coinPrice ?? ""), + durationDays, + effectiveFromMs: existingItem?.effectiveFromMs || "", + effectiveToMs: existingItem?.effectiveToMs || "", + resourceId: String(resource?.resourceId || existingItem?.resourceId || ""), + shopItemId: existingItem?.shopItemId, + sortOrder: String(existingItem?.sortOrder ?? fallbackSortOrder), + status: existingItem?.status || "active", + }; +} + function loadResourceGrantOptions() { return Promise.all([ fetchAllOptionPages(listResources, { status: "active" }), @@ -685,9 +748,11 @@ export function useResourceShopPage() { const [purchaseKeyword, setPurchaseKeyword] = useState(""); const [drawerQuery, setDrawerQuery] = useState(""); const [drawerResourceType, setDrawerResourceType] = useState(""); - const [batchDurationDays, setBatchDurationDays] = useState("1"); const [resourceOptions, setResourceOptions] = useState([]); + const [shopItemOptions, setShopItemOptions] = useState([]); const [resourceOptionsLoading, setResourceOptionsLoading] = useState(false); + const [resourceOptionsError, setResourceOptionsError] = useState(""); + const [resourceOptionsLoadVersion, setResourceOptionsLoadVersion] = useState(0); const [draftByResourceId, setDraftByResourceId] = useState({}); const [loadingAction, setLoadingAction] = useState(""); const filters = useMemo( @@ -732,25 +797,18 @@ export function useResourceShopPage() { queryKey: ["resource-shop-purchase-orders", purchaseDrawerOpen, purchaseFilters, purchasePage], }); const shopItemsByResourceId = useMemo(() => { - const items = new Map(); - (result.data?.items || []).forEach((item) => { - if (item.resourceId) { - items.set(Number(item.resourceId), item); - } - }); - return items; - }, [result.data?.items]); + return groupResourceShopItems(shopItemOptions); + }, [shopItemOptions]); const selectedDrafts = useMemo( () => Object.values(draftByResourceId) - .map((draft) => ({ - ...draft, - resource: resourceOptions.find( - (resource) => String(resource.resourceId) === String(draft.resourceId), - ), - })) - .sort((left, right) => Number(left.sortOrder || 0) - Number(right.sortOrder || 0)), - [draftByResourceId, resourceOptions], + .flatMap((draftGroup) => Object.values(draftGroup.specsByDuration || {})) + .sort( + (left, right) => + Number(left.sortOrder || 0) - Number(right.sortOrder || 0) || + Number(left.durationDays || 0) - Number(right.durationDays || 0), + ), + [draftByResourceId], ); const filteredResourceOptions = useMemo(() => { const keyword = drawerQuery.trim().toLowerCase(); @@ -771,15 +829,28 @@ export function useResourceShopPage() { } let ignore = false; setResourceOptionsLoading(true); - fetchAllOptionPages(listResources, { status: "active" }) - .then((data) => { + setResourceOptionsError(""); + setResourceOptions([]); + setShopItemOptions([]); + // 抽屉同时拉全资源和现有售卖项,避免当前列表分页把同一资源的其他天数规格漏掉或误判为新增。 + Promise.all([ + fetchAllOptionPages(listResources, { status: "active" }), + fetchAllOptionPages(listResourceShopItems), + ]) + .then(([resourceData, shopItemData]) => { if (!ignore) { - setResourceOptions(toShopResourceOptions(data.items || [])); + setResourceOptions(toShopResourceOptions(resourceData.items || [])); + setShopItemOptions(shopItemData.items || []); } }) .catch((err) => { if (!ignore) { - showToast(err.message || "加载售卖资源失败", "error"); + const message = err.message || "加载售卖资源失败"; + // 任一请求失败都不能沿用上次候选;否则“旧资源 + 空规格”会让运营误以为是在新增商品。 + setResourceOptions([]); + setShopItemOptions([]); + setResourceOptionsError(message); + showToast(message, "error"); } }) .finally(() => { @@ -790,19 +861,34 @@ export function useResourceShopPage() { return () => { ignore = true; }; - }, [drawerOpen, showToast]); + }, [drawerOpen, resourceOptionsLoadVersion, showToast]); const openShopDrawer = () => { setDraftByResourceId({}); setDrawerQuery(""); setDrawerResourceType(""); - setBatchDurationDays("1"); + setResourceOptions([]); + setShopItemOptions([]); + setResourceOptionsError(""); + setResourceOptionsLoadVersion((current) => current + 1); setDrawerOpen(true); }; + const reloadShopDrawerOptions = () => { + if (!drawerOpen) { + return; + } + setDraftByResourceId({}); + setResourceOptionsError(""); + setResourceOptionsLoadVersion((current) => current + 1); + }; + const closeShopDrawer = () => { setDrawerOpen(false); setDraftByResourceId({}); + setResourceOptions([]); + setShopItemOptions([]); + setResourceOptionsError(""); }; const openPurchaseDrawer = () => { @@ -821,41 +907,78 @@ export function useResourceShopPage() { const resourceId = String(resource.resourceId); setDraftByResourceId((current) => { if (current[resourceId]) { + if (!canUnselectResourceShopDraftGroup(current[resourceId])) { + return current; + } const next = { ...current }; delete next[resourceId]; return next; } - const existing = shopItemsByResourceId.get(Number(resource.resourceId)); return { ...current, - [resourceId]: { - durationDays: String(existing?.durationDays || batchDurationDays), - effectiveFrom: msToDatetimeLocal(existing?.effectiveFromMs), - effectiveTo: msToDatetimeLocal(existing?.effectiveToMs), - resourceId, - sortOrder: String(existing?.sortOrder ?? Object.keys(current).length), - }, + [resourceId]: buildResourceShopDraftGroup( + resource, + shopItemsByResourceId.get(Number(resource.resourceId)) || [], + Object.keys(current).length, + ), }; }); }; - const updateDraftItem = (resourceId, patch) => { - setDraftByResourceId((current) => ({ - ...current, - [resourceId]: { - ...current[resourceId], - ...patch, - }, - })); + const toggleDraftDuration = (resource, durationDays) => { + const resourceId = String(resource?.resourceId || ""); + const normalizedDuration = String(durationDays || ""); + if (!resourceId || !normalizedDuration) { + return; + } + setDraftByResourceId((current) => { + const draftGroup = current[resourceId]; + if (!draftGroup) { + return current; + } + const specsByDuration = { ...draftGroup.specsByDuration }; + if (specsByDuration[normalizedDuration]) { + if (!canUnselectResourceShopDraftSpec(specsByDuration[normalizedDuration])) { + return current; + } + delete specsByDuration[normalizedDuration]; + } else { + // 重新勾选已有规格时恢复其独立价格和主键;真正的新规格才继承资源列表价格。 + const existingItem = (shopItemsByResourceId.get(Number(resourceId)) || []).find( + (item) => String(item.durationDays || "1") === normalizedDuration, + ); + specsByDuration[normalizedDuration] = resourceShopSpecDraft( + resource, + normalizedDuration, + existingItem, + Object.keys(current).indexOf(resourceId), + ); + } + return { + ...current, + [resourceId]: { ...draftGroup, specsByDuration }, + }; + }); }; - const applyBatchDuration = (durationDays) => { - setBatchDurationDays(durationDays); - setDraftByResourceId((current) => - Object.fromEntries( - Object.entries(current).map(([resourceId, draft]) => [resourceId, { ...draft, durationDays }]), - ), - ); + const updateDraftSpec = (resourceId, durationDays, patch) => { + setDraftByResourceId((current) => { + const draftGroup = current[resourceId]; + const draft = draftGroup?.specsByDuration?.[durationDays]; + if (!draft) { + return current; + } + return { + ...current, + [resourceId]: { + ...draftGroup, + specsByDuration: { + ...draftGroup.specsByDuration, + [durationDays]: { ...draft, ...patch }, + }, + }, + }; + }); }; const submitShopItems = async (event) => { @@ -866,15 +989,17 @@ export function useResourceShopPage() { setLoadingAction("resource-shop-upsert"); try { const parsed = parseForm(resourceShopItemsFormSchema, { items: selectedDrafts }); + // 一个资源的每个天数规格都是独立商品行,价格、时间和排序必须逐条提交,不能再按资源合并。 await upsertResourceShopItems({ items: parsed.items.map((item) => ({ + coinPrice: Number(item.coinPrice), durationDays: Number(item.durationDays), - effectiveFromMs: datetimeLocalToMs(item.effectiveFrom), - effectiveToMs: datetimeLocalToMs(item.effectiveTo), + effectiveFromMs: Number(item.effectiveFromMs || 0), + effectiveToMs: Number(item.effectiveToMs || 0), resourceId: Number(item.resourceId), - shopItemId: shopItemsByResourceId.get(Number(item.resourceId))?.shopItemId, + shopItemId: item.shopItemId ? Number(item.shopItemId) : undefined, sortOrder: Number(item.sortOrder || 0), - status: "active", + status: item.status || "active", })), }); showToast("售卖资源已保存", "success"); @@ -920,8 +1045,6 @@ export function useResourceShopPage() { return { ...sharedPageState({ page, query, result, setPage, setQuery }), abilities, - applyBatchDuration, - batchDurationDays, changeDrawerQuery: setDrawerQuery, changeDrawerResourceType: setDrawerResourceType, changeResourceType: resetSetter(setResourceType, setPage), @@ -931,6 +1054,7 @@ export function useResourceShopPage() { drawerOpen, drawerQuery, drawerResourceType, + draftByResourceId, filteredResourceOptions, loadingAction, openShopDrawer, @@ -944,7 +1068,9 @@ export function useResourceShopPage() { purchaseResourceType, purchaseUserFilter, reloadPurchaseOrders: purchaseResult.reload, + reloadShopDrawerOptions, setPurchasePage, + resourceOptionsError, resourceOptionsLoading, resourceType, resetFilters, @@ -953,11 +1079,12 @@ export function useResourceShopPage() { status, submitShopItems, toggleDraftResource, + toggleDraftDuration, toggleShopItem, changePurchaseKeyword: resetSetter(setPurchaseKeyword, setPurchasePage), changePurchaseResourceType: resetSetter(setPurchaseResourceType, setPurchasePage), changePurchaseUserFilter: resetSetter(setPurchaseUserFilter, setPurchasePage), - updateDraftItem, + updateDraftSpec, }; } diff --git a/src/features/resources/hooks/useResourcePages.test.js b/src/features/resources/hooks/useResourcePages.test.js index 9d5f8d7..2336587 100644 --- a/src/features/resources/hooks/useResourcePages.test.js +++ b/src/features/resources/hooks/useResourcePages.test.js @@ -2,11 +2,15 @@ import { expect, test, vi } from "vitest"; import { applyGiftPriceDefaults, applyGiftResourceSelection, + buildResourceShopDraftGroup, buildGiftResourceDrafts, + canUnselectResourceShopDraftGroup, + canUnselectResourceShopDraftSpec, cpRelationTypeFromGift, cpRelationTypeFromPresentationJson, cpRelationTypeShortLabel, fetchAllOptionPages, + groupResourceShopItems, normalizeGiftPresentationJson, } from "./useResourcePages.js"; @@ -134,6 +138,45 @@ test("fetches all option pages instead of only the first resource page", async ( expect(result.total).toBe(5); }); +test("groups every shop duration instead of overwriting the same resource", () => { + const itemsByResourceId = groupResourceShopItems([ + { durationDays: 7, resourceId: 11, shopItemId: 3 }, + { durationDays: 1, resourceId: 11, shopItemId: 1 }, + { durationDays: 3, resourceId: 11, shopItemId: 2 }, + ]); + + expect(itemsByResourceId.get(11)?.map((item) => item.shopItemId)).toEqual([1, 2, 3]); +}); + +test("builds independent shop specs and defaults a new spec to the resource price", () => { + const resource = { coinPrice: 100, resourceId: 11 }; + const existingGroup = buildResourceShopDraftGroup(resource, [ + { coinPrice: 260, durationDays: 7, resourceId: 11, shopItemId: 3 }, + { coinPrice: 120, durationDays: 3, resourceId: 11, shopItemId: 2 }, + ]); + const newGroup = buildResourceShopDraftGroup(resource); + + expect(existingGroup.specsByDuration["3"]).toMatchObject({ + coinPrice: "120", + durationDays: "3", + shopItemId: 2, + }); + expect(existingGroup.specsByDuration["7"]).toMatchObject({ + coinPrice: "260", + durationDays: "7", + shopItemId: 3, + }); + expect(newGroup.specsByDuration["1"]).toMatchObject({ + coinPrice: "100", + durationDays: "1", + resourceId: "11", + }); + expect(canUnselectResourceShopDraftSpec(existingGroup.specsByDuration["3"])).toBe(false); + expect(canUnselectResourceShopDraftSpec(newGroup.specsByDuration["1"])).toBe(true); + expect(canUnselectResourceShopDraftGroup(existingGroup)).toBe(false); + expect(canUnselectResourceShopDraftGroup(newGroup)).toBe(true); +}); + test("reads CP relation type from gift presentation json", () => { expect(cpRelationTypeFromPresentationJson('{"cp_relation_type":"brother"}')).toBe("brother"); expect(cpRelationTypeFromPresentationJson('{"cpRelationType":"sister"}')).toBe("sister"); diff --git a/src/features/resources/mp4AlphaLayout.js b/src/features/resources/mp4AlphaLayout.js index aadd91b..654bbc5 100644 --- a/src/features/resources/mp4AlphaLayout.js +++ b/src/features/resources/mp4AlphaLayout.js @@ -123,13 +123,15 @@ export function updateMp4AlphaLayoutKind(metadataJson, alphaLayout) { if (!layout) { return normalizeMetadataJSON(metadataJson); } + const keepCustomFrames = alphaLayout === "custom"; const nextLayout = createMp4AlphaLayout({ alphaLayout, - alphaFrame: layout.alpha_frame, + // 左右/上下方向变化时必须按新方向重算矩形;沿用旧矩形会让预览仍显示上一个通道顺序。 + alphaFrame: keepCustomFrames ? layout.alpha_frame : undefined, confirmed: false, confidence: layout.confidence, detectVersion: layout.detect_version, - rgbFrame: layout.rgb_frame, + rgbFrame: keepCustomFrames ? layout.rgb_frame : undefined, videoH: layout.video_h, videoW: layout.video_w, }); diff --git a/src/features/resources/pages/ResourceListPage.jsx b/src/features/resources/pages/ResourceListPage.jsx index 16d3e0f..98bf9bb 100644 --- a/src/features/resources/pages/ResourceListPage.jsx +++ b/src/features/resources/pages/ResourceListPage.jsx @@ -75,6 +75,7 @@ import { mp4AlphaLayoutOptions, mp4LayoutLabel, readMp4VideoInfo, + removeMp4AlphaLayoutMetadata, updateMp4AlphaLayoutFrame, updateMp4AlphaLayoutKind, } from "@/features/resources/mp4AlphaLayout.js"; @@ -995,39 +996,42 @@ function mp4ResourceAnimationSource(resource) { } function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open, setForm, uploadDisabled }) { - const submitDisabled = disabled || loading || hasUnconfirmedMp4Layout(form.metadataJson); - const { showToast } = useToast(); + const [animationUploading, setAnimationUploading] = useState(false); + const [previewUploading, setPreviewUploading] = useState(false); + const submitDisabled = + disabled || loading || animationUploading || previewUploading || hasUnconfirmedMp4Layout(form.metadataJson); const profileCardLayout = form.resourceType === "profile_card" ? profileCardLayoutFromMetadata(form.metadataJson) : null; const mp4Layout = mp4AlphaLayoutFromMetadata(form.metadataJson); const handleAnimationSelected = async (file) => { + let detectedMp4Layout = null; if (isMp4File(file)) { - try { - const layout = await detectMp4AlphaLayout(file); - setForm((previous) => ({ - ...previous, - metadataJson: mergeMp4AlphaLayoutMetadata(previous.metadataJson, layout), - })); - } catch (err) { - showToast(err.message || "MP4 透明布局解析失败", "error"); + detectedMp4Layout = await detectMp4AlphaLayout(file); + } + let detectedProfileCardLayout = null; + if (form.resourceType === "profile_card") { + detectedProfileCardLayout = await detectProfileCardLayout(file); + } + // 只返回当次素材的检测增量,上传成功后再合并到最新表单,避免异步旧快照覆盖用户新修改。 + return { mp4Layout: detectedMp4Layout, profileCardLayout: detectedProfileCardLayout }; + }; + const handleAnimationChange = (animationUrl, selectionResult) => { + setForm((previous) => { + let metadataJson = previous.metadataJson; + if (!animationUrl) { + // 清除素材时,两类从该素材派生的布局元数据都必须同步清理。 + metadataJson = removeProfileCardLayoutMetadata(removeMp4AlphaLayoutMetadata(metadataJson)); + } else if (selectionResult) { + metadataJson = selectionResult.mp4Layout + ? mergeMp4AlphaLayoutMetadata(metadataJson, selectionResult.mp4Layout) + : removeMp4AlphaLayoutMetadata(metadataJson); + metadataJson = + previous.resourceType === "profile_card" && selectionResult.profileCardLayout + ? metadataWithProfileCardLayout(metadataJson, selectionResult.profileCardLayout) + : removeProfileCardLayoutMetadata(metadataJson); } - } - if (form.resourceType !== "profile_card") { - return; - } - try { - const layout = await detectProfileCardLayout(file); - setForm((previous) => - previous.resourceType === "profile_card" - ? { - ...previous, - metadataJson: metadataWithProfileCardLayout(previous.metadataJson, layout), - } - : previous, - ); - } catch (err) { - showToast(err.message || "资料卡内容高度解析失败", "error"); - } + return { ...previous, animationUrl, metadataJson }; + }); }; return ( setForm({ ...form, resourceCode: event.target.value })} /> setForm((previous) => ({ ...previous, previewUrl }))} + onUploadingChange={setPreviewUploading} /> setForm((previous) => ({ ...previous, animationUrl }))} + onChange={handleAnimationChange} onFileSelected={handleAnimationSelected} + onUploadingChange={setAnimationUploading} /> {mp4Layout ? ( setForm((previous) => ({ ...previous, metadataJson }))} /> diff --git a/src/features/resources/pages/ResourceShopPage.jsx b/src/features/resources/pages/ResourceShopPage.jsx index b04bfe3..8e26736 100644 --- a/src/features/resources/pages/ResourceShopPage.jsx +++ b/src/features/resources/pages/ResourceShopPage.jsx @@ -16,6 +16,7 @@ import { AdminListToolbar, } from "@/shared/ui/AdminListLayout.jsx"; import { SideDrawer } from "@/shared/ui/SideDrawer.jsx"; +import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx"; import { createOptionsColumnFilter, createTextColumnFilter, createUserIdentityColumnFilter } from "@/shared/ui/tableFilters.js"; import { formatMillis } from "@/shared/utils/time.js"; import { @@ -25,7 +26,11 @@ import { resourceStatusFilters, resourceTypeLabels, } from "@/features/resources/constants.js"; -import { useResourceShopPage } from "@/features/resources/hooks/useResourcePages.js"; +import { + canUnselectResourceShopDraftGroup, + canUnselectResourceShopDraftSpec, + useResourceShopPage, +} from "@/features/resources/hooks/useResourcePages.js"; import styles from "@/features/resources/resources.module.css"; const shopColumns = [ @@ -201,7 +206,12 @@ function ResourceShopDrawer({ page }) { 取消
    - + {page.filteredResourceOptions.length > 0 ? (
    {page.filteredResourceOptions.map((resource) => ( @@ -335,61 +336,94 @@ function ResourceShopPurchaseDrawer({ page }) { function ResourceSelectionRow({ disabled, page, resource }) { const resourceId = String(resource.resourceId); const selected = page.selectedResourceIds.has(resourceId); - const draft = selected ? page.selectedDrafts.find((item) => String(item.resourceId) === resourceId) : null; + const draftGroup = selected ? page.draftByResourceId[resourceId] : null; + const canUnselectGroup = !draftGroup || canUnselectResourceShopDraftGroup(draftGroup); return (
    page.toggleDraftResource(resource)} /> - {resourcePriceLabel(resource)} + 资源价 {resourcePriceLabel(resource)} + {draftGroup ? ( +
    + {resourceShopDurationOptions.map(([durationDays, label]) => ( + + ))} +
    + ) : null} +
    + ); +} + +function ResourceShopSpecRow({ disabled, draft, durationDays, label, page, resource }) { + const resourceId = String(resource.resourceId); + const updateDraft = (patch) => page.updateDraftSpec(resourceId, durationDays, patch); + return ( +
    + {draft ? ( -
    + <> page.updateDraftItem(resourceId, { durationDays: event.target.value })} - > - {resourceShopDurationOptions.map(([value, label]) => ( - - {label} - - ))} - - page.updateDraftItem(resourceId, { effectiveFrom: event.target.value })} + type="number" + value={draft.coinPrice} + onChange={(event) => updateDraft({ coinPrice: event.target.value })} /> - page.updateDraftItem(resourceId, { effectiveTo: event.target.value })} + label="生效时间" + value={{ endMs: draft.effectiveToMs, startMs: draft.effectiveFromMs }} + onChange={(range) => + updateDraft({ effectiveFromMs: range.startMs, effectiveToMs: range.endMs }) + } /> page.updateDraftItem(resourceId, { sortOrder: event.target.value })} + onChange={(event) => updateDraft({ sortOrder: event.target.value })} /> -
    - ) : null} + + ) : ( + 未售卖 + )}
    ); } diff --git a/src/features/resources/resources.module.css b/src/features/resources/resources.module.css index 3612cfa..8238797 100644 --- a/src/features/resources/resources.module.css +++ b/src/features/resources/resources.module.css @@ -268,7 +268,7 @@ .shopDrawerTools { display: grid; - grid-template-columns: minmax(220px, 1fr) minmax(160px, 0.7fr) minmax(150px, 0.55fr); + grid-template-columns: minmax(220px, 1fr) minmax(160px, 0.7fr); gap: var(--space-3); } @@ -282,16 +282,43 @@ min-height: 56px; align-items: center; gap: var(--space-3); - grid-template-columns: 40px minmax(220px, 1fr) minmax(92px, 0.35fr) minmax(0, 2fr); + grid-template-columns: 40px minmax(220px, 1fr) minmax(132px, auto); padding: var(--space-2) 0; border-bottom: 1px solid var(--border-muted); } -.shopSelectionControls { +.shopSpecList { display: grid; min-width: 0; gap: var(--space-2); - grid-template-columns: minmax(112px, 0.7fr) minmax(170px, 1fr) minmax(170px, 1fr) minmax(84px, 0.45fr); + grid-column: 2 / -1; +} + +.shopSpecRow { + display: grid; + min-width: 0; + align-items: center; + gap: var(--space-2); + grid-template-columns: minmax(88px, 0.45fr) minmax(120px, 0.65fr) minmax(220px, 1.25fr) minmax(84px, 0.45fr); +} + +.shopSpecToggle { + display: inline-flex; + min-width: 0; + align-items: center; + color: var(--text-primary); + font-weight: 650; + white-space: nowrap; +} + +.shopSpecTimeRange { + width: 100% !important; + max-width: none !important; +} + +.shopSpecDisabled { + grid-column: 2 / -1; + color: var(--text-tertiary); } .shopBasePrice { @@ -667,7 +694,7 @@ @media (max-width: 860px) { .shopDrawerTools, .shopSelectionRow, - .shopSelectionControls, + .shopSpecRow, .batchTableHead, .batchTableRow { grid-template-columns: 1fr; @@ -676,4 +703,9 @@ .shopSelectionRow { align-items: stretch; } + + .shopSpecList, + .shopSpecDisabled { + grid-column: auto; + } } diff --git a/src/features/resources/schema.js b/src/features/resources/schema.js index 3007f01..b2e537e 100644 --- a/src/features/resources/schema.js +++ b/src/features/resources/schema.js @@ -359,23 +359,28 @@ export const resourceShopItemsFormSchema = z items: z .array( z.object({ + coinPrice: z.union([z.string(), z.number()]), durationDays: z.union([z.string(), z.number()]), - effectiveFrom: z.string().optional(), - effectiveTo: z.string().optional(), + effectiveFromMs: z.union([z.string(), z.number()]).optional(), + effectiveToMs: z.union([z.string(), z.number()]).optional(), resourceId: z.union([z.string(), z.number()]), + shopItemId: z.union([z.string(), z.number()]).optional(), sortOrder: z.union([z.string(), z.number()]).optional(), + status: z.enum(["active", "disabled"]).optional(), }), ) .min(1, "请至少选择一个资源"), }) .superRefine((value, context) => { - const resourceIds = new Set(); + const specificationKeys = new Set(); value.items.forEach((item, index) => { const resourceId = Number(item.resourceId); const durationDays = String(item.durationDays || "").trim(); - const effectiveFromMs = datetimeLocalToMs(item.effectiveFrom); - const effectiveToMs = datetimeLocalToMs(item.effectiveTo); + const coinPrice = Number(item.coinPrice); + const effectiveFromMs = optionalEpochMs(item.effectiveFromMs); + const effectiveToMs = optionalEpochMs(item.effectiveToMs); const sortOrder = Number(item.sortOrder || 0); + const specificationKey = `${resourceId}:${durationDays}`; if (!Number.isInteger(resourceId) || resourceId <= 0) { context.addIssue({ @@ -384,14 +389,14 @@ export const resourceShopItemsFormSchema = z path: ["items", index, "resourceId"], }); } - if (resourceIds.has(resourceId)) { + if (specificationKeys.has(specificationKey)) { context.addIssue({ code: "custom", - message: "同一个资源不能重复添加", - path: ["items", index, "resourceId"], + message: "同一个资源的相同售卖天数不能重复添加", + path: ["items", index, "durationDays"], }); } - resourceIds.add(resourceId); + specificationKeys.add(specificationKey); if (!resourceShopDurations.includes(durationDays)) { context.addIssue({ code: "custom", @@ -399,25 +404,32 @@ export const resourceShopItemsFormSchema = z path: ["items", index, "durationDays"], }); } + if (!Number.isSafeInteger(coinPrice) || coinPrice <= 0) { + context.addIssue({ + code: "custom", + message: "请输入大于 0 的售卖价格", + path: ["items", index, "coinPrice"], + }); + } if (effectiveFromMs < 0) { context.addIssue({ code: "custom", message: "请选择生效开始时间", - path: ["items", index, "effectiveFrom"], + path: ["items", index, "effectiveFromMs"], }); } if (effectiveToMs < 0) { context.addIssue({ code: "custom", message: "请选择生效结束时间", - path: ["items", index, "effectiveTo"], + path: ["items", index, "effectiveToMs"], }); } if (effectiveFromMs > 0 && effectiveToMs > 0 && effectiveToMs <= effectiveFromMs) { context.addIssue({ code: "custom", message: "生效结束时间必须晚于开始时间", - path: ["items", index, "effectiveTo"], + path: ["items", index, "effectiveToMs"], }); } if (!Number.isInteger(sortOrder) || sortOrder < 0) { @@ -430,6 +442,15 @@ export const resourceShopItemsFormSchema = z }); }); +function optionalEpochMs(value) { + const normalized = String(value ?? "").trim(); + if (!normalized) { + return 0; + } + const parsed = Number(normalized); + return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : -1; +} + function datetimeLocalToMs(value) { const trimmed = String(value || "").trim(); if (!trimmed) { diff --git a/src/features/resources/schema.test.ts b/src/features/resources/schema.test.ts index 5b5b1af..9d1b02c 100644 --- a/src/features/resources/schema.test.ts +++ b/src/features/resources/schema.test.ts @@ -340,13 +340,22 @@ describe("resource form schema", () => { expect(() => parseForm(resourceGrantFormSchema, { ...payload, durationDays: "0" })).toThrow(FormValidationError); }); - test("validates resource shop durations", () => { + test("validates resource shop duration and price specifications", () => { const payload = parseForm(resourceShopItemsFormSchema, { items: [ { + coinPrice: "100", durationDays: "3", - effectiveFrom: "", - effectiveTo: "", + effectiveFromMs: "", + effectiveToMs: "", + resourceId: "11", + sortOrder: "0", + }, + { + coinPrice: "260", + durationDays: "7", + effectiveFromMs: "", + effectiveToMs: "", resourceId: "11", sortOrder: "0", }, @@ -354,18 +363,33 @@ describe("resource form schema", () => { }); expect(payload.items[0].durationDays).toBe("3"); + expect(payload.items[1].coinPrice).toBe("260"); expect(() => parseForm(resourceShopItemsFormSchema, { items: [ { + coinPrice: "100", durationDays: "2", - effectiveFrom: "", - effectiveTo: "", + effectiveFromMs: "", + effectiveToMs: "", resourceId: "11", sortOrder: "0", }, ], }), ).toThrow(FormValidationError); + expect(() => + parseForm(resourceShopItemsFormSchema, { + items: [ + { coinPrice: "100", durationDays: "3", resourceId: "11" }, + { coinPrice: "120", durationDays: "3", resourceId: "11" }, + ], + }), + ).toThrow(FormValidationError); + expect(() => + parseForm(resourceShopItemsFormSchema, { + items: [{ coinPrice: "0", durationDays: "1", resourceId: "11" }], + }), + ).toThrow(FormValidationError); }); }); diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index 2fdeb39..90fbe83 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -314,6 +314,7 @@ export const API_OPERATIONS = { retryRedPacketRefund: "retryRedPacketRefund", retryRoomRpsSettlement: "retryRoomRpsSettlement", retryRoomTurnoverRewardSettlement: "retryRoomTurnoverRewardSettlement", + revokeAppUserResource: "revokeAppUserResource", revokeResourceGrant: "revokeResourceGrant", savePolicyTemplate: "savePolicyTemplate", search: "search", @@ -2503,6 +2504,13 @@ export const API_ENDPOINTS: Record = { permission: "room-turnover-reward:retry", permissions: ["room-turnover-reward:retry"] }, + revokeAppUserResource: { + method: "POST", + operationId: API_OPERATIONS.revokeAppUserResource, + path: "/v1/admin/users/{user_id}/resources/{entitlement_id}/revoke", + permission: "resource-grant:revoke", + permissions: ["resource-grant:revoke"] + }, revokeResourceGrant: { method: "POST", operationId: API_OPERATIONS.revokeResourceGrant, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index d979218..1db833b 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -2876,6 +2876,22 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/users/{user_id}/resources/{entitlement_id}/revoke": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["revokeAppUserResource"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/admin/resource-grants/resource": { parameters: { query?: never; @@ -11425,6 +11441,27 @@ export interface operations { 200: components["responses"]["EmptyResponse"]; }; }; + revokeAppUserResource: { + parameters: { + query?: never; + header?: never; + path: { + user_id: string; + entitlement_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + reason: string; + }; + }; + }; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; grantResource: { parameters: { query?: never; @@ -11468,7 +11505,25 @@ export interface operations { path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": { + items: { + /** @description 售卖规格金币价;新建时省略或传 0 使用资源列表价格,更新已有规格时省略或传 0 保留当前售价 */ + coinPrice?: number; + /** @enum {integer} */ + durationDays: 1 | 3 | 7; + effectiveFromMs: number; + effectiveToMs: number; + resourceId: number; + shopItemId?: number; + sortOrder: number; + /** @enum {string} */ + status: "active" | "disabled"; + }[]; + }; + }; + }; responses: { 200: components["responses"]["EmptyResponse"]; }; diff --git a/src/shared/ui/Mp4Preview.jsx b/src/shared/ui/Mp4Preview.jsx new file mode 100644 index 0000000..6254954 --- /dev/null +++ b/src/shared/ui/Mp4Preview.jsx @@ -0,0 +1,462 @@ +import PauseRounded from "@mui/icons-material/PauseRounded"; +import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded"; +import { useEffect, useRef, useState } from "react"; +import styles from "./Mp4Preview.module.css"; + +const alphaLowThreshold = 0.08; +const alphaHighThreshold = 0.3; + +export function Mp4Preview({ autoPlay = false, controls = false, expanded = false, layout, src }) { + const layoutKey = transparentLayoutKey(layout); + const reducedMotion = prefersReducedMotion(); + + if (!layoutKey) { + return ( + + ); + } + + return ( + + ); +} + +function NativeMp4Preview({ autoPlay, controls, expanded, src }) { + const [failed, setFailed] = useState(false); + + useEffect(() => { + setFailed(false); + }, [src]); + + return ( + + {failed ? ( + MP4 预览失败 + ) : ( + + ); +} + +function AlphaMp4Preview({ autoPlay, controls, expanded, layoutKey, src }) { + const canvasRef = useRef(null); + const videoRef = useRef(null); + const [failed, setFailed] = useState(false); + const [playing, setPlaying] = useState(false); + + useEffect(() => { + const canvas = canvasRef.current; + const video = videoRef.current; + if (!canvas || !video || !src) { + return undefined; + } + + let cancelled = false; + let started = false; + let renderer; + let videoFrameId; + let animationFrameId; + let frameScheduled = false; + let lastMediaTime = -1; + let stopped = false; + const layout = JSON.parse(layoutKey); + + setFailed(false); + setPlaying(false); + + const fail = () => { + if (cancelled || stopped) { + return; + } + // 绘制失败后立即停止帧循环并释放 GPU,避免 RAF 回退路径永久空转。 + stopped = true; + if (videoFrameId !== undefined && typeof video.cancelVideoFrameCallback === "function") { + video.cancelVideoFrameCallback(videoFrameId); + } + if (animationFrameId !== undefined) { + window.cancelAnimationFrame(animationFrameId); + } + renderer?.destroy(); + renderer = undefined; + video.pause(); + setPlaying(false); + setFailed(true); + }; + + const drawFrame = (mediaTime = video.currentTime) => { + if (cancelled || stopped || !renderer || video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) { + return; + } + // RAF 会比视频帧率更快;只在媒体时间变化时上传纹理,避免无意义地重复占用 GPU。 + if (mediaTime === lastMediaTime) { + return; + } + lastMediaTime = mediaTime; + try { + renderer.draw(); + } catch { + fail(); + } + }; + + const scheduleFrame = () => { + if (cancelled || stopped || frameScheduled || video.paused) { + return; + } + if (typeof video.requestVideoFrameCallback === "function") { + frameScheduled = true; + videoFrameId = video.requestVideoFrameCallback((_, metadata) => { + frameScheduled = false; + drawFrame(metadata?.mediaTime); + scheduleFrame(); + }); + return; + } + frameScheduled = true; + animationFrameId = window.requestAnimationFrame(() => { + frameScheduled = false; + drawFrame(); + scheduleFrame(); + }); + }; + + const handlePlay = () => { + if (!cancelled && !stopped) { + setPlaying(true); + scheduleFrame(); + } + }; + const handlePause = () => { + if (!cancelled) { + setPlaying(false); + } + }; + + const start = () => { + if (cancelled || stopped || started || video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) { + return; + } + started = true; + try { + // 透明 MP4 必须按检测得到的精确矩形合成;custom/VAPC 的 RGB 与 Alpha 尺寸可能并不相等。 + renderer = createAlphaRenderer(canvas, video, layout, expanded ? 1280 : 512); + drawFrame(); + if (autoPlay) { + video.play().catch(() => { + // 浏览器若阻止自动播放,首帧仍会保留;再次打开弹窗会由用户手势触发播放。 + }); + } + } catch { + fail(); + } + }; + + video.addEventListener("loadeddata", start); + video.addEventListener("error", fail); + video.addEventListener("play", handlePlay); + video.addEventListener("pause", handlePause); + // Canvas 读取跨域视频必须先设置 anonymous;当前上传媒体域允许 CORS,本地 Blob URL 也兼容该设置。 + video.crossOrigin = "anonymous"; + video.loop = true; + video.muted = true; + video.playsInline = true; + video.preload = "auto"; + video.src = src; + video.load(); + start(); + + return () => { + cancelled = true; + video.removeEventListener("loadeddata", start); + video.removeEventListener("error", fail); + video.removeEventListener("play", handlePlay); + video.removeEventListener("pause", handlePause); + if (videoFrameId !== undefined && typeof video.cancelVideoFrameCallback === "function") { + video.cancelVideoFrameCallback(videoFrameId); + } + if (animationFrameId !== undefined) { + window.cancelAnimationFrame(animationFrameId); + } + renderer?.destroy(); + video.pause(); + video.removeAttribute("src"); + video.load(); + }; + }, [autoPlay, expanded, layoutKey, src]); + + const togglePlayback = () => { + const video = videoRef.current; + if (!video || failed) { + return; + } + if (video.paused) { + // 用户手势仍可能被浏览器策略暂时拒绝;保留播放入口,允许用户重试。 + video.play().catch(() => {}); + } else { + video.pause(); + } + }; + + return ( + + + + ); +} + +function createAlphaRenderer(canvas, video, layout, maxEdge) { + const outputSize = scaledOutputSize(layout.rgb_frame[2], layout.rgb_frame[3], maxEdge); + canvas.width = outputSize.width; + canvas.height = outputSize.height; + + const gl = canvas.getContext("webgl", { + alpha: true, + antialias: false, + premultipliedAlpha: false, + preserveDrawingBuffer: false, + }); + if (!gl) { + return createCanvas2DRenderer(canvas, video, layout); + } + + const program = createProgram(gl, vertexShaderSource, fragmentShaderSource); + const positionBuffer = gl.createBuffer(); + const texture = gl.createTexture(); + if (!positionBuffer || !texture) { + throw new Error("MP4 WebGL 初始化失败"); + } + + gl.useProgram(program); + gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 1]), gl.STATIC_DRAW); + const positionLocation = gl.getAttribLocation(program, "aPosition"); + gl.enableVertexAttribArray(positionLocation); + gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0); + + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.uniform1i(gl.getUniformLocation(program, "uTexture"), 0); + gl.uniform4fv(gl.getUniformLocation(program, "uRgbRect"), normalizedRect(layout.rgb_frame, layout)); + gl.uniform4fv(gl.getUniformLocation(program, "uAlphaRect"), normalizedRect(layout.alpha_frame, layout)); + gl.viewport(0, 0, canvas.width, canvas.height); + gl.clearColor(0, 0, 0, 0); + + return { + destroy() { + gl.deleteTexture(texture); + gl.deleteBuffer(positionBuffer); + gl.deleteProgram(program); + }, + draw() { + gl.clear(gl.COLOR_BUFFER_BIT); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, video); + gl.drawArrays(gl.TRIANGLES, 0, 6); + }, + }; +} + +function createCanvas2DRenderer(canvas, video, layout) { + const context = canvas.getContext("2d", { willReadFrequently: true }); + const maskCanvas = document.createElement("canvas"); + maskCanvas.width = canvas.width; + maskCanvas.height = canvas.height; + const maskContext = maskCanvas.getContext("2d", { willReadFrequently: true }); + if (!context || !maskContext) { + throw new Error("MP4 Canvas 初始化失败"); + } + + const rgbFrame = scaledSourceFrame(layout.rgb_frame, layout, video); + const alphaFrame = scaledSourceFrame(layout.alpha_frame, layout, video); + return { + destroy() {}, + draw() { + context.clearRect(0, 0, canvas.width, canvas.height); + maskContext.clearRect(0, 0, canvas.width, canvas.height); + context.drawImage(video, ...rgbFrame, 0, 0, canvas.width, canvas.height); + maskContext.drawImage(video, ...alphaFrame, 0, 0, canvas.width, canvas.height); + const colorPixels = context.getImageData(0, 0, canvas.width, canvas.height); + const maskPixels = maskContext.getImageData(0, 0, canvas.width, canvas.height).data; + // 与 Android/iOS 客户端保持同一亮度与 smoothstep 阈值,后台看到的透明边缘才不会和 App 偏差。 + for (let index = 0; index < colorPixels.data.length; index += 4) { + const luminance = + (maskPixels[index] * 0.299 + maskPixels[index + 1] * 0.587 + maskPixels[index + 2] * 0.114) / 255; + const progress = clamp( + (luminance - alphaLowThreshold) / (alphaHighThreshold - alphaLowThreshold), + 0, + 1, + ); + colorPixels.data[index + 3] = Math.round(progress * progress * (3 - 2 * progress) * 255); + } + context.putImageData(colorPixels, 0, 0); + }, + }; +} + +function createProgram(gl, vertexSource, fragmentSource) { + const vertexShader = compileShader(gl, gl.VERTEX_SHADER, vertexSource); + const fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSource); + const program = gl.createProgram(); + if (!program) { + throw new Error("MP4 WebGL Program 创建失败"); + } + gl.attachShader(program, vertexShader); + gl.attachShader(program, fragmentShader); + gl.linkProgram(program); + gl.deleteShader(vertexShader); + gl.deleteShader(fragmentShader); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + const message = gl.getProgramInfoLog(program) || "MP4 WebGL Program 链接失败"; + gl.deleteProgram(program); + throw new Error(message); + } + return program; +} + +function compileShader(gl, type, source) { + const shader = gl.createShader(type); + if (!shader) { + throw new Error("MP4 WebGL Shader 创建失败"); + } + gl.shaderSource(shader, source); + gl.compileShader(shader); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + const message = gl.getShaderInfoLog(shader) || "MP4 WebGL Shader 编译失败"; + gl.deleteShader(shader); + throw new Error(message); + } + return shader; +} + +function transparentLayoutKey(layout) { + if ( + !layout || + layout.alpha_layout === "normal" || + !validFrame(layout.rgb_frame) || + !validFrame(layout.alpha_frame) + ) { + return ""; + } + const videoWidth = positiveInteger(layout.video_w); + const videoHeight = positiveInteger(layout.video_h); + if (!videoWidth || !videoHeight) { + return ""; + } + return JSON.stringify({ + alpha_frame: layout.alpha_frame.slice(0, 4).map(Number), + alpha_layout: layout.alpha_layout, + rgb_frame: layout.rgb_frame.slice(0, 4).map(Number), + video_h: videoHeight, + video_w: videoWidth, + }); +} + +function normalizedRect(frame, layout) { + return new Float32Array([ + frame[0] / layout.video_w, + frame[1] / layout.video_h, + frame[2] / layout.video_w, + frame[3] / layout.video_h, + ]); +} + +function scaledSourceFrame(frame, layout, video) { + const scaleX = video.videoWidth / layout.video_w; + const scaleY = video.videoHeight / layout.video_h; + return [frame[0] * scaleX, frame[1] * scaleY, frame[2] * scaleX, frame[3] * scaleY]; +} + +function scaledOutputSize(width, height, maxEdge) { + const scale = Math.min(1, maxEdge / Math.max(width, height)); + return { + height: Math.max(1, Math.round(height * scale)), + width: Math.max(1, Math.round(width * scale)), + }; +} + +function validFrame(frame) { + return ( + Array.isArray(frame) && + frame.length >= 4 && + frame + .slice(0, 4) + .every((value, index) => Number.isFinite(Number(value)) && (index < 2 ? value >= 0 : value > 0)) + ); +} + +function positiveInteger(value) { + const number = Number(value); + return Number.isInteger(number) && number > 0 ? number : 0; +} + +function clamp(value, minimum, maximum) { + return Math.min(maximum, Math.max(minimum, value)); +} + +function prefersReducedMotion() { + return typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true; +} + +const vertexShaderSource = ` + attribute vec2 aPosition; + varying vec2 vOutputUv; + + void main() { + gl_Position = vec4(aPosition * 2.0 - 1.0, 0.0, 1.0); + vOutputUv = vec2(aPosition.x, 1.0 - aPosition.y); + } +`; + +const fragmentShaderSource = ` + precision mediump float; + uniform sampler2D uTexture; + uniform vec4 uRgbRect; + uniform vec4 uAlphaRect; + varying vec2 vOutputUv; + + vec2 mapRect(vec4 rect, vec2 uv) { + return vec2(rect.x + uv.x * rect.z, rect.y + uv.y * rect.w); + } + + void main() { + vec4 color = texture2D(uTexture, mapRect(uRgbRect, vOutputUv)); + vec4 mask = texture2D(uTexture, mapRect(uAlphaRect, vOutputUv)); + float alpha = dot(mask.rgb, vec3(0.299, 0.587, 0.114)); + alpha = smoothstep(${alphaLowThreshold.toFixed(2)}, ${alphaHighThreshold.toFixed(2)}, alpha); + gl_FragColor = vec4(color.rgb, alpha); + } +`; diff --git a/src/shared/ui/Mp4Preview.module.css b/src/shared/ui/Mp4Preview.module.css new file mode 100644 index 0000000..31e481d --- /dev/null +++ b/src/shared/ui/Mp4Preview.module.css @@ -0,0 +1,88 @@ +.root { + position: relative; + display: flex; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.expanded { + min-height: 320px; + max-height: min(68vh, 640px); +} + +.media { + display: block; + width: auto; + max-width: 100%; + height: auto; + max-height: 100%; + object-fit: contain; +} + +.expanded .media { + max-height: min(68vh, 640px); +} + +.sourceVideo { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + opacity: 0; + pointer-events: none; +} + +.error { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-tertiary); + font-weight: 650; +} + +.playbackControl { + position: absolute; + right: var(--space-3); + bottom: var(--space-3); + display: inline-flex; + height: 34px; + align-items: center; + gap: var(--space-1); + padding: 0 var(--space-3); + border: 1px solid var(--border-strong); + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.94); + color: var(--text-primary); + cursor: pointer; + font: inherit; + font-size: 13px; + font-weight: 650; + transition: + border-color var(--motion-fast) var(--ease-standard), + color var(--motion-fast) var(--ease-standard), + transform var(--motion-fast) var(--ease-standard); +} + +.playbackControl:hover, +.playbackControl:focus-visible { + border-color: var(--primary); + color: var(--primary); +} + +.playbackControl:active { + transform: scale(0.97); +} + +@media (prefers-reduced-motion: reduce) { + .playbackControl { + transition: none; + } +} diff --git a/src/shared/ui/UploadField.jsx b/src/shared/ui/UploadField.jsx index 7e6d1ad..1fa8efe 100644 --- a/src/shared/ui/UploadField.jsx +++ b/src/shared/ui/UploadField.jsx @@ -15,6 +15,7 @@ import { useEffect, useId, useRef, useState } from "react"; import { downloadResponse } from "@/shared/api/download"; import { uploadFile, uploadImage } from "@/shared/api/upload"; import { Button } from "@/shared/ui/Button.jsx"; +import { Mp4Preview } from "@/shared/ui/Mp4Preview.jsx"; import { useToast } from "@/shared/ui/ToastProvider.jsx"; import styles from "./UploadField.module.css"; @@ -30,8 +31,10 @@ export function UploadField({ kind = "image", label, hideType = false, + mp4Layout, onChange, onFileSelected, + onUploadingChange, panelClassName, previewClassName, showSourceActions = false, @@ -40,6 +43,8 @@ export function UploadField({ }) { const inputId = useId(); const inputRef = useRef(null); + const uploadGenerationRef = useRef(0); + const onUploadingChangeRef = useRef(onUploadingChange); const { showToast } = useToast(); const [uploading, setUploading] = useState(false); const [localPreview, setLocalPreview] = useState(null); @@ -53,14 +58,30 @@ export function UploadField({ const displayName = localPreview?.name || getDisplayValue(value); const inputAccept = accept !== undefined ? accept : useImageUpload && isImage ? imageAccept : undefined; const canPreviewVideo = Boolean(source && assetKind === "mp4"); + const localPreviewURL = localPreview?.url; + const previewMp4Layout = Object.prototype.hasOwnProperty.call(localPreview?.selectionResult || {}, "mp4Layout") + ? localPreview.selectionResult.mp4Layout + : mp4Layout; + + useEffect(() => { + onUploadingChangeRef.current = onUploadingChange; + }, [onUploadingChange]); useEffect(() => { return () => { - if (localPreview?.url) { - URL.revokeObjectURL(localPreview.url); + // 上传请求无法中途取消时,用代次作废迟到结果,避免弹窗关闭后回写下一次编辑表单。 + uploadGenerationRef.current += 1; + onUploadingChangeRef.current?.(false); + }; + }, []); + + useEffect(() => { + return () => { + if (localPreviewURL) { + URL.revokeObjectURL(localPreviewURL); } }; - }, [localPreview]); + }, [localPreviewURL]); const openPicker = () => { if (!disabled && !uploading) { @@ -85,18 +106,38 @@ export function UploadField({ url: URL.createObjectURL(file), }; }); + const uploadGeneration = ++uploadGenerationRef.current; setUploading(true); + onUploadingChange?.(true); try { - await onFileSelected?.(file); + const selectionResult = await onFileSelected?.(file); + if (uploadGeneration !== uploadGenerationRef.current) { + return; + } + setLocalPreview((previous) => (previous ? { ...previous, selectionResult } : previous)); const result = useImageUpload ? await uploadImage(file) : await uploadFile(file); - onChange(result.url); + if (uploadGeneration !== uploadGenerationRef.current) { + return; + } + // 只有媒体检测和上传都成功后才把检测结果交给表单,避免新布局残留在旧 URL 上。 + if (selectionResult === undefined) { + onChange(result.url); + } else { + onChange(result.url, selectionResult); + } setLocalPreview(null); showToast("上传成功", "success"); } catch (err) { + if (uploadGeneration !== uploadGenerationRef.current) { + return; + } setLocalPreview(null); showToast(err.message || "上传失败", "error"); } finally { - setUploading(false); + if (uploadGeneration === uploadGenerationRef.current) { + setUploading(false); + onUploadingChange?.(false); + } } }; @@ -155,7 +196,12 @@ export function UploadField({ type="button" onClick={openPicker} > - + {uploading ? ( @@ -249,6 +295,7 @@ export function UploadField({
    setVideoPreviewOpen(false)} @@ -265,15 +312,13 @@ export function UploadField({ ); } -function VideoPreviewDialog({ label, onClose, open, src }) { +function VideoPreviewDialog({ label, mp4Layout, onClose, open, src }) { return ( {label} MP4
    - {src ? ( -
    @@ -283,7 +328,7 @@ function VideoPreviewDialog({ label, onClose, open, src }) { ); } -function AssetPreview({ assetKind, isImage, src }) { +function AssetPreview({ assetKind, isImage, mp4Layout, src }) { if (!src) { return ( @@ -297,6 +342,9 @@ function AssetPreview({ assetKind, isImage, src }) { if (assetKind === "pag") { return ; } + if (assetKind === "mp4") { + return ; + } if (assetKind === "image" || isImage) { return ; } @@ -390,7 +438,8 @@ function SVGAAssetPreview({ src }) { return ( - + {/* svgaplayerweb 2.3.2 只会为 HTMLDivElement 创建内部 Canvas,span 会在 setVideoItem 时稳定失败。 */} +
    {failed ? SVGA 预览失败 : null} ); diff --git a/src/shared/ui/UploadField.module.css b/src/shared/ui/UploadField.module.css index 6d390a9..f3bc43e 100644 --- a/src/shared/ui/UploadField.module.css +++ b/src/shared/ui/UploadField.module.css @@ -160,14 +160,14 @@ height: 100%; } -.playerSurface canvas, -.pagCanvas { - width: 100% !important; - height: 100% !important; +.playerSurface canvas { + display: block; } .pagCanvas { display: block; + width: 100% !important; + height: 100% !important; } .footer { From 7bc1b6a423174d7a0d4e95f1d2e41b30f85fbe1a Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 15 Jul 2026 19:37:17 +0800 Subject: [PATCH 09/15] fix(resources): block form submit during uploads --- src/features/resources/pages/ResourceListPage.jsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/features/resources/pages/ResourceListPage.jsx b/src/features/resources/pages/ResourceListPage.jsx index 98bf9bb..f888691 100644 --- a/src/features/resources/pages/ResourceListPage.jsx +++ b/src/features/resources/pages/ResourceListPage.jsx @@ -1033,6 +1033,14 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit, return { ...previous, animationUrl, metadataJson }; }); }; + const handleSubmit = (event) => { + if (submitDisabled) { + // disabled 按钮之外再拦截表单事件,避免上传期间按 Enter 仍提交旧素材 URL。 + event.preventDefault(); + return; + } + onSubmit(event); + }; return ( From c67d2e2d4b3ade4d30cea2caa933e9fae1dab5b6 Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 15 Jul 2026 19:57:01 +0800 Subject: [PATCH 10/15] fix(external-admin): refine authenticated branding --- .../src/components/BannerCreateDialog.jsx | 3 +- .../src/layout/ExternalAdminLayout.jsx | 15 ++++++++-- external-admin/src/pages/OverviewPage.jsx | 11 ------- external-admin/src/styles/index.css | 16 ++++++++++ src/shared/ui/TimeRangeFilter.jsx | 18 +++++++---- src/shared/ui/TimeRangeFilter.module.css | 30 +++++++++++-------- 6 files changed, 60 insertions(+), 33 deletions(-) diff --git a/external-admin/src/components/BannerCreateDialog.jsx b/external-admin/src/components/BannerCreateDialog.jsx index 340ecc2..077fcb6 100644 --- a/external-admin/src/components/BannerCreateDialog.jsx +++ b/external-admin/src/components/BannerCreateDialog.jsx @@ -22,7 +22,7 @@ const displayScopeOptions = [ ]; export function BannerCreateDialog({ loading, onClose, onSubmit, open }) { - const { locale, t } = useExternalI18n(); + const { direction, locale, t } = useExternalI18n(); const [form, setForm] = useState(initialForm); useEffect(() => setForm(initialForm), [open]); @@ -79,6 +79,7 @@ export function BannerCreateDialog({ loading, onClose, onSubmit, open }) { setForm({ ...form, sortOrder: event.target.value })} /> - {mobile && !current ? t("app.shortTitle") : t(current?.labelKey || "app.shortTitle")} + {mobile ? ( + + ) : ( + {t(current?.labelKey || "app.shortTitle")} + )} @@ -193,7 +204,7 @@ export function ExternalAdminLayout() { })} - + diff --git a/external-admin/src/pages/OverviewPage.jsx b/external-admin/src/pages/OverviewPage.jsx index 9945f85..ee249f3 100644 --- a/external-admin/src/pages/OverviewPage.jsx +++ b/external-admin/src/pages/OverviewPage.jsx @@ -55,20 +55,9 @@ const CAPABILITY_ICONS = { export default function OverviewPage() { const { session } = useExternalAuth(); const { t } = useExternalI18n(); - const enabledCount = EXTERNAL_CAPABILITIES.filter((item) => hasExternalCapability(session, item.code)).length; return ( - - {t("overview.welcome")} - {session.appName || session.appCode} - - {t("overview.currentApp")}{session.appName || session.appCode} ({session.appCode}) - {t("overview.account")}{session.account} - {t("overview.permissions")}{enabledCount} / {EXTERNAL_CAPABILITIES.length} - - - {t("overview.features")} {EXTERNAL_CAPABILITIES.map((item) => { const enabled = hasExternalCapability(session, item.code); diff --git a/external-admin/src/styles/index.css b/external-admin/src/styles/index.css index 89fdf4b..9cda337 100644 --- a/external-admin/src/styles/index.css +++ b/external-admin/src/styles/index.css @@ -224,6 +224,22 @@ body { letter-spacing: 0.03em; } +.external-mobile-brand { + max-width: min(48vw, 180px); + color: var(--primary-strong); + font-family: var(--font-display); + font-size: 15px; + font-weight: 750; + letter-spacing: 0.04em; +} + +.external-mobile-brand img, +.external-sidebar-brand img, +.external-change-password-brand img { + border-color: var(--primary-border); + box-shadow: 0 0 0 1px rgba(214, 181, 110, 0.08), 0 8px 24px rgba(0, 0, 0, 0.38); +} + .external-user-avatar { width: 34px !important; height: 34px !important; diff --git a/src/shared/ui/TimeRangeFilter.jsx b/src/shared/ui/TimeRangeFilter.jsx index cf369df..6a90077 100644 --- a/src/shared/ui/TimeRangeFilter.jsx +++ b/src/shared/ui/TimeRangeFilter.jsx @@ -49,7 +49,7 @@ const defaultMessages = { // withTime=false 时是纯日期区间选择器:隐藏时/分与小时级快捷区间, // 选中的开始/结束日分别取当天 00:00:00.000 与 23:59:59.999 的本地毫秒。 -export function TimeRangeFilter({ className = "", disabled = false, label = "时间", locale = "zh-CN", messages, onChange, value, withTime = true }) { +export function TimeRangeFilter({ className = "", direction = "ltr", disabled = false, label = "时间", locale = "zh-CN", messages, onChange, value, withTime = true }) { const copy = useMemo(() => ({ ...defaultMessages, ...messages }), [messages]); const range = useMemo(() => normalizeRange(value), [value]); const [anchorEl, setAnchorEl] = useState(null); @@ -69,6 +69,12 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时 const rangeDistance = formatLocalizedRangeDistance(draft.startMs, draft.endMs, withTime, copy); const weekLabels = useMemo(() => localizedWeekLabels(locale), [locale]); const hasRange = Boolean(range.startMs || range.endMs); + // CSS Modules do not pass through the external-admin Emotion RTL cache. Keep + // popover anchoring and directional glyphs explicit so Arabic mirrors as a unit. + const rtl = direction === "rtl"; + const horizontalOrigin = rtl ? "right" : "left"; + const PreviousMonthIcon = rtl ? ChevronRightOutlined : ChevronLeftOutlined; + const NextMonthIcon = rtl ? ChevronLeftOutlined : ChevronRightOutlined; useEffect(() => { if (!open) { @@ -183,9 +189,9 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
    @@ -233,11 +239,11 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
    changeMonth(-1)}> - + {monthTitle(viewDate, locale)} changeMonth(1)}> - +
    diff --git a/src/shared/ui/TimeRangeFilter.module.css b/src/shared/ui/TimeRangeFilter.module.css index 7cc3c51..72447e1 100644 --- a/src/shared/ui/TimeRangeFilter.module.css +++ b/src/shared/ui/TimeRangeFilter.module.css @@ -9,7 +9,8 @@ width: auto; min-width: 132px !important; max-width: min(420px, 100%); - padding: 7px 28px 3px var(--control-padding-x) !important; + padding-block: 7px 3px !important; + padding-inline: var(--control-padding-x) 28px !important; border-color: var(--border) !important; background: var(--bg-input-strong) !important; box-shadow: none !important; @@ -29,8 +30,8 @@ .triggerMeta { position: absolute; - top: -7px; - left: var(--space-2); + inset-block-start: -7px; + inset-inline-start: var(--space-2); display: inline-flex; max-width: calc(100% - var(--space-4)); align-items: center; @@ -58,15 +59,15 @@ color: var(--text-primary); font-size: var(--control-font-size); line-height: var(--control-line-height); - text-align: left; + text-align: start; text-overflow: ellipsis; white-space: nowrap; } .triggerChevron { position: absolute; - top: 50%; - right: var(--space-2); + inset-block-start: 50%; + inset-inline-end: var(--space-2); color: var(--text-tertiary); transform: translateY(-50%); } @@ -94,8 +95,7 @@ .quickButton { min-width: 0 !important; - padding-right: var(--space-2) !important; - padding-left: var(--space-2) !important; + padding-inline: var(--space-2) !important; } .rangeFields { @@ -115,7 +115,7 @@ border-radius: var(--radius-control); background: var(--bg-input-strong); color: var(--text-primary); - text-align: left; + text-align: start; transition: border-color var(--motion-fast) var(--ease-standard), box-shadow var(--motion-fast) var(--ease-standard), @@ -275,11 +275,15 @@ } .dayRangeStart { - border-radius: var(--radius-control) 0 0 var(--radius-control); + border-radius: 0; + border-start-start-radius: var(--radius-control); + border-end-start-radius: var(--radius-control); } .dayRangeEnd { - border-radius: 0 var(--radius-control) var(--radius-control) 0; + border-radius: 0; + border-start-end-radius: var(--radius-control); + border-end-end-radius: var(--radius-control); } .dayRangeStart.dayRangeEnd { @@ -304,8 +308,8 @@ .daySelected::after { position: absolute; - top: 1px; - right: 3px; + inset-block-start: 1px; + inset-inline-end: 3px; color: currentcolor; content: attr(data-range-edge); font-size: 8px; From 56b50cc87e937d18d7545c8b0b1933a6ee988052 Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 15 Jul 2026 20:01:37 +0800 Subject: [PATCH 11/15] fix(external-admin): preserve black-gold tokens in production --- external-admin/index.html | 2 +- external-admin/src/styles/index.css | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/external-admin/index.html b/external-admin/index.html index 8c5c043..329eb1a 100644 --- a/external-admin/index.html +++ b/external-admin/index.html @@ -1,5 +1,5 @@ - + diff --git a/external-admin/src/styles/index.css b/external-admin/src/styles/index.css index 9cda337..b4ab771 100644 --- a/external-admin/src/styles/index.css +++ b/external-admin/src/styles/index.css @@ -5,7 +5,7 @@ * All directional rules use CSS logical properties because the app also runs RTL (Arabic). */ -:root { +:root[data-external-admin-theme="black-gold"] { --bg-page: #0d0d11; --bg-sidebar: #101015; --bg-header: rgba(14, 14, 19, 0.88); @@ -88,7 +88,7 @@ /* Bigger touch targets on handsets; 16px inputs also stop iOS Safari focus-zoom. */ @media (max-width: 899px) { - :root { + :root[data-external-admin-theme="black-gold"] { --control-height: 44px; --control-font-size: 16px; --control-line-height: 22px; From a93370d43483509704801e532ea6822e49d9ef61 Mon Sep 17 00:00:00 2001 From: ZuoZuo <68836346+Mrz-sakura@users.noreply.github.com> Date: Wed, 15 Jul 2026 22:10:30 +0800 Subject: [PATCH 12/15] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=B8=81=E5=95=86?= =?UTF-8?q?=E5=85=85=E5=80=BC=E5=B8=81=E7=A7=8D=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...inanceCoinSellerRechargeOrderList.test.jsx | 10 ++++++ finance/src/constants.js | 32 +++++++++++++++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/finance/src/components/FinanceCoinSellerRechargeOrderList.test.jsx b/finance/src/components/FinanceCoinSellerRechargeOrderList.test.jsx index 5a147a8..2178749 100644 --- a/finance/src/components/FinanceCoinSellerRechargeOrderList.test.jsx +++ b/finance/src/components/FinanceCoinSellerRechargeOrderList.test.jsx @@ -1,5 +1,6 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, expect, test, vi } from "vitest"; +import { COIN_SELLER_RECHARGE_PROVIDER_CURRENCIES } from "../constants.js"; import { FinanceCoinSellerRechargeOrderList } from "./FinanceCoinSellerRechargeOrderList.jsx"; afterEach(() => { @@ -86,6 +87,15 @@ test("coin seller recharge order create dialog verifies receipt before create", ); }, 10000); +test("MiFaPay and V5Pay expose the complete runtime currency list", () => { + const runtimeCurrencies = Intl.supportedValuesOf("currency"); + + expect(COIN_SELLER_RECHARGE_PROVIDER_CURRENCIES.mifapay).toEqual(expect.arrayContaining(runtimeCurrencies)); + expect(COIN_SELLER_RECHARGE_PROVIDER_CURRENCIES.v5pay).toEqual(expect.arrayContaining(runtimeCurrencies)); + expect(COIN_SELLER_RECHARGE_PROVIDER_CURRENCIES.mifapay).toContain("BRL"); + expect(COIN_SELLER_RECHARGE_PROVIDER_CURRENCIES.v5pay).toContain("BRL"); +}); + test("makeup order keeps zero coins while creating a normal recharge record", async () => { const nowMS = new Date(2026, 6, 13, 9, 0, 0, 0).getTime(); vi.spyOn(Date, "now").mockReturnValue(nowMS); diff --git a/finance/src/constants.js b/finance/src/constants.js index 440d65b..8632bd1 100644 --- a/finance/src/constants.js +++ b/finance/src/constants.js @@ -29,9 +29,37 @@ export const COIN_SELLER_RECHARGE_CHAINS = [ ["BSC", "BSC"], ]; +// 三方回单使用 ISO 4217 字母代码,前端不再维护渠道币种白名单;白名单会随渠道扩区而过期, +// 造成后端能够校验的订单在录单入口不可选。Intl 数据来自浏览器运行时,并保留旧浏览器所需的现有币种兜底。 +const FALLBACK_PROVIDER_CURRENCIES = [ + "AED", + "BDT", + "BHD", + "BRL", + "EGP", + "IDR", + "INR", + "KWD", + "MXN", + "OMR", + "PHP", + "PKR", + "QAR", + "SAR", + "TRY", + "VND", +]; +export const COIN_SELLER_RECHARGE_CURRENCIES = [ + ...new Set( + typeof Intl.supportedValuesOf === "function" + ? [...Intl.supportedValuesOf("currency"), ...FALLBACK_PROVIDER_CURRENCIES] + : FALLBACK_PROVIDER_CURRENCIES, + ), +].sort(); + export const COIN_SELLER_RECHARGE_PROVIDER_CURRENCIES = { - mifapay: ["PHP", "INR", "IDR", "VND", "SAR", "AED", "BHD", "QAR", "OMR", "KWD", "BDT", "PKR", "EGP"], - v5pay: ["PHP", "SAR", "EGP", "AED", "IDR", "INR", "PKR", "TRY", "MXN"], + mifapay: COIN_SELLER_RECHARGE_CURRENCIES, + v5pay: COIN_SELLER_RECHARGE_CURRENCIES, }; export const COIN_SELLER_RECHARGE_STATUS = [ From 4d8422a6008104c8a8014e331b6444d2be3a920a Mon Sep 17 00:00:00 2001 From: zhx Date: Thu, 16 Jul 2026 10:29:42 +0800 Subject: [PATCH 13/15] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E5=A4=96=E7=AE=A1?= =?UTF-8?q?=E5=AF=86=E7=A0=81=E9=95=BF=E5=BA=A6=E4=B8=8E=E9=A6=96=E6=AC=A1?= =?UTF-8?q?=E6=94=B9=E5=AF=86=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contracts/admin-openapi.json | 4 +- external-admin/src/ExternalAdminApp.jsx | 46 +++++++++---------- external-admin/src/auth/RouteGuards.jsx | 5 -- external-admin/src/i18n/messages.js | 9 ++-- .../src/pages/ChangePasswordPage.jsx | 8 +--- external-admin/src/pages/LoginPage.jsx | 8 ++-- src/features/external-admin-users/schema.ts | 4 +- 7 files changed, 36 insertions(+), 48 deletions(-) diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index 38e8beb..104b28c 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -9826,7 +9826,7 @@ "maxLength": 64, "pattern": "^[A-Za-z0-9._-]+$" }, - "password": { "type": "string", "minLength": 8, "maxLength": 72 }, + "password": { "type": "string", "minLength": 1, "maxLength": 72 }, "permissions": { "type": "array", "items": { "type": "string", "minLength": 1 }, @@ -9907,7 +9907,7 @@ "required": ["password"], "additionalProperties": false, "properties": { - "password": { "type": "string", "minLength": 8, "maxLength": 72 } + "password": { "type": "string", "minLength": 1, "maxLength": 72 } } }, "ApiPageAppUser": { diff --git a/external-admin/src/ExternalAdminApp.jsx b/external-admin/src/ExternalAdminApp.jsx index aaad881..c559ebf 100644 --- a/external-admin/src/ExternalAdminApp.jsx +++ b/external-admin/src/ExternalAdminApp.jsx @@ -1,6 +1,6 @@ import { lazy, Suspense } from "react"; import { Navigate, Route, Routes } from "react-router-dom"; -import { RequireExternalAuth, RequireExternalCapability, RequirePasswordChanged } from "./auth/RouteGuards.jsx"; +import { RequireExternalAuth, RequireExternalCapability } from "./auth/RouteGuards.jsx"; import { ExternalAdminLayout } from "./layout/ExternalAdminLayout.jsx"; import { ExternalPageState } from "./shared/PageComponents.jsx"; @@ -21,30 +21,28 @@ export function ExternalAdminApp() { } path="/login" /> }> } path="/change-password" /> - }> - }> - } index /> - } path="/overview" /> - }> - } path="/users" /> - - }> - } path="/bans" /> - - }> - } path="/organization/:kind" /> - - }> - } path="/rooms" /> - - }> - } path="/grants" /> - - }> - } path="/banners" /> - - } path="*" /> + }> + } index /> + } path="/overview" /> + }> + } path="/users" /> + }> + } path="/bans" /> + + }> + } path="/organization/:kind" /> + + }> + } path="/rooms" /> + + }> + } path="/grants" /> + + }> + } path="/banners" /> + + } path="*" /> diff --git a/external-admin/src/auth/RouteGuards.jsx b/external-admin/src/auth/RouteGuards.jsx index d2ae5d0..599400b 100644 --- a/external-admin/src/auth/RouteGuards.jsx +++ b/external-admin/src/auth/RouteGuards.jsx @@ -38,11 +38,6 @@ export function RequireExternalAuth() { return ; } -export function RequirePasswordChanged() { - const { session } = useExternalAuth(); - return session?.passwordChangeRequired ? : ; -} - export function RequireExternalCapability({ anyOf }) { const { session } = useExternalAuth(); const { t } = useExternalI18n(); diff --git a/external-admin/src/i18n/messages.js b/external-admin/src/i18n/messages.js index c420b8d..97400cd 100644 --- a/external-admin/src/i18n/messages.js +++ b/external-admin/src/i18n/messages.js @@ -41,7 +41,6 @@ const en = { "auth.confirmNewPassword": "Confirm new password", "auth.currentPassword": "Current password", "auth.fillAccountPassword": "Enter your account and password", - "auth.firstLoginChange": "You must change your password on first sign-in", "auth.hidePassword": "Hide password", "auth.invalidCredentials": "Incorrect account or password", "auth.login": "Sign in", @@ -52,8 +51,6 @@ const en = { "auth.password": "Password", "auth.passwordChangedFailed": "Failed to change password", "auth.passwordMismatch": "The new passwords do not match", - "auth.passwordMin": "At least 8 characters", - "auth.passwordMinError": "The new password must be at least 8 characters", "auth.passwordWhitespace": "The new password cannot be empty or contain only whitespace", "auth.recheck": "Check again", "auth.returnOverview": "Back to Home", @@ -328,7 +325,7 @@ const zhCN = { "app.shortTitle": "外管后台", "language.label": "语言", "common.actions": "操作", "common.active": "正常", "common.all": "全部", "common.allStatuses": "全部状态", "common.banned": "已封禁", "common.cancel": "取消", "common.confirm": "确认", "common.create": "创建", "common.disabled": "禁用", "common.edit": "编辑", "common.enabled": "启用", "common.expired": "过期", "common.loading": "正在加载", "common.noData": "当前无数据", "common.normal": "正常", "common.query": "查询", "common.reason": "原因", "common.reload": "重新加载", "common.retry": "重试", "common.save": "保存", "common.status": "状态", "common.user": "用户", - "auth.account": "账号", "auth.changePassword": "修改密码", "auth.checkingSession": "正在校验会话", "auth.confirmNewPassword": "确认新密码", "auth.currentPassword": "当前密码", "auth.fillAccountPassword": "请填写账号和密码", "auth.firstLoginChange": "首次登录必须修改密码", "auth.hidePassword": "隐藏密码", "auth.invalidCredentials": "账号或密码错误", "auth.login": "登录", "auth.loginFailed": "登录失败", "auth.logout": "退出登录", "auth.newPassword": "新密码", "auth.noPermission": "当前账号没有访问此功能的权限。", "auth.password": "密码", "auth.passwordChangedFailed": "密码修改失败", "auth.passwordMismatch": "两次输入的新密码不一致", "auth.passwordMin": "至少 8 个字符", "auth.passwordMinError": "新密码至少 8 个字符", "auth.passwordWhitespace": "新密码不能为空或全为空白字符", "auth.recheck": "重新校验", "auth.returnOverview": "返回首页", "auth.savePassword": "保存密码", "auth.sessionFailed": "会话校验失败", "auth.showPassword": "显示密码", + "auth.account": "账号", "auth.changePassword": "修改密码", "auth.checkingSession": "正在校验会话", "auth.confirmNewPassword": "确认新密码", "auth.currentPassword": "当前密码", "auth.fillAccountPassword": "请填写账号和密码", "auth.hidePassword": "隐藏密码", "auth.invalidCredentials": "账号或密码错误", "auth.login": "登录", "auth.loginFailed": "登录失败", "auth.logout": "退出登录", "auth.newPassword": "新密码", "auth.noPermission": "当前账号没有访问此功能的权限。", "auth.password": "密码", "auth.passwordChangedFailed": "密码修改失败", "auth.passwordMismatch": "两次输入的新密码不一致", "auth.passwordWhitespace": "新密码不能为空或全为空白字符", "auth.recheck": "重新校验", "auth.returnOverview": "返回首页", "auth.savePassword": "保存密码", "auth.sessionFailed": "会话校验失败", "auth.showPassword": "显示密码", "nav.expand": "展开菜单", "nav.more": "更多", "nav.overview": "首页", "nav.users": "用户管理", "nav.bans": "封禁管理", "nav.hosts": "主播列表", "nav.agencies": "公会列表", "nav.bds": "BD 列表", "nav.bdManagers": "BD Manager 列表", "nav.superAdmins": "Super Admin 列表", "nav.rooms": "房间管理", "nav.grants": "资源与靓号", "nav.banners": "Banner 管理", "nav.team": "我的团队", "capability.userList": "用户列表", "capability.userUpdate": "用户信息编辑", "capability.banList": "账号封禁列表", "capability.ban": "执行封禁", "capability.unban": "解除封禁", "capability.hostList": "主播列表", "capability.agencyList": "公会列表", "capability.bdList": "BD 列表", "capability.bdManagerList": "BD Manager 列表", "capability.superAdminList": "Super Admin 列表", "capability.roomList": "房间管理", "capability.roomUpdate": "房间编辑", "capability.privilegeList": "用户特权道具列表", "capability.privilegeGrant": "特权道具发放", "capability.bannerCreate": "Banner 创建", "capability.prettyIdGrant": "靓号下发", "capability.userTitleGrant": "用户称号下发", "capability.roomBackgroundGrant": "房间背景图下发", "capability.userLevelGrant": "财富/VIP 等级下发", "capability.teamView": "我的团队", "overview.currentApp": "当前 App", "overview.account": "登录账号", "overview.permissions": "已开通权限", "overview.welcome": "欢迎回来", "overview.features": "功能入口", "overview.enabled": "已开通", "overview.notEnabled": "未开通", @@ -350,7 +347,7 @@ const ar = { ...en, "app.title": "الإدارة الخارجية", "app.shortTitle": "الإدارة الخارجية", "language.label": "اللغة", "common.actions": "الإجراءات", "common.active": "نشط", "common.all": "الكل", "common.allStatuses": "كل الحالات", "common.banned": "محظور", "common.cancel": "إلغاء", "common.confirm": "تأكيد", "common.create": "إنشاء", "common.disabled": "معطّل", "common.edit": "تعديل", "common.enabled": "مفعّل", "common.expired": "منتهي", "common.loading": "جارٍ التحميل", "common.noData": "لا توجد بيانات", "common.normal": "طبيعي", "common.query": "بحث", "common.reason": "السبب", "common.reload": "إعادة التحميل", "common.retry": "إعادة المحاولة", "common.save": "حفظ", "common.status": "الحالة", "common.user": "المستخدم", - "auth.account": "الحساب", "auth.changePassword": "تغيير كلمة المرور", "auth.checkingSession": "جارٍ التحقق من الجلسة", "auth.confirmNewPassword": "تأكيد كلمة المرور الجديدة", "auth.currentPassword": "كلمة المرور الحالية", "auth.fillAccountPassword": "أدخل الحساب وكلمة المرور", "auth.firstLoginChange": "يجب تغيير كلمة المرور عند تسجيل الدخول لأول مرة", "auth.hidePassword": "إخفاء كلمة المرور", "auth.invalidCredentials": "الحساب أو كلمة المرور غير صحيحة", "auth.login": "تسجيل الدخول", "auth.loginFailed": "فشل تسجيل الدخول", "auth.logout": "تسجيل الخروج", "auth.newPassword": "كلمة المرور الجديدة", "auth.noPermission": "لا يملك هذا الحساب صلاحية الوصول إلى هذه الميزة.", "auth.password": "كلمة المرور", "auth.passwordChangedFailed": "فشل تغيير كلمة المرور", "auth.passwordMismatch": "كلمتا المرور الجديدتان غير متطابقتين", "auth.passwordMin": "8 أحرف على الأقل", "auth.passwordMinError": "يجب أن تتكون كلمة المرور الجديدة من 8 أحرف على الأقل", "auth.passwordWhitespace": "لا يمكن أن تكون كلمة المرور الجديدة فارغة أو مسافات فقط", "auth.recheck": "إعادة التحقق", "auth.returnOverview": "العودة إلى الرئيسية", "auth.savePassword": "حفظ كلمة المرور", "auth.sessionFailed": "فشل التحقق من الجلسة", "auth.showPassword": "إظهار كلمة المرور", + "auth.account": "الحساب", "auth.changePassword": "تغيير كلمة المرور", "auth.checkingSession": "جارٍ التحقق من الجلسة", "auth.confirmNewPassword": "تأكيد كلمة المرور الجديدة", "auth.currentPassword": "كلمة المرور الحالية", "auth.fillAccountPassword": "أدخل الحساب وكلمة المرور", "auth.hidePassword": "إخفاء كلمة المرور", "auth.invalidCredentials": "الحساب أو كلمة المرور غير صحيحة", "auth.login": "تسجيل الدخول", "auth.loginFailed": "فشل تسجيل الدخول", "auth.logout": "تسجيل الخروج", "auth.newPassword": "كلمة المرور الجديدة", "auth.noPermission": "لا يملك هذا الحساب صلاحية الوصول إلى هذه الميزة.", "auth.password": "كلمة المرور", "auth.passwordChangedFailed": "فشل تغيير كلمة المرور", "auth.passwordMismatch": "كلمتا المرور الجديدتان غير متطابقتين", "auth.passwordWhitespace": "لا يمكن أن تكون كلمة المرور الجديدة فارغة أو مسافات فقط", "auth.recheck": "إعادة التحقق", "auth.returnOverview": "العودة إلى الرئيسية", "auth.savePassword": "حفظ كلمة المرور", "auth.sessionFailed": "فشل التحقق من الجلسة", "auth.showPassword": "إظهار كلمة المرور", "nav.expand": "فتح القائمة", "nav.more": "المزيد", "nav.overview": "الرئيسية", "nav.users": "إدارة المستخدمين", "nav.bans": "إدارة الحظر", "nav.hosts": "المضيفون", "nav.agencies": "الوكالات", "nav.bds": "قائمة BD", "nav.bdManagers": "مديرو BD", "nav.superAdmins": "المشرفون العامون", "nav.rooms": "إدارة الغرف", "nav.grants": "الموارد والمعرّفات المميزة", "nav.banners": "إدارة اللافتات", "nav.team": "فريقي", "capability.userList": "قائمة المستخدمين", "capability.userUpdate": "تعديل بيانات المستخدم", "capability.banList": "قائمة الحسابات المحظورة", "capability.ban": "حظر المستخدمين", "capability.unban": "إلغاء حظر المستخدمين", "capability.hostList": "قائمة المضيفين", "capability.agencyList": "قائمة الوكالات", "capability.bdList": "قائمة BD", "capability.bdManagerList": "قائمة مديري BD", "capability.superAdminList": "قائمة المشرفين العامين", "capability.roomList": "إدارة الغرف", "capability.roomUpdate": "تعديل الغرف", "capability.privilegeList": "قائمة عناصر الامتياز", "capability.privilegeGrant": "منح عناصر الامتياز", "capability.bannerCreate": "إنشاء اللافتات", "capability.prettyIdGrant": "منح المعرّفات المميزة", "capability.userTitleGrant": "منح ألقاب المستخدمين", "capability.roomBackgroundGrant": "منح خلفيات الغرف", "capability.userLevelGrant": "منح مستويات الثروة/VIP", "capability.teamView": "فريقي", "overview.currentApp": "التطبيق الحالي", "overview.account": "الحساب", "overview.permissions": "الصلاحيات المفعّلة", "overview.welcome": "مرحبًا بعودتك", "overview.features": "الخدمات", "overview.enabled": "مفعّلة", "overview.notEnabled": "غير مفعّلة", @@ -372,7 +369,7 @@ const tr = { ...en, "app.title": "Harici Yönetim", "app.shortTitle": "Harici Yönetim", "language.label": "Dil", "common.actions": "İşlemler", "common.active": "Aktif", "common.all": "Tümü", "common.allStatuses": "Tüm durumlar", "common.banned": "Yasaklı", "common.cancel": "İptal", "common.confirm": "Onayla", "common.create": "Oluştur", "common.disabled": "Devre dışı", "common.edit": "Düzenle", "common.enabled": "Etkin", "common.expired": "Süresi dolmuş", "common.loading": "Yükleniyor", "common.noData": "Veri yok", "common.normal": "Normal", "common.query": "Ara", "common.reason": "Neden", "common.reload": "Yeniden yükle", "common.retry": "Tekrar dene", "common.save": "Kaydet", "common.status": "Durum", "common.user": "Kullanıcı", - "auth.account": "Hesap", "auth.changePassword": "Parolayı değiştir", "auth.checkingSession": "Oturum kontrol ediliyor", "auth.confirmNewPassword": "Yeni parolayı doğrula", "auth.currentPassword": "Mevcut parola", "auth.fillAccountPassword": "Hesabınızı ve parolanızı girin", "auth.firstLoginChange": "İlk girişte parolanızı değiştirmeniz gerekir", "auth.hidePassword": "Parolayı gizle", "auth.invalidCredentials": "Hesap veya parola hatalı", "auth.login": "Giriş yap", "auth.loginFailed": "Giriş başarısız", "auth.logout": "Çıkış yap", "auth.newPassword": "Yeni parola", "auth.noPermission": "Bu hesabın bu özelliğe erişimi yok.", "auth.password": "Parola", "auth.passwordChangedFailed": "Parola değiştirilemedi", "auth.passwordMismatch": "Yeni parolalar eşleşmiyor", "auth.passwordMin": "En az 8 karakter", "auth.passwordMinError": "Yeni parola en az 8 karakter olmalıdır", "auth.passwordWhitespace": "Yeni parola boş veya yalnızca boşluk olamaz", "auth.recheck": "Tekrar kontrol et", "auth.returnOverview": "Ana sayfaya dön", "auth.savePassword": "Parolayı kaydet", "auth.sessionFailed": "Oturum doğrulanamadı", "auth.showPassword": "Parolayı göster", + "auth.account": "Hesap", "auth.changePassword": "Parolayı değiştir", "auth.checkingSession": "Oturum kontrol ediliyor", "auth.confirmNewPassword": "Yeni parolayı doğrula", "auth.currentPassword": "Mevcut parola", "auth.fillAccountPassword": "Hesabınızı ve parolanızı girin", "auth.hidePassword": "Parolayı gizle", "auth.invalidCredentials": "Hesap veya parola hatalı", "auth.login": "Giriş yap", "auth.loginFailed": "Giriş başarısız", "auth.logout": "Çıkış yap", "auth.newPassword": "Yeni parola", "auth.noPermission": "Bu hesabın bu özelliğe erişimi yok.", "auth.password": "Parola", "auth.passwordChangedFailed": "Parola değiştirilemedi", "auth.passwordMismatch": "Yeni parolalar eşleşmiyor", "auth.passwordWhitespace": "Yeni parola boş veya yalnızca boşluk olamaz", "auth.recheck": "Tekrar kontrol et", "auth.returnOverview": "Ana sayfaya dön", "auth.savePassword": "Parolayı kaydet", "auth.sessionFailed": "Oturum doğrulanamadı", "auth.showPassword": "Parolayı göster", "nav.expand": "Menüyü aç", "nav.more": "Daha fazla", "nav.overview": "Ana sayfa", "nav.users": "Kullanıcı yönetimi", "nav.bans": "Yasak yönetimi", "nav.hosts": "Yayıncılar", "nav.agencies": "Ajanslar", "nav.bds": "BD listesi", "nav.bdManagers": "BD Yöneticileri", "nav.superAdmins": "Süper Yöneticiler", "nav.rooms": "Oda yönetimi", "nav.grants": "Kaynaklar ve özel ID'ler", "nav.banners": "Banner yönetimi", "nav.team": "Ekibim", "capability.userList": "Kullanıcı listesi", "capability.userUpdate": "Kullanıcı bilgilerini düzenleme", "capability.banList": "Yasaklı hesap listesi", "capability.ban": "Kullanıcı yasaklama", "capability.unban": "Kullanıcı yasağını kaldırma", "capability.hostList": "Yayıncı listesi", "capability.agencyList": "Ajans listesi", "capability.bdList": "BD listesi", "capability.bdManagerList": "BD Yöneticisi listesi", "capability.superAdminList": "Süper Yönetici listesi", "capability.roomList": "Oda yönetimi", "capability.roomUpdate": "Oda düzenleme", "capability.privilegeList": "Ayrıcalık öğesi listesi", "capability.privilegeGrant": "Ayrıcalık öğesi verme", "capability.bannerCreate": "Banner oluşturma", "capability.prettyIdGrant": "Özel ID verme", "capability.userTitleGrant": "Kullanıcı unvanı verme", "capability.roomBackgroundGrant": "Oda arka planı verme", "capability.userLevelGrant": "Servet/VIP seviyesi verme", "capability.teamView": "Ekibim", "overview.currentApp": "Geçerli uygulama", "overview.account": "Hesap", "overview.permissions": "Etkin yetkiler", "overview.welcome": "Tekrar hoş geldiniz", "overview.features": "Özellikler", "overview.enabled": "Etkin", "overview.notEnabled": "Etkin değil", diff --git a/external-admin/src/pages/ChangePasswordPage.jsx b/external-admin/src/pages/ChangePasswordPage.jsx index 31d923d..a14a139 100644 --- a/external-admin/src/pages/ChangePasswordPage.jsx +++ b/external-admin/src/pages/ChangePasswordPage.jsx @@ -30,10 +30,6 @@ export default function ChangePasswordPage() { setError(t("auth.passwordWhitespace")); return; } - if (form.newPassword.length < 8) { - setError(t("auth.passwordMinError")); - return; - } if (form.newPassword !== form.confirmPassword) { setError(t("auth.passwordMismatch")); return; @@ -70,12 +66,12 @@ export default function ChangePasswordPage() { size="large" /> {t("auth.changePassword")} - {session?.passwordChangeRequired ? t("auth.firstLoginChange") : session?.account} + {session?.account} {error ? {error} : null} setForm({ ...form, oldPassword: event.target.value })} /> - setForm({ ...form, newPassword: event.target.value })} /> + setForm({ ...form, newPassword: event.target.value })} /> setForm({ ...form, confirmPassword: event.target.value })} /> diff --git a/external-admin/src/pages/LoginPage.jsx b/external-admin/src/pages/LoginPage.jsx index d631aad..458c9cf 100644 --- a/external-admin/src/pages/LoginPage.jsx +++ b/external-admin/src/pages/LoginPage.jsx @@ -36,7 +36,9 @@ export default function LoginPage() { useEffect(() => { if (!initializing && session) { - const destination = session.passwordChangeRequired ? "/change-password" : location.state?.from?.pathname || "/overview"; + // Password rotation is optional. An authenticated session always returns to the + // requested business page, including sessions created by older server versions. + const destination = location.state?.from?.pathname || "/overview"; navigate(destination, { replace: true }); } }, [initializing, location.state, navigate, session]); @@ -50,8 +52,8 @@ export default function LoginPage() { setSubmitting(true); setError(""); try { - const nextSession = await login(form); - navigate(nextSession.passwordChangeRequired ? "/change-password" : "/overview", { replace: true }); + await login(form); + navigate("/overview", { replace: true }); } catch (requestError) { // Every login 401 uses one generic credential message so unknown, disabled and locked accounts cannot be distinguished. setError(requestError?.status === 401 ? t("auth.invalidCredentials") : translateExternalError(requestError, t, "auth.loginFailed")); diff --git a/src/features/external-admin-users/schema.ts b/src/features/external-admin-users/schema.ts index c39cc70..6f2e719 100644 --- a/src/features/external-admin-users/schema.ts +++ b/src/features/external-admin-users/schema.ts @@ -27,10 +27,10 @@ const permissionsSchema = z.array(permissionCodeSchema).max(200, "权限数量 ...new Set(items), ]); -// 密码属于登录凭证,不能 trim 或自动改写;前端只做长度与确认值校验,服务端会重复校验并仅持久化密码摘要。 +// 密码属于登录凭证,不能 trim 或自动改写;外管不设最小长度,但空值和全空白仍不可作为有效凭证。 const passwordSchema = z .string() - .min(8, "密码至少 8 位") + .min(1, "请输入密码") .max(72, "密码不能超过 72 个字符") .refine((value) => value.trim().length > 0, "密码不能全部为空白字符"); From e04aa7ed2f4bf9f4aa4171eff9d3eaecda8ade1e Mon Sep 17 00:00:00 2001 From: zhx Date: Thu, 16 Jul 2026 11:26:31 +0800 Subject: [PATCH 14/15] fix(databi): separate game trend and detail navigation --- databi/src/social/views/OverviewView.jsx | 27 +++++++++++++++--------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/databi/src/social/views/OverviewView.jsx b/databi/src/social/views/OverviewView.jsx index 7909faa..8a1f644 100644 --- a/databi/src/social/views/OverviewView.jsx +++ b/databi/src/social/views/OverviewView.jsx @@ -2,6 +2,7 @@ // 数据契约见 SocialBiApp.jsx 的 useSocialBi();本视图只做展示与聚合,不直接调 API。 import { useMemo, useState } from "react"; +import Button from "@mui/material/Button"; import { EChart } from "../../charts/EChart.jsx"; import { deltaDirection, formatCount, formatDeltaRate, formatMoney, isBlank } from "../format.js"; import { aggregateMetricMaps, appColor, formatMetric, metricChartValue, metricLabel, metricTooltip } from "../metrics.js"; @@ -22,7 +23,8 @@ const TREND_METRICS = [ { key: "recharge_usd_minor", label: "充值" }, { key: "new_users", label: "新增" }, { key: "active_users", label: "日活" }, - { key: "gift_coin_spent", label: "礼物流水" } + { key: "gift_coin_spent", label: "礼物流水" }, + { key: "game_turnover", label: "游戏流水" } ]; const COMPARE_METRICS = [ @@ -332,26 +334,31 @@ export function OverviewView() { {trendLabel}趋势 按 App 堆叠
    -
    + {trendMetric === "game_turnover" ? ( + // 游戏流水先留在概览中展示趋势,只有用户明确点击明细入口时才切换到数据需求视图。 + + ) : null} +
    {TREND_METRICS.map((item) => ( ))} -
    From 00b6af42ddd98036f57199dc61d19971a5436261 Mon Sep 17 00:00:00 2001 From: zhx Date: Thu, 16 Jul 2026 11:48:12 +0800 Subject: [PATCH 15/15] =?UTF-8?q?=E6=B8=B8=E6=88=8F=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- databi/src/DatabiApp.test.jsx | 27 +- databi/src/social/SocialBiApp.jsx | 2 +- databi/src/social/state.js | 8 +- databi/src/social/views/DataTableView.jsx | 50 +- databi/src/social/views/OverviewView.jsx | 51 +- external-admin/src/pages/LoginPage.jsx | 2 +- src/features/games/adapterConfigModel.js | 670 ++++++++++++++++++ src/features/games/api.ts | 2 +- .../games/components/AdapterConfigFields.jsx | 320 +++++++++ .../components/AdapterConfigFields.module.css | 162 +++++ .../games/components/PlatformFormDialog.jsx | 210 ++++++ .../components/PlatformFormDialog.module.css | 3 + src/features/games/games.module.css | 6 - src/features/games/hooks/useGamesPage.js | 55 +- src/features/games/pages/GameListPage.jsx | 119 +--- src/shared/ui/JsonEditorField.jsx | 4 +- 16 files changed, 1482 insertions(+), 209 deletions(-) create mode 100644 src/features/games/adapterConfigModel.js create mode 100644 src/features/games/components/AdapterConfigFields.jsx create mode 100644 src/features/games/components/AdapterConfigFields.module.css create mode 100644 src/features/games/components/PlatformFormDialog.jsx create mode 100644 src/features/games/components/PlatformFormDialog.module.css diff --git a/databi/src/DatabiApp.test.jsx b/databi/src/DatabiApp.test.jsx index 6373b28..969e48c 100644 --- a/databi/src/DatabiApp.test.jsx +++ b/databi/src/DatabiApp.test.jsx @@ -543,10 +543,31 @@ test("renders social BI data requirements table with section filters and CSV exp expect(fetchSocialBiRequirements).toHaveBeenCalledWith(expect.objectContaining({ payerType: "all", regionIds: [9, 11], - section: "new_users", + section: "revenue", userRole: "all" })); - expect(screen.getByRole("tab", { name: "新用户" })).toHaveAttribute("aria-selected", "true"); + const requirementTabs = screen.getByRole("tablist", { name: "数据需求分组" }); + expect(within(requirementTabs).getAllByRole("tab").map((tab) => tab.textContent)).toEqual([ + "充值", + "新增", + "日活", + "礼物", + "游戏", + "留存" + ]); + expect(screen.getByRole("tab", { name: "充值" })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByRole("radiogroup", { name: "用户身份" })).toBeTruthy(); + expect(screen.getByRole("radiogroup", { name: "付费身份" })).toBeTruthy(); + expect(screen.getByText("充值用户")).toBeTruthy(); + expect(screen.getByText("充值金额")).toBeTruthy(); + expect(screen.getAllByText("$123").length).toBeGreaterThan(0); + + await act(async () => { + screen.getByRole("tab", { name: "新增" }).click(); + }); + await flushEffects(); + expect(fetchSocialBiRequirements).toHaveBeenLastCalledWith(expect.objectContaining({ section: "new_users" })); + expect(screen.getByRole("tab", { name: "新增" })).toHaveAttribute("aria-selected", "true"); expect(screen.getByText("下载APP人数")).toBeTruthy(); expect(screen.getByText("注册成功率")).toBeTruthy(); expect(screen.getAllByText("12").length).toBeGreaterThan(0); @@ -555,7 +576,7 @@ test("renders social BI data requirements table with section filters and CSV exp expect(screen.queryByRole("radiogroup", { name: "付费身份" })).toBeNull(); await act(async () => { - screen.getByRole("tab", { name: "营收" }).click(); + screen.getByRole("tab", { name: "充值" }).click(); }); await flushEffects(); expect(screen.getByRole("radiogroup", { name: "用户身份" })).toBeTruthy(); diff --git a/databi/src/social/SocialBiApp.jsx b/databi/src/social/SocialBiApp.jsx index cd8af39..0875c1f 100644 --- a/databi/src/social/SocialBiApp.jsx +++ b/databi/src/social/SocialBiApp.jsx @@ -88,7 +88,7 @@ export function SocialBiApp() { }, []); const openRequirementSection = useCallback((requirementSection) => { - // 下钻必须同时设置明细模式和业务分组,否则 DataTableView 会按默认值落回“运营宽表 / 新用户”。 + // 下钻必须同时设置明细模式和业务分组,否则 DataTableView 会按默认值落回“运营宽表 / 充值”。 setTableState({ mode: "requirements", requirementSection }); setViewKey("table"); }, []); diff --git a/databi/src/social/state.js b/databi/src/social/state.js index b0c8b9b..3dadb04 100644 --- a/databi/src/social/state.js +++ b/databi/src/social/state.js @@ -6,12 +6,12 @@ import { lastDaysRange, thisMonthRange, todayRange } from "../utils/time.js"; export const ALL = "all"; const TABLE_MODES = new Set(["wide", "requirements"]); -const REQUIREMENT_SECTIONS = new Set(["new_users", "revenue", "retention", "active", "gift", "game"]); +const REQUIREMENT_SECTIONS = new Set(["revenue", "new_users", "active", "gift", "game", "retention"]); export function createDefaultTableState() { return { mode: "wide", - requirementSection: "new_users" + requirementSection: "revenue" }; } @@ -200,9 +200,9 @@ export function writeStateToURL(filters, view, table, history = window.history, params.set("view", view); } if (view === "table" && table?.mode === "requirements") { - // 目标分组与数据需求模式一起入 URL,保证概览下钻和复制后的链接都能落到同一个游戏数据视图。 + // 非默认分组与数据需求模式一起入 URL,保证概览下钻和复制后的链接都能落到同一个业务明细视图。 params.set("mode", "requirements"); - if (REQUIREMENT_SECTIONS.has(table.requirementSection) && table.requirementSection !== "new_users") { + if (REQUIREMENT_SECTIONS.has(table.requirementSection) && table.requirementSection !== "revenue") { params.set("section", table.requirementSection); } } diff --git a/databi/src/social/views/DataTableView.jsx b/databi/src/social/views/DataTableView.jsx index cf9004d..8a88dc0 100644 --- a/databi/src/social/views/DataTableView.jsx +++ b/databi/src/social/views/DataTableView.jsx @@ -47,17 +47,6 @@ const TABLE_MODES = [ ]; const REQUIREMENT_SECTIONS = [ - { - csvName: "p0-new-users", - fallbackColumns: [ - { key: "app_first_open_users", label: "下载APP人数", type: "count" }, - { key: "new_users", label: "新增注册", type: "count" }, - { key: "profile_complete_users", label: "完善资料", type: "count" }, - { key: "first_room_join_users", label: "首次进房", type: "count" } - ], - key: "new_users", - label: "新用户" - }, { csvName: "p1-revenue", fallbackColumns: [ @@ -68,25 +57,21 @@ const REQUIREMENT_SECTIONS = [ { key: "arppu_usd_minor", label: "ARPPU", type: "money_minor" } ], key: "revenue", - label: "营收", + label: "充值", newUserFilter: true, payerFilter: true, roleFilter: true }, { - csvName: "p2-retention", + csvName: "p0-new-users", fallbackColumns: [ - { key: "active_d1_active_base_users", label: "活跃用户基数", type: "count" }, - { key: "active_d1_active_users", label: "次留人数", type: "count" }, - { key: "active_d1_active_rate", label: "用户次留", type: "ratio" }, - { key: "active_d7_active_rate", label: "用户7日留", type: "ratio" }, - { key: "active_d30_active_rate", label: "用户30日留", type: "ratio" } + { key: "app_first_open_users", label: "下载APP人数", type: "count" }, + { key: "new_users", label: "新增注册", type: "count" }, + { key: "profile_complete_users", label: "完善资料", type: "count" }, + { key: "first_room_join_users", label: "首次进房", type: "count" } ], - key: "retention", - label: "留存", - newUserFilter: true, - payerFilter: true, - roleFilter: true + key: "new_users", + label: "新增" }, { csvName: "p2-active", @@ -98,7 +83,7 @@ const REQUIREMENT_SECTIONS = [ { key: "avg_session_ms", label: "人均在线", type: "duration_ms" } ], key: "active", - label: "活跃", + label: "日活", newUserFilter: true, payerFilter: true, roleFilter: true @@ -112,7 +97,7 @@ const REQUIREMENT_SECTIONS = [ { key: "gift_paid_users", label: "付费送礼用户", type: "count" } ], key: "gift", - label: "送礼", + label: "礼物", newUserFilter: true, roleFilter: true }, @@ -129,6 +114,21 @@ const REQUIREMENT_SECTIONS = [ label: "游戏", newUserFilter: true, roleFilter: true + }, + { + csvName: "p2-retention", + fallbackColumns: [ + { key: "active_d1_active_base_users", label: "活跃用户基数", type: "count" }, + { key: "active_d1_active_users", label: "次留人数", type: "count" }, + { key: "active_d1_active_rate", label: "用户次留", type: "ratio" }, + { key: "active_d7_active_rate", label: "用户7日留", type: "ratio" }, + { key: "active_d30_active_rate", label: "用户30日留", type: "ratio" } + ], + key: "retention", + label: "留存", + newUserFilter: true, + payerFilter: true, + roleFilter: true } ]; diff --git a/databi/src/social/views/OverviewView.jsx b/databi/src/social/views/OverviewView.jsx index 8a1f644..e876a6a 100644 --- a/databi/src/social/views/OverviewView.jsx +++ b/databi/src/social/views/OverviewView.jsx @@ -4,7 +4,7 @@ import { useMemo, useState } from "react"; import Button from "@mui/material/Button"; import { EChart } from "../../charts/EChart.jsx"; -import { deltaDirection, formatCount, formatDeltaRate, formatMoney, isBlank } from "../format.js"; +import { deltaDirection, formatCount, formatDeltaRate, formatMoney, formatPercentValue, isBlank } from "../format.js"; import { aggregateMetricMaps, appColor, formatMetric, metricChartValue, metricLabel, metricTooltip } from "../metrics.js"; import { SocialAppIdentity, useSocialBi } from "../SocialBiApp.jsx"; import { bucketDay } from "../state.js"; @@ -20,11 +20,12 @@ const HERO_METRICS = [ ]; const TREND_METRICS = [ - { key: "recharge_usd_minor", label: "充值" }, - { key: "new_users", label: "新增" }, - { key: "active_users", label: "日活" }, - { key: "gift_coin_spent", label: "礼物流水" }, - { key: "game_turnover", label: "游戏流水" } + { key: "recharge_usd_minor", label: "充值", requirementSection: "revenue" }, + { key: "new_users", label: "新增", requirementSection: "new_users" }, + { key: "active_users", label: "日活", requirementSection: "active" }, + { key: "gift_coin_spent", label: "礼物", requirementSection: "gift" }, + { key: "game_turnover", label: "游戏", requirementSection: "game" }, + { key: "d1_retention_rate", label: "留存", requirementSection: "retention" } ]; const COMPARE_METRICS = [ @@ -42,12 +43,16 @@ const MONEY_METRIC_KEYS = new Set([ "mifapay_recharge_usd_minor", "coin_seller_recharge_usd_minor" ]); +const RATIO_METRIC_KEYS = new Set(["d1_retention_rate"]); // 图表值已由 metricChartValue 换算(金额→美元),tooltip 里按指标类型补格式。 function formatChartAmount(metricKey, value) { if (isBlank(value)) { return "--"; } + if (RATIO_METRIC_KEYS.has(metricKey)) { + return formatPercentValue(value); + } return MONEY_METRIC_KEYS.has(metricKey) ? formatMoney(value) : formatCount(value); } @@ -65,6 +70,7 @@ function buildBuckets(rows, granularity) { } function buildStackedBarOption({ bucketKeys, series, valueMetric }) { + const isRatioMetric = RATIO_METRIC_KEYS.has(valueMetric); return { grid: { bottom: 4, containLabel: true, left: 8, right: 8, top: 28 }, legend: { itemHeight: 10, itemWidth: 10, left: 0, textStyle: { color: "#5b7089", fontSize: 12 }, top: 0 }, @@ -84,7 +90,7 @@ function buildStackedBarOption({ bucketKeys, series, valueMetric }) { return `${item.marker}${item.seriesName}  ${formatChartAmount(valueMetric, numeric)}`; }); const header = list[0]?.axisValueLabel || list[0]?.name || ""; - if (list.length > 1) { + if (list.length > 1 && !isRatioMetric) { lines.push(`合计  ${formatChartAmount(valueMetric, total)}`); } return [header, ...lines].filter(Boolean).join("
    "); @@ -100,7 +106,7 @@ function buildStackedBarOption({ bucketKeys, series, valueMetric }) { type: "category" }, yAxis: { - axisLabel: { color: "#5b7089", fontSize: 11 }, + axisLabel: { color: "#5b7089", fontSize: 11, ...(isRatioMetric ? { formatter: "{value}%" } : {}) }, splitLine: { lineStyle: { color: "#eef3f9" } }, type: "value" } @@ -230,12 +236,13 @@ export function OverviewView() { }, [appCodes, buckets]); const trendOption = useMemo(() => { + const shouldStack = !RATIO_METRIC_KEYS.has(trendMetric); const series = appBuckets.apps.map((app) => ({ barMaxWidth: 26, data: app.cells.map((cell) => (cell ? metricChartValue(trendMetric, cell[trendMetric]) : null)), itemStyle: { color: appColor(app.app_code, appCodes) }, name: app.app_name, - stack: "total", + ...(shouldStack ? { stack: "total" } : {}), type: "bar" })); return buildStackedBarOption({ bucketKeys: appBuckets.keys, series, valueMetric: trendMetric }); @@ -298,7 +305,7 @@ export function OverviewView() { const soloApp = !derived.regionScoped && derived.appTotals.length === 1 ? derived.appTotals[0] : null; const hasRestricted = derived.appTotals.some((row) => row.restricted) || derived.appErrors.some((row) => row.restricted); const hasTrend = appBuckets.keys.length > 0 && appBuckets.apps.length > 0; - const trendLabel = TREND_METRICS.find((item) => item.key === trendMetric)?.label || ""; + const trendConfig = TREND_METRICS.find((item) => item.key === trendMetric) || TREND_METRICS[0]; if (isLoading && !derived.appTotals.length && !derived.regionTotals.length) { return ; @@ -331,20 +338,18 @@ export function OverviewView() {
    - {trendLabel}趋势 - 按 App 堆叠 + {trendConfig.label}趋势 + {RATIO_METRIC_KEYS.has(trendMetric) ? "按 App 对比" : "按 App 堆叠"}
    - {trendMetric === "game_turnover" ? ( - // 游戏流水先留在概览中展示趋势,只有用户明确点击明细入口时才切换到数据需求视图。 - - ) : null} + {/* 趋势 Tab 与数据需求分组一一映射,按钮始终跟随当前指标进入对应明细。 */} +
    {TREND_METRICS.map((item) => ( +
    + {rows.length > 0 ? ( +
    + {rows.map((row, index) => { + const duplicate = + row.providerGameId.trim() !== "" && + rows.some( + (candidate, candidateIndex) => + candidateIndex !== index && + candidate.providerGameId.trim() === row.providerGameId.trim(), + ); + return ( +
    +
    + + changeRow(row.rowKey, { providerGameId: event.target.value }) + } + /> + changeRow(row.rowKey, { gameName: event.target.value })} + /> + changeRow(row.rowKey, { gameUrl: event.target.value })} + /> + +
    +
    + 素材与游戏参数 +
    + changeRow(row.rowKey, { iconUrl: event.target.value })} + /> + + changeRow(row.rowKey, { coverUrl: event.target.value }) + } + /> + {adapterType === "yomi_v4" ? ( + + changeRow(row.rowKey, { gameLevelUid: event.target.value }) + } + /> + ) : null} +
    +
    +
    + ); + })} +
    + ) : ( +
    当前无游戏目录
    + )} +
    + ); +} + +function withRowKeys(rows, prefix) { + return rows.map((row, index) => ({ ...row, rowKey: `${prefix}-${index}` })); +} + +function catalogSignature(rows) { + return JSON.stringify( + (rows || []).map(({ providerGameId, gameName, gameUrl, iconUrl, coverUrl, gameLevelUid }) => ({ + providerGameId, + gameName, + gameUrl, + iconUrl, + coverUrl, + gameLevelUid, + })), + ); +} diff --git a/src/features/games/components/AdapterConfigFields.module.css b/src/features/games/components/AdapterConfigFields.module.css new file mode 100644 index 0000000..5723611 --- /dev/null +++ b/src/features/games/components/AdapterConfigFields.module.css @@ -0,0 +1,162 @@ +.root { + display: grid; + min-width: 0; + gap: var(--space-4); +} + +.group, +.catalog { + display: grid; + min-width: 0; + gap: var(--space-3); +} + +.groupTitle { + color: var(--text-secondary); + font-size: 12px; + font-weight: 760; + line-height: 18px; +} + +.fieldGrid { + display: grid; + min-width: 0; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-3); +} + +.fieldGrid > :global(.MuiTextField-root), +.fieldGrid > :global(.MuiFormControl-root) { + width: 100%; +} + +.wideField { + grid-column: span 2; +} + +.invalid { + padding: 9px 12px; + border: 1px solid color-mix(in srgb, var(--danger) 30%, transparent); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--danger) 7%, transparent); + color: var(--danger); + font-size: 12px; + font-weight: 650; +} + +.catalogHeader { + display: flex; + min-height: 32px; + align-items: center; + justify-content: space-between; + gap: var(--space-3); +} + +.catalogRows { + display: grid; + gap: var(--space-2); +} + +.catalogRow { + display: grid; + min-width: 0; + gap: var(--space-2); + padding: var(--space-3); + border: 1px solid var(--border-soft); + border-radius: var(--radius-sm); + background: var(--bg-card-strong); +} + +.catalogMain { + display: grid; + min-width: 0; + grid-template-columns: minmax(130px, 0.55fr) minmax(150px, 0.7fr) minmax(260px, 1.5fr) auto; + align-items: start; + gap: var(--space-2); +} + +.catalogMain > :global(.MuiTextField-root) { + width: 100%; +} + +.rowDetails, +.details { + min-width: 0; + overflow: hidden; + border: 1px solid var(--border-soft); + border-radius: var(--radius-sm); + background: var(--bg-card-strong); +} + +.rowDetails { + border: 0; + background: transparent; +} + +.rowDetails > summary, +.details > summary { + padding: 9px 11px; + color: var(--text-tertiary); + cursor: pointer; + font-size: 12px; + font-weight: 700; + user-select: none; +} + +.rowDetails > summary { + padding: 2px 0; +} + +.details[open] > summary { + border-bottom: 1px solid var(--border-soft); +} + +.rowExtra { + display: grid; + min-width: 0; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-2); + padding-top: var(--space-2); +} + +.advancedEditor, +.details > .fieldGrid { + padding: var(--space-3); +} + +.empty { + display: grid; + min-height: 48px; + place-items: center; + border: 1px dashed var(--border); + border-radius: var(--radius-sm); + color: var(--text-tertiary); + font-size: 12px; +} + +@media (max-width: 900px) { + .fieldGrid, + .rowExtra { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .catalogMain { + grid-template-columns: minmax(120px, 0.6fr) minmax(150px, 0.8fr) minmax(220px, 1.4fr) auto; + } +} + +@media (max-width: 720px) { + .fieldGrid, + .rowExtra, + .catalogMain { + grid-template-columns: 1fr; + } + + .wideField { + grid-column: auto; + } + + .catalogMain > :global(button) { + justify-self: end; + } +} diff --git a/src/features/games/components/PlatformFormDialog.jsx b/src/features/games/components/PlatformFormDialog.jsx new file mode 100644 index 0000000..f7f42a9 --- /dev/null +++ b/src/features/games/components/PlatformFormDialog.jsx @@ -0,0 +1,210 @@ +import MenuItem from "@mui/material/MenuItem"; +import TextField from "@mui/material/TextField"; +import { AdapterConfigFields } from "@/features/games/components/AdapterConfigFields.jsx"; +import { + adapterDefinition, + adapterSecretState, + adapterTypeOptions, + migrateAdapterConfigJson, + readAdapterConfig, +} from "@/features/games/adapterConfigModel.js"; +import { + AdminFormDialog, + AdminFormFieldGrid, + AdminFormSection, + AdminFormSwitchField, +} from "@/shared/ui/AdminFormDialog.jsx"; +import styles from "@/features/games/components/PlatformFormDialog.module.css"; + +export function PlatformFormDialog({ form, loading, mode, open, page, onClose, onSubmit }) { + const definition = adapterDefinition(form.adapterType); + const configValid = Boolean(readAdapterConfig(form.adapterConfigJson)); + const disabled = !page.abilities.canUpdate; + const fixedAdapterType = adapterTypeOptions.find( + ([value]) => adapterDefinition(value).fixedPlatformCode === form.platformCode.trim(), + )?.[0]; + const { configuredSecretUsable, required: secretRequired } = adapterSecretState(form, mode === "edit"); + + const changeAdapter = (adapterType) => { + const previousDefinition = adapterDefinition(form.adapterType); + const nextDefinition = adapterDefinition(adapterType); + const adapterDrafts = { + ...(form.adapterDrafts || {}), + [form.adapterType]: { + adapterConfigJson: form.adapterConfigJson, + apiBaseUrl: form.apiBaseUrl, + callbackIpWhitelistText: form.callbackIpWhitelistText, + }, + }; + const savedDraft = adapterDrafts[adapterType]; + const next = { + ...form, + adapterType, + adapterDrafts, + // 不允许把上一厂商刚输入的签名密钥带进新协议;编辑迁移时下方会强制重新录入。 + callbackSecret: "", + // 每个厂商保留独立草稿,切回时恢复;首次切入才从公共字段和游戏目录生成兼容配置。 + adapterConfigJson: + savedDraft?.adapterConfigJson ?? + migrateAdapterConfigJson(form.adapterConfigJson, form.adapterType, adapterType), + apiBaseUrl: savedDraft?.apiBaseUrl ?? "", + callbackIpWhitelistText: savedDraft?.callbackIpWhitelistText ?? "", + }; + if (mode === "create" && nextDefinition.fixedPlatformCode) { + const canReplaceName = + !form.platformName.trim() || form.platformName.trim() === previousDefinition.fixedPlatformName; + // ZGame/AMG 的回调按固定 platformCode 查启动会话;这里必须锁定,不能让可保存但无法结算的配置进入库。 + next.platformCode = nextDefinition.fixedPlatformCode; + if (canReplaceName) { + next.platformName = nextDefinition.fixedPlatformName; + } + } else if ( + mode === "create" && + previousDefinition.fixedPlatformCode && + form.platformCode.trim() === previousDefinition.fixedPlatformCode + ) { + next.platformCode = ""; + if (form.platformName.trim() === previousDefinition.fixedPlatformName) { + next.platformName = ""; + } + } + page.setPlatformForm(next); + }; + + return ( + + + + page.setPlatformForm({ ...form, platformCode: event.target.value })} + /> + page.setPlatformForm({ ...form, platformName: event.target.value })} + /> + + page.setPlatformForm({ + ...form, + status: checked ? "active" : form.status === "disabled" ? "disabled" : "maintenance", + }) + } + /> + page.setPlatformForm({ ...form, sortOrder: event.target.value })} + /> + + + + + changeAdapter(event.target.value)} + > + {adapterTypeOptions.map(([value, label]) => { + const optionDefinition = adapterDefinition(value); + const fixedCodeMismatch = + mode === "edit" && + optionDefinition.fixedPlatformCode && + optionDefinition.fixedPlatformCode !== form.platformCode.trim(); + return ( + + {label} + + ); + })} + + {definition.apiUrl !== false ? ( + page.setPlatformForm({ ...form, apiBaseUrl: event.target.value })} + /> + ) : null} + page.setPlatformForm({ ...form, callbackSecret })} + /> + {definition.ipWhitelist ? ( + + page.setPlatformForm({ ...form, callbackIpWhitelistText: event.target.value }) + } + /> + ) : null} + + + + page.setPlatformForm({ ...form, adapterConfigJson })} + /> + + + ); +} + +function SecretField({ configured, disabled, label, placeholder, required, value, onChange }) { + const whitespaceOnly = required && Boolean(value) && !value.trim(); + return ( + onChange(event.target.value)} + /> + ); +} diff --git a/src/features/games/components/PlatformFormDialog.module.css b/src/features/games/components/PlatformFormDialog.module.css new file mode 100644 index 0000000..a04bfab --- /dev/null +++ b/src/features/games/components/PlatformFormDialog.module.css @@ -0,0 +1,3 @@ +.ipWhitelist { + grid-column: 1 / -1; +} diff --git a/src/features/games/games.module.css b/src/features/games/games.module.css index ebeb116..ff2b739 100644 --- a/src/features/games/games.module.css +++ b/src/features/games/games.module.css @@ -50,12 +50,6 @@ white-space: nowrap; } -.secretMeta { - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; - overflow-wrap: anywhere; - white-space: normal; -} - .switchOnlyField { display: inline-flex; min-height: 56px; diff --git a/src/features/games/hooks/useGamesPage.js b/src/features/games/hooks/useGamesPage.js index a4419aa..728216f 100644 --- a/src/features/games/hooks/useGamesPage.js +++ b/src/features/games/hooks/useGamesPage.js @@ -15,8 +15,13 @@ import { import { useGameAbilities } from "@/features/games/permissions.js"; import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js"; import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx"; -import { normalizeJsonObjectString } from "@/shared/ui/JsonEditorField.jsx"; import { useToast } from "@/shared/ui/ToastProvider.jsx"; +import { + adapterDefinition, + normalizeAdapterCallbackSecret, + normalizeAdapterConfigJson, + writableBridgeAdapterTypes, +} from "@/features/games/adapterConfigModel.js"; const defaultGameForm = { gameId: "", @@ -43,12 +48,14 @@ const defaultPlatformForm = { apiBaseUrl: "", // 新建平台必须选择真实厂商协议,避免把线上配置误落成 demo。 adapterType: "yomi_v4", - // 后台现在会回显厂商 key,编辑弹窗直接显示当前值;提交空值仍代表“不覆盖旧值”。 + initialAdapterType: "yomi_v4", + // 已保存密钥只回显“已配置”,不会重新进入表单状态;提交空值仍代表“不覆盖旧值”。 callbackSecret: "", callbackSecretSet: false, callbackIpWhitelistText: "", // 厂商扩展配置以 JSON 透传,服务端按 adapter_type 自己解析需要的字段。 adapterConfigJson: "{}", + adapterDrafts: {}, sortOrder: 0, }; @@ -57,17 +64,6 @@ const defaultBridgeScriptForm = { bridgeScriptVersion: "", }; -const writableBridgeAdapterTypes = new Set([ - "yomi_v4", - "leadercc_v1", - "zeeone_v1", - "baishun_v1", - "vivagames_v1", - "reyou_v1", - "zgame_v1", - "amg_v1", -]); - const emptyCatalog = { items: [], pageSize: 50 }; const emptyPlatforms = { items: [] }; const defaultPageSize = 50; @@ -302,7 +298,7 @@ export function useGamesPage() { event.preventDefault(); let payload; try { - payload = platformPayload(platformForm); + payload = platformPayload(platformForm, Boolean(editingPlatformCode)); } catch (err) { showToast(err.message || "适配器配置 JSON 不正确", "error"); return; @@ -784,11 +780,13 @@ function platformToForm(platform) { status: platform.status || "active", apiBaseUrl: platform.apiBaseUrl || "", adapterType: platform.adapterType || "yomi_v4", - // 使用后端返回的当前密钥,方便运营直接核对;用户清空提交时后端会保留旧值。 + initialAdapterType: platform.adapterType || "yomi_v4", + // 运营要求密钥明文可核对;后端有返回时直接进入编辑框,没有返回时仍保留“已配置”状态语义。 callbackSecret: platform.callbackSecret || "", callbackSecretSet: Boolean(platform.callbackSecretSet), callbackIpWhitelistText: (platform.callbackIpWhitelist || []).join("\n"), adapterConfigJson: platform.adapterConfigJson || "{}", + adapterDrafts: {}, sortOrder: Number(platform.sortOrder || 0), }; } @@ -823,24 +821,29 @@ function normalizeLaunchMode(value) { return "full_screen"; } -function platformPayload(form) { - const isLeaderCC = form.adapterType.trim() === "leadercc_v1"; +function platformPayload(form, editing) { + const adapterType = form.adapterType.trim() || "yomi_v4"; + const definition = adapterDefinition(adapterType); + const status = form.status.trim() || "active"; + const callbackSecret = normalizeAdapterCallbackSecret(form, editing); + // required 属性只能拦截空字符串,纯空格提交后会被 trim 成空并触发后端“保留旧密钥”;提交层必须再次校验。 return { platformCode: form.platformCode.trim(), platformName: form.platformName.trim(), - status: form.status.trim() || "active", - apiBaseUrl: isLeaderCC ? "" : form.apiBaseUrl.trim(), - adapterType: form.adapterType.trim() || "yomi_v4", + status, + apiBaseUrl: definition.apiUrl === false ? "" : form.apiBaseUrl.trim(), + adapterType, // 空密钥提交给后端后会保留旧值;非空才覆盖,用于安全轮换 key/AppSecret。 - callbackSecret: form.callbackSecret.trim(), + callbackSecret, // 支持换行或逗号分隔,方便直接粘贴厂商给的多 IP 白名单。 - callbackIpWhitelist: isLeaderCC - ? [] - : form.callbackIpWhitelistText + callbackIpWhitelist: definition.ipWhitelist + ? form.callbackIpWhitelistText .split(/[\n,]/) .map((item) => item.trim()) - .filter(Boolean), - adapterConfigJson: normalizeJsonObjectString(form.adapterConfigJson), + .filter(Boolean) + : [], + // 可视字段只 patch 当前 key;提交时再统一类型与历史别名,未知字段、游戏目录和桥接配置原样保留。 + adapterConfigJson: normalizeAdapterConfigJson(form.adapterConfigJson, adapterType), sortOrder: Number(form.sortOrder || 0), }; } diff --git a/src/features/games/pages/GameListPage.jsx b/src/features/games/pages/GameListPage.jsx index 68699e1..a6328a6 100644 --- a/src/features/games/pages/GameListPage.jsx +++ b/src/features/games/pages/GameListPage.jsx @@ -14,7 +14,6 @@ import { AdminFormDialog, AdminFormFieldGrid, AdminFormSection } from "@/shared/ import { Button } from "@/shared/ui/Button.jsx"; import { DataState } from "@/shared/ui/DataState.jsx"; import { DataTable } from "@/shared/ui/DataTable.jsx"; -import { JsonEditorField } from "@/shared/ui/JsonEditorField.jsx"; import { AdminActionIconButton, AdminListBody, @@ -27,6 +26,7 @@ import { UploadField } from "@/shared/ui/UploadField.jsx"; import { formatMillis } from "@/shared/utils/time.js"; import { useGamesPage } from "@/features/games/hooks/useGamesPage.js"; import { GameWhitelistDialog } from "@/features/games/components/GameWhitelistDialog.jsx"; +import { PlatformFormDialog } from "@/features/games/components/PlatformFormDialog.jsx"; import styles from "@/features/games/games.module.css"; const statusOptions = [ @@ -45,18 +45,6 @@ const launchModeOptions = [ ["half_screen", "半屏"], ["three_quarter_screen", "3/4屏"], ]; -// 这里的值必须和 game-service 的 adapter_type 白名单一致;新增厂商先后端登记,再放到后台可选项。 -const adapterTypeOptions = [ - ["yomi_v4", "小游 Yomi V4"], - ["leadercc_v1", "灵仙 LeaderCC V1"], - ["zeeone_v1", "ZeeOne V1"], - ["baishun_v1", "百顺 BAISHUN V1"], - ["vivagames_v1", "VIVAGAMES V1"], - ["reyou_v1", "热游 Reyou V1"], - ["zgame_v1", "ZGame V1"], - ["amg_v1", "AMG V1"], -]; - const baseColumns = [ { key: "game", @@ -487,108 +475,6 @@ function BridgeScriptDialog({ form, loading, open, page, onClose, onSubmit }) { ); } -function PlatformFormDialog({ form, loading, mode, open, page, onClose, onSubmit }) { - const isLeaderCC = form.adapterType === "leadercc_v1"; - return ( - - - - page.setPlatformForm({ ...form, platformCode: event.target.value })} - /> - page.setPlatformForm({ ...form, platformName: event.target.value })} - /> - - page.setPlatformForm({ - ...form, - status: checked ? "active" : form.status === "disabled" ? "disabled" : "maintenance", - }) - } - /> - page.setPlatformForm({ ...form, sortOrder: event.target.value })} - /> - - - - - {/* 适配器决定服务端启动参数、签名/解密和错误码映射。 */} - page.setPlatformForm({ ...form, adapterType: event.target.value })} - > - {adapterTypeOptions.map(([value, label]) => ( - - {label} - - ))} - - {!isLeaderCC ? ( - page.setPlatformForm({ ...form, apiBaseUrl: event.target.value })} - /> - ) : null} - {/* 后台直接回显当前厂商 key;清空保存时后端保持旧密钥,输入新值才轮换。 */} - page.setPlatformForm({ ...form, callbackSecret: event.target.value })} - /> - {!isLeaderCC ? ( - - page.setPlatformForm({ ...form, callbackIpWhitelistText: event.target.value }) - } - /> - ) : null} - {/* 厂商差异字段放 JSON:如 uid_mode、default_lang、token_ttl_seconds、game_level_uid。 */} - page.setPlatformForm({ ...form, adapterConfigJson })} - /> - - - - ); -} - function PlatformManageDialog({ open, page, onClose }) { const platforms = page.platformData.items || []; return ( @@ -613,9 +499,6 @@ function PlatformManageDialog({ open, page, onClose }) { {platform.platformCode} / {platform.adapterType || "未配置"} {platform.apiBaseUrl ? {platform.apiBaseUrl} : null} - - 密钥:{platform.callbackSecret || "未配置"} -
    diff --git a/src/shared/ui/JsonEditorField.jsx b/src/shared/ui/JsonEditorField.jsx index eeb7d65..1396cf5 100644 --- a/src/shared/ui/JsonEditorField.jsx +++ b/src/shared/ui/JsonEditorField.jsx @@ -6,6 +6,7 @@ import styles from "@/shared/ui/JsonEditorField.module.css"; export function JsonEditorField({ className = "", disabled = false, + formatter, label, minRows = 6, onChange, @@ -28,7 +29,8 @@ export function JsonEditorField({ if (!result.valid) { return; } - onChange(JSON.stringify(result.value, null, 2)); + // 厂商适配器可提供保留 int64 token 的格式化器;默认路径仍沿用普通 JSON 格式化。 + onChange(formatter ? formatter(value) : JSON.stringify(result.value, null, 2)); }; return (