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 }