feat(admin): add external management system

This commit is contained in:
zhx 2026-07-15 16:05:28 +08:00
parent 9045de6ad2
commit bcc3854114
58 changed files with 5056 additions and 3 deletions

View File

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

View File

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

13
external-admin/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#ffffff" />
<title>HYApp 外管</title>
</head>
<body>
<div id="external-admin-root"></div>
<script type="module" src="/external-admin/src/main.jsx"></script>
</body>
</html>

View File

@ -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 (
<Suspense fallback={<ExternalPageState loading />}>
<Routes>
<Route element={<LoginPage />} path="/login" />
<Route element={<RequireExternalAuth />}>
<Route element={<ChangePasswordPage />} path="/change-password" />
<Route element={<RequirePasswordChanged />}>
<Route element={<ExternalAdminLayout />}>
<Route element={<Navigate replace to="/overview" />} index />
<Route element={<OverviewPage />} path="/overview" />
<Route element={<RequireExternalCapability anyOf={["user:list", "user:update", "user:ban", "user-level:grant"]} />}>
<Route element={<UsersPage />} path="/users" />
</Route>
<Route element={<RequireExternalCapability anyOf={["user-ban:list", "user:unban"]} />}>
<Route element={<BansPage />} path="/bans" />
</Route>
<Route element={<RequireExternalCapability anyOf={["host:list", "agency:list", "bd:list", "bd-manager:list", "super-admin:list", "team:view"]} />}>
<Route element={<OrganizationPage />} path="/organization/:kind" />
</Route>
<Route element={<RequireExternalCapability anyOf={["room:list", "room:update"]} />}>
<Route element={<RoomsPage />} path="/rooms" />
</Route>
<Route element={<RequireExternalCapability anyOf={["privilege:list", "privilege:grant", "pretty-id:grant", "user-title:grant", "room-background:grant"]} />}>
<Route element={<GrantsPage />} path="/grants" />
</Route>
<Route element={<RequireExternalCapability anyOf={["banner:create"]} />}>
<Route element={<BannersPage />} path="/banners" />
</Route>
<Route element={<Navigate replace to="/overview" />} path="*" />
</Route>
</Route>
</Route>
</Routes>
</Suspense>
);
}

View File

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

View File

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

View File

@ -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(?=\/)/, "");
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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 <ExternalAuthContext.Provider value={value}>{children}</ExternalAuthContext.Provider>;
}
export function useExternalAuth() {
const context = useContext(ExternalAuthContext);
if (!context) {
throw new Error("useExternalAuth must be used inside ExternalAuthProvider");
}
return context;
}

View File

@ -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 (
<Box className="external-full-state" role="status">
<CircularProgress size={28} />
<Typography color="text.secondary">正在校验外管会话</Typography>
</Box>
);
}
if (!session) {
if (initializationError) {
return (
<Box className="external-full-state">
<Alert severity="error">{initializationError}</Alert>
<Button onClick={refresh}>重新校验</Button>
</Box>
);
}
return <Navigate replace state={{ from: location }} to="/login" />;
}
return <Outlet />;
}
export function RequirePasswordChanged() {
const { session } = useExternalAuth();
return session?.passwordChangeRequired ? <Navigate replace to="/change-password" /> : <Outlet />;
}
export function RequireExternalCapability({ anyOf }) {
const { session } = useExternalAuth();
if (hasAnyExternalCapability(session, anyOf)) {
return <Outlet />;
}
return (
<Box className="external-page external-full-state">
<Alert severity="warning">当前外管账号没有访问此功能的权限</Alert>
<Button component="a" href="/external-admin/overview" variant="outlined">
返回权限总览
</Button>
</Box>
);
}

View File

@ -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 (
<Dialog fullWidth maxWidth="sm" open={open} onClose={loading ? undefined : onClose}>
<Stack component="form" onSubmit={submit}>
<DialogTitle>创建 Banner</DialogTitle>
<DialogContent>
<Stack className="external-dialog-fields" spacing={2}>
<ExternalImageUploadField disabled={loading} label="Banner 图片" value={form.coverUrl} onChange={(coverUrl) => setForm({ ...form, coverUrl })} />
{form.displayScopes.includes("room") ? <ExternalImageUploadField disabled={loading} label="房间内小屏图" value={form.roomSmallImageUrl} onChange={(roomSmallImageUrl) => setForm({ ...form, roomSmallImageUrl })} /> : null}
<TextField disabled={loading} label="平台" required select value={form.platform} onChange={(event) => setForm({ ...form, platform: event.target.value })}>
<MenuItem value="android">Android</MenuItem>
<MenuItem value="ios">iOS</MenuItem>
</TextField>
<TextField disabled={loading} label="描述" slotProps={{ htmlInput: { maxLength: 255 } }} value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} />
<AppJumpConfigField
allowCustom
appParamLabel="参数APP 公共跳转 JSON"
disabled={loading}
h5Label="参数H5 链接)"
paramValue={form.param}
required
typeLabel="跳转类型"
typeValue={form.bannerType}
onChange={({ param, type }) => setForm({ ...form, bannerType: type, param })}
/>
<TextField
disabled={loading}
label="展示范围"
required
select
slotProps={{ select: { multiple: true, renderValue: (selected) => 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]) => <MenuItem key={value} value={value}><Checkbox checked={form.displayScopes.includes(value)} /><ListItemText primary={label} /></MenuItem>)}
</TextField>
<Stack direction={{ xs: "column", sm: "row" }} spacing={1.5}>
<TextField disabled={loading} label="区域 ID" slotProps={{ htmlInput: { min: 0 } }} type="number" value={form.regionId} onChange={(event) => setForm({ ...form, regionId: event.target.value })} />
<TextField disabled={loading} helperText="2-3 位字母,留空为全部" label="国家码" value={form.countryCode} onChange={(event) => setForm({ ...form, countryCode: event.target.value.toUpperCase() })} />
<TextField disabled={loading} label="排序" slotProps={{ htmlInput: { min: 0 } }} type="number" value={form.sortOrder} onChange={(event) => setForm({ ...form, sortOrder: event.target.value })} />
</Stack>
<TimeRangeFilter
disabled={loading}
label="投放时间"
value={{ endMs: form.endsAtMs, startMs: form.startsAtMs }}
onChange={({ endMs, startMs }) => setForm({ ...form, endsAtMs: endMs, startsAtMs: startMs })}
/>
<TextField disabled={loading} label="状态" select value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value })}>
<MenuItem value="active">启用</MenuItem>
<MenuItem value="disabled">禁用</MenuItem>
<MenuItem value="expired">过期</MenuItem>
</TextField>
</Stack>
</DialogContent>
<DialogActions><Button disabled={loading} onClick={onClose}>取消</Button><Button disabled={loading || !form.coverUrl} type="submit" variant="contained">创建</Button></DialogActions>
</Stack>
</Dialog>
);
}
const initialForm = {
bannerType: "h5",
countryCode: "",
coverUrl: "",
description: "",
displayScopes: ["home"],
endsAtMs: 0,
param: "",
platform: "android",
regionId: "",
roomSmallImageUrl: "",
sortOrder: "0",
startsAtMs: 0,
status: "active"
};

View File

@ -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(<BannerCreateDialog open onClose={vi.fn()} onSubmit={vi.fn()} />);
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" }));
});
});

View File

@ -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 (
<GrantDialog loading={loading} open={open} title="特权道具发放" onClose={onClose} onSubmit={() => onSubmit(form)}>
<TargetNotice />
<TextField disabled={loading} label="用户 ID / 短 ID靓号" required value={form.target} onChange={(event) => setForm({ ...form, target: event.target.value.trim() })} />
<TextField disabled={loading} label="资源" required select value={form.resourceId} onChange={(event) => setForm({ ...form, resourceId: event.target.value })}>
{resources.map((item) => <MenuItem key={item.id} value={item.id}>{item.name}{item.id}</MenuItem>)}
</TextField>
<TextField disabled={loading} label="数量" required slotProps={{ htmlInput: { min: 1 } }} type="number" value={form.quantity} onChange={(event) => setForm({ ...form, quantity: event.target.value })} />
<TextField disabled={loading} helperText="填 0 表示永久有效" label="有效天数" required slotProps={{ htmlInput: { min: 0 } }} type="number" value={form.durationDays} onChange={(event) => setForm({ ...form, durationDays: event.target.value })} />
<TextField disabled={loading} label="发放原因" minRows={2} multiline required value={form.reason} onChange={(event) => setForm({ ...form, reason: event.target.value })} />
</GrantDialog>
);
}
export function PrettyIdGrantDialog({ loading, onClose, onSubmit, open }) {
const [form, setForm] = useState(prettyGrantInitial);
useEffect(() => setForm(prettyGrantInitial), [open]);
return (
<GrantDialog loading={loading} open={open} title="靓号下发" onClose={onClose} onSubmit={() => onSubmit(form)}>
<TargetNotice />
<TextField disabled={loading} label="用户 ID / 当前短 ID" required value={form.target} onChange={(event) => setForm({ ...form, target: event.target.value.trim() })} />
<TextField disabled={loading} label="下发靓号" required slotProps={{ htmlInput: { inputMode: "numeric", pattern: "[0-9]*" } }} value={form.prettyId} onChange={(event) => setForm({ ...form, prettyId: event.target.value.trim() })} />
<TextField disabled={loading} helperText="填 0 表示永久有效" label="有效天数" required slotProps={{ htmlInput: { min: 0 } }} type="number" value={form.durationDays} onChange={(event) => setForm({ ...form, durationDays: event.target.value })} />
<TextField disabled={loading} label="下发原因" minRows={2} multiline required value={form.reason} onChange={(event) => setForm({ ...form, reason: event.target.value })} />
</GrantDialog>
);
}
function TargetNotice() {
return <Alert severity="info">提交前会校验当前 App 内的用户并使用服务端返回的用户 ID 执行发放</Alert>;
}
function GrantDialog({ children, loading, onClose, onSubmit, open, title }) {
return (
<Dialog fullWidth maxWidth="sm" open={open} onClose={loading ? undefined : onClose}>
<Stack component="form" onSubmit={(event) => { event.preventDefault(); onSubmit(); }}>
<DialogTitle>{title}</DialogTitle>
<DialogContent><Stack className="external-dialog-fields" spacing={2}>{children}</Stack></DialogContent>
<DialogActions><Button disabled={loading} onClick={onClose}>取消</Button><Button disabled={loading} type="submit" variant="contained">确认发放</Button></DialogActions>
</Stack>
</Dialog>
);
}
const resourceGrantInitial = { durationDays: "30", quantity: "1", reason: "", resourceId: "", target: "" };
const prettyGrantInitial = { durationDays: "0", prettyId: "", reason: "", target: "" };

View File

@ -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 (
<Dialog fullWidth maxWidth="sm" open={open} onClose={loading ? undefined : onClose}>
<Stack component="form" onSubmit={(event) => { event.preventDefault(); onSubmit(form); }}>
<DialogTitle>编辑房间</DialogTitle>
<DialogContent><Stack className="external-dialog-fields" spacing={2}>
<ExternalImageUploadField disabled={loading} label="房间背景图" value={form.coverUrl} onChange={(coverUrl) => setForm({ ...form, coverUrl })} />
<TextField disabled={loading} label="房间名称" required value={form.title} onChange={(event) => setForm({ ...form, title: event.target.value })} />
<TextField disabled={loading} label="房间介绍" minRows={3} multiline value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} />
<TextField disabled={loading} label="可见区域 ID" value={form.visibleRegionId} onChange={(event) => setForm({ ...form, visibleRegionId: event.target.value.trim() })} />
</Stack></DialogContent>
<DialogActions><Button disabled={loading} onClick={onClose}>取消</Button><Button disabled={loading} type="submit" variant="contained">保存</Button></DialogActions>
</Stack>
</Dialog>
);
}

View File

@ -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 (
<BaseDialog loading={loading} open={open} title="编辑用户" onClose={onClose} onSubmit={() => onSubmit(form)}>
<ExternalImageUploadField disabled={loading} label="头像" value={form.avatar} onChange={(avatar) => setForm({ ...form, avatar })} />
<TextField disabled={loading} label="用户名称" required value={form.username} onChange={(event) => setForm({ ...form, username: event.target.value })} />
<TextField disabled={loading} label="性别" select value={form.gender} onChange={(event) => setForm({ ...form, gender: event.target.value })}>
<MenuItem value="">未设置</MenuItem><MenuItem value="male"></MenuItem><MenuItem value="female"></MenuItem><MenuItem value="unknown">未知</MenuItem>
</TextField>
<TextField disabled={loading} label="国家/地区码" value={form.country} onChange={(event) => setForm({ ...form, country: event.target.value.trim().toUpperCase() })} />
</BaseDialog>
);
}
export function UserBanDialog({ loading, onClose, onSubmit, open, user }) {
const [form, setForm] = useState({ days: "7", reason: "" });
useEffect(() => setForm({ days: "7", reason: "" }), [user]);
return (
<BaseDialog loading={loading} open={open} title={`封禁用户 ${user?.prettyId || user?.username || ""}`} onClose={onClose} onSubmit={() => onSubmit(form)}>
<TextField disabled={loading} helperText="填 0 表示永久封禁" label="封禁天数" required slotProps={{ htmlInput: { min: 0 } }} type="number" value={form.days} onChange={(event) => setForm({ ...form, days: event.target.value })} />
<TextField disabled={loading} label="封禁原因" minRows={3} multiline required value={form.reason} onChange={(event) => setForm({ ...form, reason: event.target.value })} />
</BaseDialog>
);
}
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 (
<BaseDialog loading={loading} open={open} title={`等级下发 ${user?.prettyId || user?.username || ""}`} onClose={onClose} onSubmit={() => onSubmit(form)}>
<TextField disabled={loading} label="等级类型" select value={form.track} onChange={(event) => setForm({ ...form, track: event.target.value })}><MenuItem value="wealth">财富等级</MenuItem><MenuItem value="charm">魅力等级</MenuItem><MenuItem value="vip">VIP 等级</MenuItem></TextField>
<TextField disabled={loading} label="等级" required slotProps={{ htmlInput: { min: 1 } }} type="number" value={form.level} onChange={(event) => setForm({ ...form, level: event.target.value })} />
<TextField disabled={loading} helperText="有效期至少 1 天" label="有效天数" required slotProps={{ htmlInput: { min: 1 } }} type="number" value={form.days} onChange={(event) => setForm({ ...form, days: event.target.value })} />
<TextField disabled={loading} label="下发原因" minRows={2} multiline required value={form.reason} onChange={(event) => setForm({ ...form, reason: event.target.value })} />
</BaseDialog>
);
}
function BaseDialog({ children, loading, onClose, onSubmit, open, title }) {
const handleSubmit = (event) => {
event.preventDefault();
onSubmit();
};
return (
<Dialog fullWidth maxWidth="sm" open={open} onClose={loading ? undefined : onClose}>
<Stack component="form" onSubmit={handleSubmit}>
<DialogTitle>{title}</DialogTitle>
<DialogContent><Stack className="external-dialog-fields" spacing={2}>{children}</Stack></DialogContent>
<DialogActions><Button disabled={loading} onClick={onClose}>取消</Button><Button disabled={loading} type="submit" variant="contained">确认</Button></DialogActions>
</Stack>
</Dialog>
);
}
const emptyEditForm = { avatar: "", country: "", gender: "", username: "" };

View File

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

View File

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

View File

@ -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 = (
<Box className="external-sidebar" sx={{ width: drawerWidth }}>
<Box className="external-logo">
<Box className="external-logo-mark">HY</Box>
<Box>
<Typography fontWeight={750}>HYApp 外管</Typography>
<Typography color="text.secondary" variant="caption">{session?.appName || session?.appCode}</Typography>
</Box>
</Box>
<Divider />
<List className="external-nav">
{visibleNavigation.map((item) => {
const Icon = item.icon;
return (
<ListItemButton
component={NavLink}
key={item.path}
selected={location.pathname === item.path || location.pathname.startsWith(`${item.path}/`)}
to={item.path}
onClick={() => setDrawerOpen(false)}
>
<ListItemIcon><Icon fontSize="small" /></ListItemIcon>
<ListItemText primary={item.label} />
</ListItemButton>
);
})}
</List>
</Box>
);
return (
<Box className="external-shell">
<AppBar className="external-header" color="inherit" elevation={0} position="fixed" sx={{ ml: { md: `${drawerWidth}px` }, width: { md: `calc(100% - ${drawerWidth}px)` } }}>
<Toolbar>
{mobile ? <IconButton aria-label="展开菜单" onClick={() => setDrawerOpen(true)}><MenuOutlined /></IconButton> : null}
<Typography component="h2" fontWeight={700} noWrap>{current?.label || "外管后台"}</Typography>
<Box flex={1} />
<Stack alignItems="center" direction="row" spacing={1}>
<Avatar className="external-user-avatar">{String(session?.account || "管").slice(0, 1).toUpperCase()}</Avatar>
{!mobile ? (
<Box>
<Typography fontWeight={650} lineHeight={1.2}>{session?.account}</Typography>
<Typography color="text.secondary" variant="caption">{session?.appCode}{session?.displayName ? ` · ${session.displayName}` : ""}</Typography>
</Box>
) : null}
<Tooltip title="退出登录">
<Button aria-label="退出登录" color="inherit" onClick={handleLogout} startIcon={<LogoutOutlined />}>{mobile ? "" : "退出"}</Button>
</Tooltip>
</Stack>
</Toolbar>
</AppBar>
<Drawer open={drawerOpen} variant="temporary" onClose={() => setDrawerOpen(false)} ModalProps={{ keepMounted: true }} sx={{ display: { md: "none" }, "& .MuiDrawer-paper": { width: drawerWidth } }}>{drawer}</Drawer>
<Drawer open variant="permanent" sx={{ display: { xs: "none", md: "block" }, "& .MuiDrawer-paper": { width: drawerWidth } }}>{drawer}</Drawer>
<Box className="external-main" component="main" sx={{ ml: { md: `${drawerWidth}px` } }}>
<Toolbar />
<Outlet />
</Box>
</Box>
);
}

View File

@ -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(
<React.StrictMode>
<ThemeProvider theme={theme}>
<ToastProvider>
<CssBaseline />
<BrowserRouter basename="/external-admin">
<ExternalAuthProvider>
<ExternalAdminApp />
</ExternalAuthProvider>
</BrowserRouter>
</ToastProvider>
</ThemeProvider>
</React.StrictMode>
);

View File

@ -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) => <Stack alignItems="center" direction="row" spacing={1}>{item.coverUrl ? <Box alt={item.description} className="external-banner-thumb" component="img" src={item.coverUrl} /> : null}<Box><Typography fontWeight={650}>{item.description || "-"}</Typography><Typography color="text.secondary" variant="caption">ID {item.id || "-"}</Typography></Box></Stack> },
{ 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) => <StatusChip status={item.status} /> }
], []);
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 (
<ExternalPage actions={<Button onClick={() => setOpen(true)} startIcon={<AddOutlined />} variant="contained">创建 Banner</Button>} title="Banner 管理">
<ExternalPageState error={error} loading={loading} onRetry={reload} />
{!loading && !error ? <><ResponsiveDataList columns={columns} items={data.items} rowKey={(item) => item.id} /><PagePagination {...data} onChange={setPage} /></> : null}
<BannerCreateDialog loading={submitting} open={open} onClose={() => setOpen(false)} onSubmit={submit} />
</ExternalPage>
);
}

View File

@ -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) => <Stack alignItems="center" direction="row" spacing={1}><Avatar src={user.avatar}>{user.username.slice(0, 1)}</Avatar><Box><Typography fontWeight={650}>{user.username || "-"}</Typography><Typography color="text.secondary" variant="caption">ID {user.id}</Typography></Box></Stack> },
{ key: "prettyId", label: "短 ID靓号", render: (user) => user.prettyId || "-" },
{ key: "reason", label: "封禁原因", render: (user) => user.reason || "-" },
{ key: "status", label: "状态", render: () => <StatusChip status="banned" /> },
{ key: "actions", label: "操作", render: (user) => canUnban ? <Button onClick={() => setTarget(user)} startIcon={<LockOpenOutlined />}>解除封禁</Button> : "-" }
], [canUnban]);
return (
<ExternalPage title="账号封禁列表">
<Stack className="external-filter-bar" component="form" direction={{ xs: "column", sm: "row" }} spacing={1.5} onSubmit={(event) => { event.preventDefault(); setQuery({ keyword, page: 1 }); }}>
<TextField label="用户 ID / 短 ID / 名称" value={keyword} onChange={(event) => setKeyword(event.target.value)} />
<Button type="submit" variant="contained">查询</Button>
</Stack>
<ExternalPageState error={error} loading={loading} onRetry={reload} />
{!loading && !error ? <><ResponsiveDataList columns={columns} items={data.items} rowKey={(item) => item.id} /><PagePagination {...data} onChange={(page) => setQuery({ ...query, page })} /></> : null}
<ConfirmDialog confirmColor="primary" content={`确认解除用户 ${target?.prettyId || target?.username || ""} 的封禁?`} loading={submitting} open={Boolean(target)} title="解除封禁" onClose={() => setTarget(null)} onConfirm={handleUnban} />
</ExternalPage>
);
}

View File

@ -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 (
<Box className="external-login-page">
<Card className="external-login-card" elevation={0}>
<CardContent>
<Stack alignItems="center" spacing={1.5}><Avatar className="external-login-avatar"><KeyOutlined /></Avatar><Typography component="h1" variant="h5">修改外管密码</Typography><Typography color="text.secondary">{session?.passwordChangeRequired ? "首次登录必须修改密码" : session?.account}</Typography></Stack>
<Stack component="form" spacing={2} onSubmit={submit}>
{error ? <Alert severity="error">{error}</Alert> : null}
<TextField autoComplete="current-password" disabled={submitting} label="当前密码" required type="password" value={form.oldPassword} onChange={(event) => setForm({ ...form, oldPassword: event.target.value })} />
<TextField autoComplete="new-password" disabled={submitting} helperText="至少 8 个字符" label="新密码" required type="password" value={form.newPassword} onChange={(event) => setForm({ ...form, newPassword: event.target.value })} />
<TextField autoComplete="new-password" disabled={submitting} label="确认新密码" required type="password" value={form.confirmPassword} onChange={(event) => setForm({ ...form, confirmPassword: event.target.value })} />
<Stack direction="row" spacing={1}><Button disabled={submitting} fullWidth onClick={cancel} variant="outlined">退出登录</Button><Button disabled={submitting} fullWidth type="submit" variant="contained">保存密码</Button></Stack>
</Stack>
</CardContent>
</Card>
</Box>
);
}

View File

@ -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(<MemoryRouter><ChangePasswordPage /></MemoryRouter>);
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(<MemoryRouter><ChangePasswordPage /></MemoryRouter>);
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 " });
});
});

View File

@ -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) => <StatusChip status={item.status} /> },
{ key: "reason", label: "原因", render: (item) => String(item.reason || "-") }
], []);
return (
<ExternalPage
actions={<>{canGrant ? <Button disabled={resourcesState.loading} onClick={() => setDialog("resource")} startIcon={<CardGiftcardOutlined />} variant="contained">发放道具</Button> : null}{canGrantPrettyId ? <Button onClick={() => setDialog("pretty")} startIcon={<NumbersOutlined />} variant="outlined">下发靓号</Button> : null}</>}
title="资源与靓号"
>
{canGrant ? (
<Box className="external-form-panel">
<Typography fontWeight={700} gutterBottom>可发放资源</Typography>
<Stack direction="row" flexWrap="wrap" gap={1}>
{resourcesState.data.items.slice(0, 12).map((resource) => <Stack alignItems="center" direction="row" key={resource.id} spacing={0.75}><Avatar src={resource.coverUrl} sx={{ height: 28, width: 28 }} variant="rounded"></Avatar><Typography variant="body2">{resource.name}</Typography></Stack>)}
{!resourcesState.loading && !resourcesState.data.items.length ? <Typography color="text.secondary">当前无数据</Typography> : null}
</Stack>
</Box>
) : null}
{canList ? <><ExternalPageState error={grantsState.error} loading={grantsState.loading} onRetry={grantsState.reload} />{!grantsState.loading && !grantsState.error ? <><ResponsiveDataList columns={columns} items={grantsState.data.items} rowKey={(item, index) => item.grantId || item.grant_id || index} /><PagePagination {...grantsState.data} onChange={setPage} /></> : null}</> : null}
<ResourceGrantDialog loading={submitting} open={dialog === "resource"} resources={resourcesState.data.items} onClose={() => setDialog("")} onSubmit={submitResource} />
<PrettyIdGrantDialog loading={submitting} open={dialog === "pretty"} onClose={() => setDialog("")} onSubmit={submitPrettyId} />
</ExternalPage>
);
}
function createCommandId(prefix) {
return `${prefix}-${globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`}`;
}

View File

@ -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 (
<Box className="external-login-page">
<Card className="external-login-card" elevation={0}>
<CardContent>
<Stack alignItems="center" spacing={1.5}>
<Avatar className="external-login-avatar"><LockOutlined /></Avatar>
<Typography component="h1" variant="h5">外管后台登录</Typography>
</Stack>
<Stack component="form" spacing={2.25} onSubmit={handleSubmit}>
{error ? <Alert severity="error">{error}</Alert> : null}
{appsError ? <Alert action={<Button color="inherit" onClick={loadApps} size="small">重试</Button>} severity="error">{appsError}</Alert> : null}
{apps.length || appsLoading ? (
<FormControl disabled={appsLoading || submitting} fullWidth required>
<InputLabel id="external-login-app-label">App</InputLabel>
<Select label="App" labelId="external-login-app-label" value={form.appCode} onChange={(event) => setForm({ ...form, appCode: event.target.value })}>
{apps.map((app) => <MenuItem key={app.appCode} value={app.appCode}>{app.name}{app.appCode}</MenuItem>)}
</Select>
</FormControl>
) : (
<TextField disabled={submitting} label="App Code" required value={form.appCode} onChange={(event) => setForm({ ...form, appCode: event.target.value.trim() })} />
)}
<TextField autoComplete="username" disabled={submitting} label="外管账号" required value={form.account} onChange={(event) => setForm({ ...form, account: event.target.value })} />
<TextField
autoComplete="current-password"
disabled={submitting}
label="密码"
required
type={showPassword ? "text" : "password"}
value={form.password}
onChange={(event) => setForm({ ...form, password: event.target.value })}
slotProps={{ input: { endAdornment: <InputAdornment position="end"><IconButton aria-label={showPassword ? "隐藏密码" : "显示密码"} edge="end" onClick={() => setShowPassword((current) => !current)}>{showPassword ? <VisibilityOffOutlined /> : <VisibilityOutlined />}</IconButton></InputAdornment> } }}
/>
<Button disabled={appsLoading || submitting} size="large" type="submit" variant="contained">
{submitting ? <CircularProgress color="inherit" size={20} /> : "登录"}
</Button>
</Stack>
</CardContent>
</Card>
</Box>
);
}

View File

@ -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) => <Box><Typography fontWeight={650}>公会 {item.id || "-"}</Typography><Typography color="text.secondary" variant="caption">Owner {item.userId || "-"}</Typography></Box> },
{ key: "parent", label: "上级 BD", render: (item) => item.parentBdUserId || "-" },
{ key: "status", label: "状态", render: (item) => <StatusChip status={item.status} /> }
] : [
{ key: "identity", label: "成员", render: (item) => <Stack alignItems="center" direction="row" spacing={1}><Avatar src={item.avatar}>{(item.name || item.username || "-").slice(0, 1)}</Avatar><Box><Typography fontWeight={650}>{item.name || item.username || "-"}</Typography><Typography color="text.secondary" variant="caption">ID {item.id || "-"}{item.userId && item.userId !== item.id ? ` / 用户 ${item.userId}` : ""}</Typography></Box></Stack> },
{ key: "prettyId", label: "短 ID靓号", render: (item) => item.prettyId || "-" },
{ key: "country", label: "国家/地区", render: (item) => item.country || "-" },
{ key: "status", label: "状态", render: (item) => <StatusChip status={item.status} /> }
], [kind]);
if (!config || !allowed) {
return <ExternalPage title={config?.title || "组织管理"}><Alert severity="warning">当前外管账号没有访问该组织列表的权限</Alert></ExternalPage>;
}
return (
<ExternalPage title={config.title}>
<Stack className="external-filter-bar" component="form" direction={{ xs: "column", sm: "row" }} spacing={1.5} onSubmit={(event) => { event.preventDefault(); setQuery({ keyword, page: 1 }); }}>
<TextField label={kind === "team" ? "公会 ID / Owner 用户 ID / 上级 BD ID" : "用户 ID / 短 ID / 名称"} value={keyword} onChange={(event) => setKeyword(event.target.value)} />
<Button type="submit" variant="contained">查询</Button>
</Stack>
<ExternalPageState error={error} loading={loading} onRetry={reload} />
{kind === "team" && !loading && !error ? (
<Box className="external-form-panel">
<Stack direction={{ xs: "column", sm: "row" }} spacing={{ xs: 1, sm: 4 }}>
<Typography>BD Leader<strong>{data.totalBdLeaders || 0}</strong></Typography>
<Typography>BD<strong>{data.totalBds || 0}</strong></Typography>
<Typography>公会<strong>{data.total || data.items.length}</strong></Typography>
</Stack>
{data.truncated ? <Alert severity="warning" sx={{ mt: 2 }}>团队规模超过接口上限当前结果不完整</Alert> : null}
</Box>
) : null}
{!loading && !error ? <><ResponsiveDataList columns={columns} items={data.items} rowKey={(item, index) => item.id || index} /><PagePagination {...data} onChange={(page) => setQuery({ ...query, page })} /></> : null}
</ExternalPage>
);
}

View File

@ -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 (
<ExternalPage title="权限总览">
<Card className="external-session-card" variant="outlined">
<CardContent>
<Stack direction={{ xs: "column", sm: "row" }} spacing={{ xs: 1, sm: 4 }}>
<Box><Typography color="text.secondary" variant="caption">当前 App</Typography><Typography fontWeight={700}>{session.appName || session.appCode}{session.appCode}</Typography></Box>
<Box><Typography color="text.secondary" variant="caption">外管账号</Typography><Typography fontWeight={700}>{session.account}</Typography></Box>
<Box><Typography color="text.secondary" variant="caption">已开通权限</Typography><Typography fontWeight={700}>{EXTERNAL_CAPABILITIES.filter((item) => hasExternalCapability(session, item.code)).length} / {EXTERNAL_CAPABILITIES.length}</Typography></Box>
</Stack>
</CardContent>
</Card>
<Box className="external-capability-grid">
{EXTERNAL_CAPABILITIES.map((item) => {
const enabled = hasExternalCapability(session, item.code);
const content = (
<CardContent>
<Stack alignItems="flex-start" spacing={1.5}>
{enabled ? <CheckCircleOutlineOutlined color="success" /> : <LockOutlined color="disabled" />}
<Typography fontWeight={700}>{item.label}</Typography>
<Chip color={enabled ? "success" : "default"} label={enabled ? "已开通" : "未开通"} size="small" variant="outlined" />
</Stack>
</CardContent>
);
return (
<Card className={enabled ? "external-capability-card is-enabled" : "external-capability-card"} key={item.code} variant="outlined">
{enabled ? <CardActionArea component={Link} to={item.path}>{content}</CardActionArea> : content}
</Card>
);
})}
</Box>
</ExternalPage>
);
}

View File

@ -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(<MemoryRouter><OverviewPage /></MemoryRouter>);
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();
});
});

View File

@ -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) => <Stack alignItems="center" direction="row" spacing={1}><Avatar src={item.coverUrl} variant="rounded"></Avatar><Box><Typography fontWeight={650}>{item.title || "-"}</Typography><Typography color="text.secondary" variant="caption">ID {item.id}</Typography></Box></Stack> },
{ key: "owner", label: "房主", render: (item) => item.ownerName || "-" },
{ key: "region", label: "可见区域", render: (item) => item.visibleRegionId || "全部" },
{ key: "status", label: "状态", render: (item) => <StatusChip status={item.status} /> },
{ key: "actions", label: "操作", render: (item) => canEdit ? <Button onClick={() => setRoom(item)} startIcon={<EditOutlined />}>编辑</Button> : "-" }
], [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 (
<ExternalPage title="房间管理">
<Stack className="external-filter-bar" component="form" direction={{ xs: "column", sm: "row" }} spacing={1.5} onSubmit={(event) => { event.preventDefault(); setQuery({ keyword, page: 1 }); }}><TextField label="房间 ID / 名称 / 房主" value={keyword} onChange={(event) => setKeyword(event.target.value)} /><Button type="submit" variant="contained">查询</Button></Stack>
<ExternalPageState error={error} loading={loading} onRetry={reload} />
{!loading && !error ? <><ResponsiveDataList columns={columns} items={data.items} rowKey={(item) => item.id} /><PagePagination {...data} onChange={(page) => setQuery({ ...query, page })} /></> : null}
<RoomEditDialog loading={submitting} open={Boolean(room)} room={room} onClose={() => setRoom(null)} onSubmit={submit} />
</ExternalPage>
);
}

View File

@ -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) => <Stack alignItems="center" direction="row" spacing={1}><Avatar src={user.avatar}>{user.username.slice(0, 1)}</Avatar><Box><Typography fontWeight={650}>{user.username || "-"}</Typography><Typography color="text.secondary" variant="caption">ID {user.id}</Typography></Box></Stack> },
{ 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) => <StatusChip status={user.banned ? "banned" : user.status} /> },
{ key: "actions", label: "操作", render: (user) => <Stack direction="row" flexWrap="wrap" gap={0.5}>{canEdit ? <Button onClick={() => setDialog({ type: "edit", user })} startIcon={<EditOutlined />}>编辑</Button> : null}{canGrantLevel ? <Button onClick={() => setDialog({ type: "level", user })} startIcon={<TrendingUpOutlined />}>等级</Button> : null}{canBan && !user.banned ? <Button color="error" onClick={() => setDialog({ type: "ban", user })} startIcon={<GppBadOutlined />}>封禁</Button> : null}</Stack> }
], [canBan, canEdit, canGrantLevel]);
return (
<ExternalPage title="用户管理">
<Stack className="external-filter-bar" component="form" direction={{ xs: "column", sm: "row" }} spacing={1.5} onSubmit={(event) => { event.preventDefault(); setQuery({ ...filters, page: 1 }); }}>
<TextField label="用户 ID / 短 ID / 名称" value={filters.keyword} onChange={(event) => setFilters({ ...filters, keyword: event.target.value })} />
<TextField label="状态" select value={filters.status} onChange={(event) => setFilters({ ...filters, status: event.target.value })}><MenuItem value="">全部状态</MenuItem><MenuItem value="active">正常</MenuItem><MenuItem value="banned">已封禁</MenuItem></TextField>
<Button type="submit" variant="contained">查询</Button>
</Stack>
<ExternalPageState error={error} loading={loading} onRetry={reload} />
{!loading && !error ? <><ResponsiveDataList columns={columns} items={data.items} rowKey={(item) => item.id} /><PagePagination {...data} onChange={(page) => setQuery({ ...query, page })} /></> : null}
<UserEditDialog loading={submitting} open={dialog.type === "edit"} user={dialog.user} onClose={closeDialog} onSubmit={(form) => execute(() => updateUser(dialog.user.id, form), "用户信息已更新")} />
<UserBanDialog loading={submitting} open={dialog.type === "ban"} user={dialog.user} onClose={closeDialog} onSubmit={(form) => execute(() => banUser(dialog.user.id, { expires_at_ms: Number(form.days) === 0 ? 0 : Date.now() + Number(form.days) * 86400000, reason: form.reason.trim() }), "用户已封禁")} />
<UserLevelDialog loading={submitting} open={dialog.type === "level"} user={dialog.user} onClose={closeDialog} onSubmit={(form) => 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() }), "用户等级已下发")} />
</ExternalPage>
);
}
function createLevelCommandId() {
return `external-level-grant-${globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`}`;
}

View File

@ -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 (
<Dialog fullWidth maxWidth="xs" open={open} onClose={loading ? undefined : onClose}>
<DialogTitle>{title}</DialogTitle>
<DialogContent><DialogContentText>{content}</DialogContentText></DialogContent>
<DialogActions><Button disabled={loading} onClick={onClose}>取消</Button><Button color={confirmColor} disabled={loading} onClick={onConfirm} variant="contained">确认</Button></DialogActions>
</Dialog>
);
}

View File

@ -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 (
<Stack spacing={1}>
<Typography color="text.secondary" variant="body2">{label}</Typography>
<Box className="external-image-field">
{value ? <img alt={`${label}预览`} src={value} /> : <Box className="external-image-placeholder">暂无图片</Box>}
<Button
disabled={disabled || uploading}
onClick={() => inputRef.current?.click()}
startIcon={uploading ? <CircularProgress size={16} /> : <CloudUploadOutlined />}
variant="outlined"
>
{uploading ? "上传中" : value ? "更换图片" : "上传图片"}
</Button>
<input accept="image/*" hidden ref={inputRef} type="file" onChange={handleFile} />
</Box>
{error ? <Alert severity="error">{error}</Alert> : null}
</Stack>
);
}

View File

@ -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 (
<Box className="external-page">
<Stack alignItems={{ md: "center" }} direction={{ xs: "column", md: "row" }} justifyContent="space-between" spacing={2}>
<Typography component="h1" variant="h5">
{title}
</Typography>
{actions ? <Stack direction="row" flexWrap="wrap" gap={1}>{actions}</Stack> : null}
</Stack>
{children}
</Box>
);
}
export function ExternalPageState({ error, loading, onRetry }) {
if (loading) {
return (
<Box aria-label="正在加载" className="external-skeleton" role="status">
<Skeleton height={46} variant="rounded" />
<Skeleton height="min(52vh, 520px)" variant="rounded" />
</Box>
);
}
if (error) {
return (
<Stack spacing={2}>
<Alert severity="error">{error}</Alert>
<Button onClick={onRetry} variant="outlined">重新加载</Button>
</Stack>
);
}
return null;
}
export function ResponsiveDataList({ columns, emptyText = "当前无数据", items, rowKey }) {
const theme = useTheme();
const mobile = useMediaQuery(theme.breakpoints.down("sm"));
if (!items.length) {
return <Box className="external-empty">{emptyText}</Box>;
}
if (mobile) {
return (
<Stack spacing={1.5}>
{items.map((item, index) => (
<Card key={rowKey(item, index)} variant="outlined">
<CardContent className="external-mobile-card">
{columns.map((column) => (
<Box className="external-mobile-row" key={column.key}>
<Typography color="text.secondary" component="span" variant="body2">{column.label}</Typography>
<Box>{column.render(item)}</Box>
</Box>
))}
</CardContent>
</Card>
))}
</Stack>
);
}
return (
<TableContainer className="external-table-container">
<Table size="small">
<TableHead>
<TableRow>{columns.map((column) => <TableCell key={column.key}>{column.label}</TableCell>)}</TableRow>
</TableHead>
<TableBody>
{items.map((item, index) => (
<TableRow hover key={rowKey(item, index)}>
{columns.map((column) => <TableCell key={column.key}>{column.render(item)}</TableCell>)}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
}
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 <Chip color={enabled ? "success" : dangerous ? "error" : "default"} label={status || "-"} size="small" variant="outlined" />;
}
export function PagePagination({ page, pageSize, total, onChange }) {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
return (
<Stack alignItems="center" direction="row" justifyContent="space-between" spacing={1}>
<Typography color="text.secondary" variant="body2"> {total} </Typography>
<Stack direction="row" spacing={1}>
<Button disabled={page <= 1} onClick={() => onChange(page - 1)} variant="outlined">上一页</Button>
<Typography className="external-page-number" variant="body2">{page} / {totalPages}</Typography>
<Button disabled={page >= totalPages} onClick={() => onChange(page + 1)} variant="outlined">下一页</Button>
</Stack>
</Stack>
);
}

View File

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

View File

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

View File

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

View File

@ -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配置" },

View File

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

View File

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

View File

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

View File

@ -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<ApiPage<ExternalAdminUserDto>> {
const endpoint = API_ENDPOINTS.listExternalAdminUsers;
const data = await apiRequest<RawPage<RawRecord>>(apiEndpointPath(API_OPERATIONS.listExternalAdminUsers), {
headers: appScopeHeaders(appCode),
method: endpoint.method,
query,
});
return normalizePage(data, query);
}
export async function lookupExternalAdminUserTarget(
appCode: string,
displayUserId: string,
): Promise<ExternalAdminUserTargetDto> {
const endpoint = API_ENDPOINTS.lookupExternalAdminUserTarget;
const data = await apiRequest<RawRecord>(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<ExternalAdminUserDto> {
const endpoint = API_ENDPOINTS.createExternalAdminUser;
const data = await apiRequest<RawRecord, CreateExternalAdminUserPayload>(
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<ExternalAdminUserDto> {
const endpoint = API_ENDPOINTS.updateExternalAdminUserStatus;
const data = await apiRequest<RawRecord, { status: ExternalAdminUserStatus }>(
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<ExternalAdminUserDto> {
const endpoint = API_ENDPOINTS.resetExternalAdminUserPassword;
const data = await apiRequest<RawRecord, { password: string }>(
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<string, unknown>;
interface RawPage<TItem> {
items?: TItem[];
page?: number;
page_size?: number;
pageSize?: number;
total?: number;
}
function normalizePage(data: RawPage<RawRecord>, query: PageQuery): ApiPage<ExternalAdminUserDto> {
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) : [];
}

View File

@ -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 (
<AdminFormDialog
disabled={!abilities.canCreate}
loading={loading}
open={open}
size="large"
submitDisabled={!abilities.canCreate || loading || !targetUser?.userId || Boolean(existingAccount)}
submitLabel="创建"
title="创建外管用户"
onClose={onClose}
onSubmit={onSubmit}
>
<AdminFormSection title="绑定 App 用户">
<div className={styles.lookupRow}>
<TextField
autoComplete="off"
disabled={!abilities.canCreate || loading}
label="用户短 ID / 靓号"
required
value={form.displayUserId}
onChange={(event) => onDisplayUserIdChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
onLookup();
}
}}
/>
<Button
className={styles.lookupButton}
disabled={!abilities.canCreate || loading || lookupLoading}
startIcon={<SearchOutlined fontSize="small" />}
onClick={onLookup}
>
{lookupLoading ? "搜索中" : "搜索"}
</Button>
</div>
{targetUser ? (
<div className={styles.targetPanel}>
<AdminUserIdentity
openInAppUserDetail
rows={[targetUser.displayUserId, targetUser.userId]}
size="large"
user={targetUser}
/>
{existingAccount ? (
<div className={styles.existingWarning}>
该用户已绑定外管账号 {existingAccount.username || existingAccount.id}不能重复创建
</div>
) : null}
</div>
) : null}
</AdminFormSection>
<AdminFormSection title="外管登录账号">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<TextField
autoComplete="off"
disabled={!abilities.canCreate || loading}
label="外管账号"
required
value={form.username}
onChange={(event) => onFormChange({ username: event.target.value })}
/>
<TextField
autoComplete="new-password"
disabled={!abilities.canCreate || loading}
label="密码"
required
type="password"
value={form.password}
onChange={(event) => onFormChange({ password: event.target.value })}
/>
<TextField
autoComplete="new-password"
disabled={!abilities.canCreate || loading}
label="确认密码"
required
type="password"
value={form.confirmPassword}
onChange={(event) => onFormChange({ confirmPassword: event.target.value })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
</AdminFormDialog>
);
}

View File

@ -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 (
<AdminFormDialog
disabled={!abilities.canResetPassword}
loading={loading}
open={Boolean(user)}
size="compact"
submitLabel="重置密码"
title="重置外管密码"
onClose={onClose}
onSubmit={onSubmit}
>
<AdminFormSection title="外管用户">
<AdminUserIdentity
rows={[user?.linkedUser?.displayUserId, user?.linkedUser?.userId]}
user={user?.linkedUser}
/>
</AdminFormSection>
<AdminFormSection title="新密码">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<TextField
autoComplete="new-password"
disabled={!abilities.canResetPassword || loading}
label="密码"
required
type="password"
value={form.password}
onChange={(event) => onFormChange({ password: event.target.value })}
/>
<TextField
autoComplete="new-password"
disabled={!abilities.canResetPassword || loading}
label="确认密码"
required
type="password"
value={form.confirmPassword}
onChange={(event) => onFormChange({ confirmPassword: event.target.value })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
</AdminFormDialog>
);
}

View File

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

View File

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

View File

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

View File

@ -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) => (
<AdminUserIdentity
openInAppUserDetail
rows={[item.linkedUser?.displayUserId, item.linkedUser?.userId]}
user={item.linkedUser}
/>
),
width: "minmax(250px, 1.2fr)",
},
{
key: "username",
label: "外管账号",
render: (item) => (
<div className={styles.accountCell}>
<span className={styles.accountName}>{item.username || "-"}</span>
<span className={styles.accountMeta}>ID {item.id || "-"}</span>
</div>
),
width: "minmax(190px, 0.95fr)",
},
{
key: "status",
label: "状态",
render: (item, _index, context) => {
const page = context?.page;
// loading
const accountActionInFlight = Boolean(page?.loadingAction);
return (
<AdminSwitch
checked={item.status === "active"}
checkedLabel="启用"
disabled={!page?.abilities.canStatus || accountActionInFlight}
label={item.status === "active" ? "停用外管用户" : "启用外管用户"}
uncheckedLabel="停用"
onChange={() => 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 (
<AdminRowActions>
<AdminActionIconButton
disabled={!page?.abilities.canResetPassword || Boolean(page.loadingAction)}
label="重置密码"
onClick={() => page.openPassword(item)}
>
<PasswordOutlined fontSize="small" />
</AdminActionIconButton>
</AdminRowActions>
);
},
resizable: false,
width: "88px",
},
];
export function ExternalAdminUsersPage() {
const page = useExternalAdminUsersPage();
const items = page.data.items || [];
const total = Number(page.data.total || 0);
return (
<AdminListPage>
<AdminListToolbar
actions={
<>
<AdminActionIconButton
component="a"
href="/external-admin/"
label="打开外管后台"
rel="noopener noreferrer"
target="_blank"
>
<OpenInNewOutlined fontSize="small" />
</AdminActionIconButton>
{page.abilities.canCreate ? (
<AdminActionIconButton
disabled={Boolean(page.loadingAction)}
label="创建外管用户"
primary
onClick={page.openCreate}
>
<PersonAddAltOutlined fontSize="small" />
</AdminActionIconButton>
) : null}
</>
}
filters={
<>
<AdminSearchBox
label="搜索"
placeholder="外管账号、短 ID"
value={page.keyword}
onChange={page.setKeyword}
/>
<AdminFilterSelect
label="状态"
options={statusOptions}
value={page.status}
onChange={page.setStatus}
/>
<AdminFilterResetButton
disabled={!page.keyword && !page.status}
onClick={page.resetFilters}
/>
</>
}
/>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<AdminListBody>
<DataTable
columns={columns}
context={{ page }}
items={items}
minWidth="1350px"
pagination={
total > 0
? {
onPageChange: page.setPage,
page: page.page,
pageSize: page.data.pageSize || page.pageSize,
total,
}
: undefined
}
rowKey={(item) => item.id || `${item.appCode}-${item.username}`}
/>
</AdminListBody>
</DataState>
<ExternalAdminUserFormDialog
abilities={page.abilities}
form={page.createForm}
loading={page.loadingAction === "create"}
lookupLoading={page.lookupLoading}
open={page.createOpen}
targetUser={page.targetUser}
onClose={page.closeCreate}
onDisplayUserIdChange={page.setDisplayUserId}
onFormChange={page.setCreateForm}
onLookup={page.lookupTarget}
onSubmit={page.submitCreate}
/>
<ExternalAdminUserPasswordDialog
abilities={page.abilities}
form={page.passwordForm}
loading={page.loadingAction.startsWith("password:")}
user={page.passwordUser}
onClose={page.closePassword}
onFormChange={page.setPasswordForm}
onSubmit={page.submitPassword}
/>
</AdminListPage>
);
}

View File

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

View File

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

View File

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

View File

@ -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<typeof externalAdminUserCreateFormSchema>;
export type ExternalAdminUserCreatePayload = z.output<typeof externalAdminUserCreateFormSchema>;
export type ExternalAdminUserPasswordForm = z.input<typeof externalAdminUserPasswordFormSchema>;
export type ExternalAdminUserPasswordPayload = z.output<typeof externalAdminUserPasswordFormSchema>;

View File

@ -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: "支付管理" },

View File

@ -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<ApiOperationId, ApiEndpoint> = {
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<ApiOperationId, ApiEndpoint> = {
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<ApiOperationId, ApiEndpoint> = {
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<ApiOperationId, ApiEndpoint> = {
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<ApiOperationId, ApiEndpoint> = {
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,

View File

@ -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?: {

View File

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